diff --git a/Projects/TDCore/Sources/Error/TDDataError.swift b/Projects/TDCore/Sources/Error/TDDataError.swift index 2393ce044..81cbbe440 100644 --- a/Projects/TDCore/Sources/Error/TDDataError.swift +++ b/Projects/TDCore/Sources/Error/TDDataError.swift @@ -14,6 +14,7 @@ public enum TDDataError: Error, Equatable { case parsingError case createRequestFailure case fetchRequestFailure + case permissionDenied /// 로그인 case invalidIDOrPassword @@ -56,6 +57,8 @@ extension TDDataError: CustomStringConvertible { "요청 생성 실패" case .fetchRequestFailure: "요청 실패" + case .permissionDenied: + "권한이 거부되었습니다." /// 로그인 관련 case .invalidIDOrPassword: diff --git a/Projects/TDData/Sources/DTO/ScheduleListResponseDTO.swift b/Projects/TDData/Sources/DTO/ScheduleListResponseDTO.swift index 4aeba87d1..eaf8eccce 100644 --- a/Projects/TDData/Sources/DTO/ScheduleListResponseDTO.swift +++ b/Projects/TDData/Sources/DTO/ScheduleListResponseDTO.swift @@ -55,7 +55,8 @@ public extension ScheduleHeadDTO { place: location, memo: memo, isFinished: false, - scheduleRecords: records + scheduleRecords: records, + source: .server ) } } diff --git a/Projects/TDData/Sources/DataAssembly.swift b/Projects/TDData/Sources/DataAssembly.swift index 95a997d31..58d70dbfa 100644 --- a/Projects/TDData/Sources/DataAssembly.swift +++ b/Projects/TDData/Sources/DataAssembly.swift @@ -70,7 +70,10 @@ public struct DataAssembly: Assembly { guard let service = container.resolve(ScheduleService.self) else { fatalError("ScheduleService is not registered") } - return ScheduleRepositoryImpl(service: service) + guard let storage = container.resolve(ScheduleStorage.self) else { + fatalError("ScheduleStorage is not registered") + } + return ScheduleRepositoryImpl(service: service, storage: storage) }.inObjectScope(.container) container.register(UserRepository.self) { _ in diff --git a/Projects/TDData/Sources/Repository/ScheduleRepositoryImpl.swift b/Projects/TDData/Sources/Repository/ScheduleRepositoryImpl.swift index dc06c1f53..fd3fad7ab 100644 --- a/Projects/TDData/Sources/Repository/ScheduleRepositoryImpl.swift +++ b/Projects/TDData/Sources/Repository/ScheduleRepositoryImpl.swift @@ -1,12 +1,18 @@ import TDCore +import EventKit import TDDomain import Foundation public final class ScheduleRepositoryImpl: ScheduleRepository { private let service: ScheduleService + private let storage: ScheduleStorage - public init(service: ScheduleService) { + public init( + service: ScheduleService, + storage: ScheduleStorage + ) { self.service = service + self.storage = storage } public func createSchedule(schedule: Schedule) async throws { @@ -14,35 +20,56 @@ public final class ScheduleRepositoryImpl: ScheduleRepository { try await service.createSchedule(schedule: scheduleRequestDTO) } - public func fetchScheduleList(startDate: String, endDate: String) async throws -> [Schedule] { + public func fetchServerScheduleList(startDate: String, endDate: String) async throws -> [Schedule] { let responseDTO = try await service.fetchScheduleList(startDate: startDate, endDate: endDate) return responseDTO.scheduleHeadDtos.map { $0.convertToSchedule() } } - public func fetchSchedule() async throws -> Schedule { - return - Schedule( - id: 0, - title: "title", - category: TDCategory(colorHex: "", imageName: ""), - startDate: "", - endDate: "", - isAllDay: false, - time: nil, + public func fetchLocalCalendarScheduleList(startDate: String, endDate: String) async throws -> [Schedule] { + let format = DateFormatType.yearMonthDay + let calendar = Calendar.current + + guard let startDay = Date.convertFromString(startDate, format: format), + let endDay = Date.convertFromString(endDate, format: format) else { + throw TDDataError.convertDTOFailure + } + + let rangeStartDate = calendar.startOfDay(for: startDay) + + guard let rangeEndDate = calendar.date(byAdding: .day, value: 1, to: calendar.startOfDay(for: endDay)) else { + throw TDDataError.convertDTOFailure + } + + let events = try await storage.fetchEvents(from: rangeStartDate, to: rangeEndDate) + + return mapToSchedules(from: events) + } + + private func mapToSchedules(from events: [EKEvent]) -> [Schedule] { + return events.map { event in + return Schedule( + id: event.eventIdentifier.hashValue, + title: event.title ?? "", + category: TDCategory(colorHex: "#FFFFFF", imageName: "none"), + startDate: event.startDate.convertToString(formatType: .yearMonthDay), + endDate: event.endDate.convertToString(formatType: .yearMonthDay), + isAllDay: event.isAllDay, + time: event.isAllDay ? nil : event.startDate.convertToString(formatType: .time24Hour), repeatDays: nil, alarmTime: nil, - place: nil, - memo: nil, + place: event.location, + memo: event.notes, isFinished: false, - scheduleRecords: nil + scheduleRecords: nil, + source: .localCalendar ) + } } public func finishSchedule(scheduleId: Int, isComplete: Bool, queryDate: String) async throws { try await service.finishSchedule(scheduleId: scheduleId, isComplete: isComplete, queryDate: queryDate) } - public func updateSchedule(scheduleId: Int, isOneDayDeleted: Bool, queryDate: String, scheduleData: Schedule) async throws { let scheduleData = ScheduleDataDTO(schedule: scheduleData) let scheduleUpdateRequestDTO = ScheduleUpdateRequestDTO( @@ -61,8 +88,4 @@ public final class ScheduleRepositoryImpl: ScheduleRepository { queryDate: queryDate ) } - - public func moveTomorrowSchedule(scheduleId: Int) async throws { - - } } diff --git a/Projects/TDData/Sources/StorageProtocol/ScheduleStorage.swift b/Projects/TDData/Sources/StorageProtocol/ScheduleStorage.swift new file mode 100644 index 000000000..3331ab3b1 --- /dev/null +++ b/Projects/TDData/Sources/StorageProtocol/ScheduleStorage.swift @@ -0,0 +1,15 @@ +import Foundation +import EventKit + +/// 캘린더 데이터 소스(Storage)가 수행해야 하는 기능을 정의하는 프로토콜입니다. +/// EventKit 프레임워크에 직접 접근하여 원시 데이터인 `EKEvent`를 가져오는 역할을 합니다. +public protocol ScheduleStorage { + + /// 지정된 기간에 해당하는 모든 캘린더 이벤트를 가져옵니다. + /// - Parameters: + /// - startDate: 조회를 시작할 날짜 + /// - endDate: 조회를 종료할 날짜 + /// - Returns: EventKit의 원시 데이터 모델인 `EKEvent`의 배열 + /// - Throws: 권한이 없거나 데이터를 가져오는 중 오류가 발생하면 에러를 던집니다. + func fetchEvents(from startDate: Date, to endDate: Date) async throws -> [EKEvent] +} diff --git a/Projects/TDDomain/Sources/DomainAssembly.swift b/Projects/TDDomain/Sources/DomainAssembly.swift index edfa37263..b07ad1ac1 100644 --- a/Projects/TDDomain/Sources/DomainAssembly.swift +++ b/Projects/TDDomain/Sources/DomainAssembly.swift @@ -313,18 +313,18 @@ public struct DomainAssembly: Assembly { return FetchRoutineUseCaseImpl(repository: repository) } - container.register(FetchScheduleListUseCase.self) { resolver in + container.register(FetchServerScheduleListUseCase.self) { resolver in guard let repository = resolver.resolve(ScheduleRepository.self) else { fatalError("컨테이너에 ScheduleRepository가 등록되어 있지 않습니다.") } - return FetchScheduleListUseCaseImpl(repository: repository) + return FetchServerScheduleListUseCaseImpl(repository: repository) } - container.register(FetchScheduleUseCase.self) { resolver in + container.register(FetchLocalCalendarScheduleListUseCase.self) { resolver in guard let repository = resolver.resolve(ScheduleRepository.self) else { fatalError("컨테이너에 ScheduleRepository가 등록되어 있지 않습니다.") } - return FetchScheduleUseCaseImpl(repository: repository) + return FetchLocalCalendarScheduleListUseCaseImpl(repository: repository) } container.register(FetchUserPostUseCase.self) { resolver in @@ -390,13 +390,6 @@ public struct DomainAssembly: Assembly { return FinishRoutineUseCaseImpl(repository: repository) } - container.register(MoveTomorrowScheduleUseCase.self) { resolver in - guard let repository = resolver.resolve(ScheduleRepository.self) else { - fatalError("컨테이너에 ScheduleRepository가 등록되어 있지 않습니다.") - } - return MoveTomorrowScheduleUseCaseImpl(repository: repository) - } - container.register(ReportCommentUseCase.self) { resolver in guard let repository = resolver.resolve(SocialRepository.self) else { fatalError("컨테이너에 SocialRepository가 등록되어 있지 않습니다.") diff --git a/Projects/TDDomain/Sources/Entity/Schedule.swift b/Projects/TDDomain/Sources/Entity/Schedule.swift index 2041f2f21..f799b3078 100644 --- a/Projects/TDDomain/Sources/Entity/Schedule.swift +++ b/Projects/TDDomain/Sources/Entity/Schedule.swift @@ -1,6 +1,11 @@ import Foundation public struct Schedule: TodoItem { + public enum Source { + case server + case localCalendar + } + public let id: Int? // 서버의 일정 PK public let title: String public let category: TDCategory @@ -15,6 +20,7 @@ public struct Schedule: TodoItem { public let isFinished: Bool public let scheduleRecords: [ScheduleRecord]? public let eventMode: TDTodoMode = .schedule + public let source: Source public var isRepeating: Bool { repeatDays != nil || startDate != endDate @@ -33,7 +39,8 @@ public struct Schedule: TodoItem { place: String?, memo: String?, isFinished: Bool, - scheduleRecords: [ScheduleRecord]? + scheduleRecords: [ScheduleRecord]?, + source: Source ) { self.id = id self.title = title @@ -48,6 +55,7 @@ public struct Schedule: TodoItem { self.memo = memo self.isFinished = isFinished self.scheduleRecords = scheduleRecords + self.source = source } } diff --git a/Projects/TDDomain/Sources/RepositoryProtocol/ScheduleRepository.swift b/Projects/TDDomain/Sources/RepositoryProtocol/ScheduleRepository.swift index 194a91e02..369f503e5 100644 --- a/Projects/TDDomain/Sources/RepositoryProtocol/ScheduleRepository.swift +++ b/Projects/TDDomain/Sources/RepositoryProtocol/ScheduleRepository.swift @@ -4,9 +4,8 @@ import TDCore public protocol ScheduleRepository { func createSchedule(schedule: Schedule) async throws func finishSchedule(scheduleId: Int, isComplete: Bool, queryDate: String) async throws - func fetchSchedule() async throws -> Schedule - func fetchScheduleList(startDate: String, endDate: String) async throws -> [Schedule] + func fetchServerScheduleList(startDate: String, endDate: String) async throws -> [Schedule] + func fetchLocalCalendarScheduleList(startDate: String, endDate: String) async throws -> [Schedule] func updateSchedule(scheduleId: Int, isOneDayDeleted: Bool, queryDate: String, scheduleData: Schedule) async throws func deleteSchedule(scheduleId: Int, isOneDayDeleted: Bool, queryDate: String) async throws - func moveTomorrowSchedule(scheduleId: Int) async throws } diff --git a/Projects/TDDomain/Sources/UseCase/Schedule/FetchAllSchedulesUseCase.swift b/Projects/TDDomain/Sources/UseCase/Schedule/FetchAllSchedulesUseCase.swift new file mode 100644 index 000000000..847d31398 --- /dev/null +++ b/Projects/TDDomain/Sources/UseCase/Schedule/FetchAllSchedulesUseCase.swift @@ -0,0 +1,33 @@ +import Foundation +import TDCore + +public protocol FetchAllSchedulesUseCase { + func execute(startDate: String, endDate: String) async throws -> [Date: [Schedule]] +} + +public final class FetchAllSchedulesUseCaseImpl: FetchAllSchedulesUseCase { + private let serverUseCase: FetchServerScheduleListUseCase + private let localUseCase: FetchLocalCalendarScheduleListUseCase + + public init( + serverUseCase: FetchServerScheduleListUseCase, + localUseCase: FetchLocalCalendarScheduleListUseCase + ) { + self.serverUseCase = serverUseCase + self.localUseCase = localUseCase + } + + public func execute(startDate: String, endDate: String) async throws -> [Date: [Schedule]] { + async let serverSchedulesTask = serverUseCase.execute(startDate: startDate, endDate: endDate) + async let localSchedulesTask = localUseCase.execute(startDate: startDate, endDate: endDate) + + let (serverSchedules, localSchedules) = try await (serverSchedulesTask, localSchedulesTask) + + var combinedSchedules = serverSchedules + for (date, schedules) in localSchedules { + combinedSchedules[date, default: []].append(contentsOf: schedules) + } + + return combinedSchedules + } +} diff --git a/Projects/TDDomain/Sources/UseCase/Schedule/FetchLocalCalendarScheduleListUseCase.swift b/Projects/TDDomain/Sources/UseCase/Schedule/FetchLocalCalendarScheduleListUseCase.swift new file mode 100644 index 000000000..a37d85742 --- /dev/null +++ b/Projects/TDDomain/Sources/UseCase/Schedule/FetchLocalCalendarScheduleListUseCase.swift @@ -0,0 +1,51 @@ +import Foundation + +public protocol FetchLocalCalendarScheduleListUseCase { + func execute(startDate: String, endDate: String) async throws -> [Date: [Schedule]] +} + +public final class FetchLocalCalendarScheduleListUseCaseImpl: FetchLocalCalendarScheduleListUseCase { + private let repository: ScheduleRepository + + public init(repository: ScheduleRepository) { + self.repository = repository + } + + public func execute( + startDate: String, + endDate: String + ) async throws -> [Date: [Schedule]] { + let schedules = try await repository.fetchLocalCalendarScheduleList( + startDate: startDate, + endDate: endDate + ) + + return groupSchedulesByDay(schedules: schedules) + } + + private func groupSchedulesByDay(schedules: [Schedule]) -> [Date: [Schedule]] { + var groupedDictionary = [Date: [Schedule]]() + let calendar = Calendar.current + + for schedule in schedules { + guard + let startDate = Date.convertFromString(schedule.startDate, format: .yearMonthDay), + let endDate = Date.convertFromString(schedule.endDate, format: .yearMonthDay) + else { continue } + + var currentDate = startDate + while calendar.startOfDay(for: currentDate) <= calendar.startOfDay(for: endDate) { + let dayKey = calendar.startOfDay(for: currentDate) + groupedDictionary[dayKey, default: []].append(schedule) + + if let nextDay = calendar.date(byAdding: .day, value: 1, to: currentDate) { + currentDate = nextDay + } else { + break + } + } + } + + return groupedDictionary + } +} diff --git a/Projects/TDDomain/Sources/UseCase/Schedule/FetchScheduleUseCase.swift b/Projects/TDDomain/Sources/UseCase/Schedule/FetchScheduleUseCase.swift deleted file mode 100644 index 9359cbc69..000000000 --- a/Projects/TDDomain/Sources/UseCase/Schedule/FetchScheduleUseCase.swift +++ /dev/null @@ -1,17 +0,0 @@ -import Foundation - -public protocol FetchScheduleUseCase { - func execute() async throws -> Schedule -} - -public final class FetchScheduleUseCaseImpl: FetchScheduleUseCase { - private let scheduleRepository: ScheduleRepository - - public init(repository: ScheduleRepository) { - self.scheduleRepository = repository - } - - public func execute() async throws -> Schedule { - try await scheduleRepository.fetchSchedule() - } -} diff --git a/Projects/TDDomain/Sources/UseCase/Schedule/FetchScheduleListUseCase.swift b/Projects/TDDomain/Sources/UseCase/Schedule/FetchServerScheduleListUseCase.swift similarity index 90% rename from Projects/TDDomain/Sources/UseCase/Schedule/FetchScheduleListUseCase.swift rename to Projects/TDDomain/Sources/UseCase/Schedule/FetchServerScheduleListUseCase.swift index 3c7e43d61..df23421d3 100644 --- a/Projects/TDDomain/Sources/UseCase/Schedule/FetchScheduleListUseCase.swift +++ b/Projects/TDDomain/Sources/UseCase/Schedule/FetchServerScheduleListUseCase.swift @@ -1,11 +1,11 @@ import TDCore import Foundation -public protocol FetchScheduleListUseCase { +public protocol FetchServerScheduleListUseCase { func execute(startDate: String, endDate: String) async throws -> [Date: [Schedule]] } -public final class FetchScheduleListUseCaseImpl: FetchScheduleListUseCase { +public final class FetchServerScheduleListUseCaseImpl: FetchServerScheduleListUseCase { private let repository: ScheduleRepository public init(repository: ScheduleRepository) { @@ -16,7 +16,7 @@ public final class FetchScheduleListUseCaseImpl: FetchScheduleListUseCase { startDate: String, endDate: String ) async throws -> [Date: [Schedule]] { - let fetchedScheduleList = try await repository.fetchScheduleList(startDate: startDate, endDate: endDate) + let fetchedScheduleList = try await repository.fetchServerScheduleList(startDate: startDate, endDate: endDate) let filteredScheduleList = filterScheduleList(with: fetchedScheduleList, startDate: startDate, endDate: endDate) let buildScheduleDictionary = buildScheduleDictionary(with: filteredScheduleList, queryStartDate: startDate, queryEndDate: endDate) @@ -175,23 +175,8 @@ public final class FetchScheduleListUseCaseImpl: FetchScheduleListUseCase { place: schedule.place, memo: schedule.memo, isFinished: isFinished, - scheduleRecords: schedule.scheduleRecords + scheduleRecords: schedule.scheduleRecords, + source: .server ) } } - -extension Date { - func weekdayEnum() -> TDWeekDay { - let weekday = Calendar.current.component(.weekday, from: self) - switch weekday { - case 1: return .sunday - case 2: return .monday - case 3: return .tuesday - case 4: return .wednesday - case 5: return .thursday - case 6: return .friday - case 7: return .saturday - default: return .monday - } - } -} diff --git a/Projects/TDDomain/Sources/UseCase/Schedule/MoveTomorrowScheduleUseCase.swift b/Projects/TDDomain/Sources/UseCase/Schedule/MoveTomorrowScheduleUseCase.swift deleted file mode 100644 index 6c0735be4..000000000 --- a/Projects/TDDomain/Sources/UseCase/Schedule/MoveTomorrowScheduleUseCase.swift +++ /dev/null @@ -1,17 +0,0 @@ -import Foundation - -public protocol MoveTomorrowScheduleUseCase { - func execute(scheduleId: Int) async throws -} - -public final class MoveTomorrowScheduleUseCaseImpl: MoveTomorrowScheduleUseCase { - private let scheduleRepository: ScheduleRepository - - public init(repository: ScheduleRepository) { - self.scheduleRepository = repository - } - - public func execute(scheduleId: Int) async throws { - try await scheduleRepository.moveTomorrowSchedule(scheduleId: scheduleId) - } -} diff --git a/Projects/TDDomain/Sources/Utility/Date+weekdayEnum.swift b/Projects/TDDomain/Sources/Utility/Date+weekdayEnum.swift new file mode 100644 index 000000000..002e1d47e --- /dev/null +++ b/Projects/TDDomain/Sources/Utility/Date+weekdayEnum.swift @@ -0,0 +1,17 @@ +import Foundation + +extension Date { + func weekdayEnum() -> TDWeekDay { + let weekday = Calendar.current.component(.weekday, from: self) + switch weekday { + case 1: return .sunday + case 2: return .monday + case 3: return .tuesday + case 4: return .wednesday + case 5: return .thursday + case 6: return .friday + case 7: return .saturday + default: return .monday + } + } +} diff --git a/Projects/TDDomain/Tests/Extension/Schedule+Init.swift b/Projects/TDDomain/Tests/Extension/Schedule+Init.swift new file mode 100644 index 000000000..43acf5b45 --- /dev/null +++ b/Projects/TDDomain/Tests/Extension/Schedule+Init.swift @@ -0,0 +1,33 @@ +import TDDomain + +extension Schedule { + init(id: Int?, title: String, startDate: String, endDate: String, repeatDays: [TDWeekDay]? = nil, scheduleRecords: [ScheduleRecord]? = nil) { + self.init( + id: id, + title: title, + category: TDCategory(colorHex: "FFFFFF", imageName: "default"), + startDate: startDate, + endDate: endDate, + isAllDay: true, + time: nil, + repeatDays: repeatDays, + alarmTime: nil, + place: nil, + memo: nil, + isFinished: false, + scheduleRecords: scheduleRecords, + source: .server + ) + } +} + +extension ScheduleRecord { + init(recordDate: String, deletedAt: String? = nil) { + self.init( + id: Int.random(in: 1...10000), + isComplete: false, + recordDate: recordDate, + deletedAt: deletedAt + ) + } +} diff --git a/Projects/TDDomain/Tests/FetchLocalCalendarScheduleListUseCaseTests.swift b/Projects/TDDomain/Tests/FetchLocalCalendarScheduleListUseCaseTests.swift new file mode 100644 index 000000000..8cfb8381a --- /dev/null +++ b/Projects/TDDomain/Tests/FetchLocalCalendarScheduleListUseCaseTests.swift @@ -0,0 +1,83 @@ +import XCTest +@testable import TDCore +@testable import TDDomain + +final class FetchLocalCalendarScheduleListUseCaseTests: XCTestCase { + + // MARK: - Properties + + var sut: FetchLocalCalendarScheduleListUseCase! + var mockRepository: MockScheduleRepository! + + // MARK: - Test Lifecycle + + override func setUpWithError() throws { + try super.setUpWithError() + mockRepository = MockScheduleRepository() + sut = FetchLocalCalendarScheduleListUseCaseImpl(repository: mockRepository) + } + + override func tearDownWithError() throws { + sut = nil + mockRepository = nil + try super.tearDownWithError() + } + + // MARK: - Success Case Test + + func test_로컬일정목록을_날짜별로_정확히_그룹화하는지() async throws { + // GIVEN: Repository가 반환할 테스트용 데이터를 준비합니다. + // 단일 일정과 기간 일정을 모두 포함하여 그룹화 로직을 검증합니다. + mockRepository.mockScheduleList = [ + // 1. 단일 일정 (2025-08-21) + Schedule(id: 101, title: "치과 예약", startDate: "2025-08-21", endDate: "2025-08-21"), + // 2. 기간 일정 (2025-08-20 ~ 2025-08-22) + Schedule(id: 102, title: "여름 휴가", startDate: "2025-08-20", endDate: "2025-08-22") + ] + + // WHEN: UseCase를 실행하여 결과를 받습니다. + let result = try await sut.execute(startDate: "2025-08-01", endDate: "2025-08-31") + + // THEN: 결과가 기대한 대로 그룹화되었는지 검증합니다. + let calendar = Calendar.current + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "yyyy-MM-dd" + + // 1. 결과 딕셔너리의 키 개수 검증 (20일, 21일, 22일 -> 총 3일) + XCTAssertEqual(result.count, 3, "총 3개의 날짜에 대한 일정이 있어야 합니다.") + + // 2. 8월 20일에는 '여름 휴가' 하나만 있어야 합니다. + let day20Key = calendar.startOfDay(for: dateFormatter.date(from: "2025-08-20")!) + XCTAssertNotNil(result[day20Key], "8월 20일 일정이 존재해야 합니다.") + XCTAssertEqual(result[day20Key]?.count, 1, "8월 20일에는 1개의 일정이 있어야 합니다.") + XCTAssertEqual(result[day20Key]?.first?.title, "여름 휴가") + + // 3. 8월 21일에는 '치과 예약'과 '여름 휴가' 두 개가 모두 있어야 합니다. + let day21Key = calendar.startOfDay(for: dateFormatter.date(from: "2025-08-21")!) + XCTAssertNotNil(result[day21Key], "8월 21일 일정이 존재해야 합니다.") + XCTAssertEqual(result[day21Key]?.count, 2, "8월 21일에는 2개의 일정이 있어야 합니다.") + + // 4. 8월 23일에는 일정이 없어야 합니다 (결과에 포함되지 않아야 함). + let day23Key = calendar.startOfDay(for: dateFormatter.date(from: "2025-08-23")!) + XCTAssertNil(result[day23Key], "8월 23일에는 일정이 없어야 합니다.") + } + + // MARK: - Failure Case Test + + func test_리포지토리에서_에러가발생하면_UseCase가_에러를전달하는지() async { + // GIVEN: Repository가 에러를 던지도록 설정합니다. + mockRepository.shouldThrowError = true + + // WHEN & THEN: UseCase 실행 시 에러가 발생하는지 검증합니다. + do { + _ = try await sut.execute(startDate: "2025-08-01", endDate: "2025-08-31") + + // 만약 이 라인까지 코드가 실행된다면, 에러가 발생하지 않았다는 의미이므로 테스트를 실패시킵니다. + XCTFail("UseCase가 에러를 던져야 했지만, 정상적으로 완료되었습니다.") + + } catch { + // 에러가 성공적으로 잡혔습니다. 이제 잡힌 에러가 우리가 예상한 에러인지 확인합니다. + XCTAssertEqual(error as? TestError, TestError.repositoryError, "예상치 못한 다른 종류의 에러가 발생했습니다.") + } + } +} diff --git a/Projects/TDDomain/Tests/FetchScheduleListUseCaseTests.swift b/Projects/TDDomain/Tests/FetchScheduleListUseCaseTests.swift deleted file mode 100644 index 163de8ef2..000000000 --- a/Projects/TDDomain/Tests/FetchScheduleListUseCaseTests.swift +++ /dev/null @@ -1,281 +0,0 @@ -import XCTest -@testable import TDCore -@testable import TDDomain - -final class FetchScheduleListUseCaseTests: XCTestCase { - // 1) 하루 선택 + 반복 X - func test하루선택_반복없음_하루만생성() async throws { - // Arrange - let schedule = Schedule( - id: 1, - title: "하루·반복X", - category: TDCategory(colorHex: "#123456", imageName: "redBook"), - startDate: "2025-04-10", - endDate: "2025-04-10", - isAllDay: true, - time: nil, - repeatDays: nil, - alarmTime: nil, - place: nil, - memo: nil, - isFinished: false, - scheduleRecords: nil - ) - let sut = FetchScheduleListUseCaseImpl( - repository: MockScheduleRepository([schedule]) - ) - - // Act - let result = try await sut.execute( - startDate: "2025-04-01", - endDate: "2025-04-30" - ) - - // Assert - let expectedDate = Date - .convertFromString("2025-04-10", format: .yearMonthDay)! - .stripTime() - XCTAssertEqual(result.count, 1) - XCTAssertEqual(result[expectedDate]?.count, 1) - } - - // 2) 하루 선택 + 반복 O - func test하루선택_반복있음_요일마다생성() async throws { - // Arrange - let schedule = Schedule( - id: 2, - title: "하루·반복O [화·수]", - category: TDCategory(colorHex: "#123456", imageName: "redBook"), - startDate: "2025-04-05", // 토요일 - endDate: "2025-04-05", - isAllDay: true, - time: nil, - repeatDays: [.tuesday, .wednesday], - alarmTime: nil, - place: nil, - memo: nil, - isFinished: false, - scheduleRecords: nil - ) - let sut = FetchScheduleListUseCaseImpl( - repository: MockScheduleRepository([schedule]) - ) - - // Act - let result = try await sut.execute( - startDate: "2025-04-01", - endDate: "2025-04-30" - ) - - // Assert - let formatter = DateFormatter.yyyymmdd - let expectedStrings = [ - "2025-04-08","2025-04-09", - "2025-04-15","2025-04-16", - "2025-04-22","2025-04-23", - "2025-04-29","2025-04-30" - ] - let expectedDates = Set(expectedStrings.compactMap { formatter.date(from:$0)?.stripTime() }) - - XCTAssertEqual(result.keys.count, expectedDates.count) - expectedDates.forEach { date in - XCTAssertEqual(result[date]?.count, 1, "\(date) 가 누락되었습니다") - } - } - - // 3) 기간 선택 + 반복 X - func test기간선택_반복없음_모든날짜생성() async throws { - // Arrange - let schedule = Schedule( - id: 3, - title: "기간·반복X", - category: TDCategory(colorHex: "#123456", imageName: "redBook"), - startDate: "2025-04-10", - endDate: "2025-04-12", - isAllDay: true, - time: nil, - repeatDays: nil, - alarmTime: nil, - place: nil, - memo: nil, - isFinished: false, - scheduleRecords: nil - ) - let sut = FetchScheduleListUseCaseImpl( - repository: MockScheduleRepository([schedule]) - ) - - // Act - let result = try await sut.execute( - startDate: "2025-04-01", - endDate: "2025-04-30" - ) - - // Assert - let formatter = DateFormatter.yyyymmdd - let expected = ["2025-04-10","2025-04-11","2025-04-12"] - .compactMap { formatter.date(from:$0)?.stripTime() } - - XCTAssertEqual(result.keys.count, expected.count) - expected.forEach { date in - XCTAssertEqual(result[date]?.count, 1) - } - } - - // 4) 기간 선택 + 반복 O - func test기간선택_반복있음_특정요일만생성() async throws { - // Arrange - let schedule = Schedule( - id: 4, - title: "기간·반복O [월·수·금]", - category: TDCategory(colorHex: "#123456", imageName: "redBook"), - startDate: "2025-04-01", - endDate: "2025-04-30", - isAllDay: true, - time: nil, - repeatDays: [.monday, .wednesday, .friday], - alarmTime: nil, - place: nil, - memo: nil, - isFinished: false, - scheduleRecords: nil - ) - let sut = FetchScheduleListUseCaseImpl( - repository: MockScheduleRepository([schedule]) - ) - - // Act - let result = try await sut.execute( - startDate: "2025-04-01", - endDate: "2025-04-30" - ) - - // Assert - XCTAssertFalse(result.isEmpty) - - result.keys.forEach { date in - let weekday = date.weekdayEnum() - XCTAssertTrue([.monday,.wednesday,.friday].contains(weekday), - "허용되지 않은 요일 \(weekday) 발견") - } - } - - func test삭제된기록이있으면_해당날짜일정이생성되지않음() async throws { - // Arrange - let formatter = DateFormatter.yyyymmdd - let targetDate = formatter.date(from: "2025-06-14")! - - let deletedRecord = ScheduleRecord( - id: 999, - isComplete: false, - recordDate: "2025-06-14", - deletedAt: "2025-06-14T13:50:59" // 삭제된 기록 - ) - - let schedule = Schedule( - id: 5, - title: "삭제된 일정", - category: TDCategory(colorHex: "#123456", imageName: "sleep"), - startDate: "2025-06-14", - endDate: "2025-06-14", - isAllDay: true, - time: nil, - repeatDays: [.saturday], - alarmTime: nil, - place: nil, - memo: nil, - isFinished: false, - scheduleRecords: [deletedRecord] - ) - - let sut = FetchScheduleListUseCaseImpl( - repository: MockScheduleRepository([schedule]) - ) - - // Act - let result = try await sut.execute( - startDate: "2025-06-01", - endDate: "2025-06-30" - ) - - // Assert - let stripped = targetDate.stripTime() - XCTAssertNil(result[stripped], "삭제된 일정이 표시되었습니다") - } - - func test기간일정에서_삭제된날짜는제외된다() async throws { - // Arrange - let formatter = DateFormatter.yyyymmdd - let deletedDate = formatter.date(from: "2025-06-16")! - - let deletedRecord = ScheduleRecord( - id: 999, - isComplete: false, - recordDate: "2025-06-16", // 삭제된 날짜 - deletedAt: "2025-06-16T10:00:00" - ) - - let schedule = Schedule( - id: 6, - title: "기간 일정 중 일부 삭제", - category: TDCategory(colorHex: "#123456", imageName: "redBook"), - startDate: "2025-06-12", - endDate: "2025-06-17", - isAllDay: true, - time: nil, - repeatDays: nil, - alarmTime: nil, - place: nil, - memo: nil, - isFinished: false, - scheduleRecords: [deletedRecord] - ) - - let sut = FetchScheduleListUseCaseImpl( - repository: MockScheduleRepository([schedule]) - ) - - // Act - let result = try await sut.execute( - startDate: "2025-06-01", - endDate: "2025-06-30" - ) - - // Assert - let expectedDates = [ - "2025-06-12", - "2025-06-13", - "2025-06-14", - "2025-06-15", - "2025-06-17" - ].compactMap { formatter.date(from: $0)?.stripTime() } - - let excludedDate = deletedDate.stripTime() - - // 생성되어야 할 날짜는 모두 존재해야 함 - for date in expectedDates { - XCTAssertEqual(result[date]?.count, 1, "\(date) 일정이 누락됨") - } - - // 삭제된 날짜는 없어야 함 - XCTAssertNil(result[excludedDate], "삭제된 2025-06-16 일정이 표시됨") - } -} - -// MARK: - Test Helpers - -private extension DateFormatter { - static let yyyymmdd: DateFormatter = { - let f = DateFormatter() - f.calendar = Calendar(identifier: .gregorian) - f.locale = Locale(identifier: "en_US_POSIX") - f.timeZone = TimeZone(secondsFromGMT: 0) - f.dateFormat = "yyyy-MM-dd" - return f - }() -} - -private extension Date { - /// 00:00:00 로 통일하여 dictionary key 비교 시 시간 차이를 제거 - func stripTime() -> Date { Calendar.current.startOfDay(for: self) } -} diff --git a/Projects/TDDomain/Tests/FetchServerScheduleListUseCaseTests.swift b/Projects/TDDomain/Tests/FetchServerScheduleListUseCaseTests.swift new file mode 100644 index 000000000..214c0394a --- /dev/null +++ b/Projects/TDDomain/Tests/FetchServerScheduleListUseCaseTests.swift @@ -0,0 +1,173 @@ +import XCTest +@testable import TDCore +@testable import TDDomain + +final class FetchServerScheduleListUseCaseTests: XCTestCase { + // MARK: - Properties + + var sut: FetchServerScheduleListUseCase! + var mockRepository: MockScheduleRepository! + + // MARK: - Test Lifecycle + + override func setUpWithError() throws { + try super.setUpWithError() + mockRepository = MockScheduleRepository() + sut = FetchServerScheduleListUseCaseImpl(repository: mockRepository) + } + + override func tearDownWithError() throws { + sut = nil + mockRepository = nil + try super.tearDownWithError() + } + + // MARK: - Test Cases + + // 1) 하루 선택 + 반복 X + func test하루선택_반복없음_하루만생성() async throws { + // GIVEN + let schedule = Schedule(id: 1, title: "하루·반복X", startDate: "2025-04-10", endDate: "2025-04-10") + mockRepository.mockScheduleList = [schedule] + + // WHEN + let result = try await sut.execute( + startDate: "2025-04-01", + endDate: "2025-04-30" + ) + + // THEN + let expectedDate = Date.convertFromString("2025-04-10", format: .yearMonthDay)!.stripTime() + XCTAssertEqual(result.count, 1) + XCTAssertEqual(result[expectedDate]?.count, 1) + } + + // 2) 하루 선택 + 반복 O + func test하루선택_반복있음_요일마다생성() async throws { + // GIVEN + let schedule = Schedule(id: 2, title: "하루·반복O [화·수]", startDate: "2025-04-05", endDate: "2025-04-05", repeatDays: [.tuesday, .wednesday]) + mockRepository.mockScheduleList = [schedule] + + // WHEN + let result = try await sut.execute( + startDate: "2025-04-01", + endDate: "2025-04-30" + ) + + // THEN + let expectedStrings = [ + "2025-04-08", "2025-04-09", + "2025-04-15", "2025-04-16", + "2025-04-22", "2025-04-23", + "2025-04-29", "2025-04-30" + ] + let expectedDates = Set(expectedStrings.compactMap { DateFormatter.yyyymmdd.date(from:$0)?.stripTime() }) + + XCTAssertEqual(result.keys.count, expectedDates.count) + expectedDates.forEach { date in + XCTAssertEqual(result[date]?.count, 1, "\(date) 가 누락되었습니다") + } + } + + // 3) 기간 선택 + 반복 X + func test기간선택_반복없음_모든날짜생성() async throws { + // GIVEN + let schedule = Schedule(id: 3, title: "기간·반복X", startDate: "2025-04-10", endDate: "2025-04-12") + mockRepository.mockScheduleList = [schedule] + + // WHEN + let result = try await sut.execute( + startDate: "2025-04-01", + endDate: "2025-04-30" + ) + + // THEN + let expected = ["2025-04-10", "2025-04-11", "2025-04-12"] + .compactMap { DateFormatter.yyyymmdd.date(from:$0)?.stripTime() } + + XCTAssertEqual(result.keys.count, expected.count) + expected.forEach { date in + XCTAssertEqual(result[date]?.count, 1) + } + } + + // 4) 기간 선택 + 반복 O + func test기간선택_반복있음_특정요일만생성() async throws { + // GIVEN + let schedule = Schedule(id: 4, title: "기간·반복O [월·수·금]", startDate: "2025-04-01", endDate: "2025-04-30", repeatDays: [.monday, .wednesday, .friday]) + mockRepository.mockScheduleList = [schedule] + + // WHEN + let result = try await sut.execute( + startDate: "2025-04-01", + endDate: "2025-04-30" + ) + + // THEN + XCTAssertFalse(result.isEmpty) + + result.keys.forEach { date in + let weekday = date.weekdayEnum() + XCTAssertTrue([.monday, .wednesday, .friday].contains(weekday), + "허용되지 않은 요일 \(weekday) 발견") + } + } + + func test삭제된기록이있으면_해당날짜일정이생성되지않음() async throws { + // GIVEN + let deletedRecord = ScheduleRecord(recordDate: "2025-06-14", deletedAt: "2025-06-14T13:50:59") + let schedule = Schedule(id: 5, title: "삭제된 일정", startDate: "2025-06-14", endDate: "2025-06-14", repeatDays: [.saturday], scheduleRecords: [deletedRecord]) + mockRepository.mockScheduleList = [schedule] + + // WHEN + let result = try await sut.execute( + startDate: "2025-06-01", + endDate: "2025-06-30" + ) + + // THEN + let targetDate = DateFormatter.yyyymmdd.date(from: "2025-06-14")! + XCTAssertNil(result[targetDate.stripTime()], "삭제된 일정이 표시되었습니다") + } + + func test기간일정에서_삭제된날짜는제외된다() async throws { + // GIVEN + let deletedRecord = ScheduleRecord(recordDate: "2025-06-16", deletedAt: "2025-06-16T10:00:00") + let schedule = Schedule(id: 6, title: "기간 일정 중 일부 삭제", startDate: "2025-06-12", endDate: "2025-06-17", scheduleRecords: [deletedRecord]) + mockRepository.mockScheduleList = [schedule] + + // WHEN + let result = try await sut.execute( + startDate: "2025-06-01", + endDate: "2025-06-30" + ) + + // THEN + let expectedDates = [ + "2025-06-12", "2025-06-13", "2025-06-14", "2025-06-15", "2025-06-17" + ].compactMap { DateFormatter.yyyymmdd.date(from: $0)?.stripTime() } + + let excludedDate = DateFormatter.yyyymmdd.date(from: "2025-06-16")!.stripTime() + + XCTAssertEqual(result.keys.count, expectedDates.count, "삭제된 날짜를 제외한 나머지 날짜 수가 일치하지 않습니다.") + XCTAssertNil(result[excludedDate], "삭제된 2025-06-16 일정이 표시됨") + } +} + +// MARK: - Test Helpers + +private extension DateFormatter { + static let yyyymmdd: DateFormatter = { + let f = DateFormatter() + f.calendar = Calendar(identifier: .gregorian) + f.locale = Locale(identifier: "en_US_POSIX") + f.timeZone = TimeZone(secondsFromGMT: 0) + f.dateFormat = "yyyy-MM-dd" + return f + }() +} + +private extension Date { + /// 00:00:00 로 통일하여 dictionary key 비교 시 시간 차이를 제거 + func stripTime() -> Date { Calendar.current.startOfDay(for: self) } +} diff --git a/Projects/TDDomain/Tests/Mock/MockScheduleRepository.swift b/Projects/TDDomain/Tests/Mock/MockScheduleRepository.swift index 54019e4fd..268747461 100644 --- a/Projects/TDDomain/Tests/Mock/MockScheduleRepository.swift +++ b/Projects/TDDomain/Tests/Mock/MockScheduleRepository.swift @@ -6,22 +6,31 @@ final class MockScheduleRepository: ScheduleRepository { var didCallUpdate = false var updatedScheduleId: Int? - + var didCallDelete = false var deletedScheduleId: Int? - + var didCallMoveTomorrow = false var movedScheduleId: Int? - + var didCallCreate = false var createdSchedule: Schedule? - init(_ schedules: [Schedule]) { self.mockScheduleList = schedules } - - func fetchScheduleList(startDate: String, endDate: String) async throws -> [Schedule] { + var shouldThrowError = false + var mockError = TestError.repositoryError + + init() { } + + func fetchServerScheduleList(startDate: String, endDate: String) async throws -> [Schedule] { + if shouldThrowError { throw mockError } return mockScheduleList } - + + func fetchLocalCalendarScheduleList(startDate: String, endDate: String) async throws -> [Schedule] { + if shouldThrowError { throw mockError } + return mockScheduleList + } + func fetchSchedule() async throws -> TDDomain.Schedule { return Schedule( id: 999, @@ -36,10 +45,11 @@ final class MockScheduleRepository: ScheduleRepository { place: nil, memo: nil, isFinished: false, - scheduleRecords: nil + scheduleRecords: nil, + source: .server ) } - + func updateSchedule(scheduleId: Int) async throws { didCallUpdate = true updatedScheduleId = scheduleId @@ -49,17 +59,21 @@ final class MockScheduleRepository: ScheduleRepository { didCallDelete = true deletedScheduleId = scheduleId } - + func moveTomorrowSchedule(scheduleId: Int) async throws { didCallMoveTomorrow = true movedScheduleId = scheduleId } - - func createSchedule(schedule: TDDomain.Schedule) async throws { + + func createSchedule(schedule: Schedule) async throws { didCallCreate = true createdSchedule = schedule } func finishSchedule(scheduleId: Int, isComplete: Bool, queryDate: String) async throws { } - func updateSchedule(scheduleId: Int, isOneDayDeleted: Bool, queryDate: String, scheduleData: TDDomain.Schedule) async throws { } + func updateSchedule(scheduleId: Int, isOneDayDeleted: Bool, queryDate: String, scheduleData: Schedule) async throws { } +} + +enum TestError: Error { + case repositoryError } diff --git a/Projects/TDDomain/Tests/ShouldMarkAllDayUseCaseTests.swift b/Projects/TDDomain/Tests/ShouldMarkAllDayUseCaseTests.swift index c406ebaa8..6ef72d39d 100644 --- a/Projects/TDDomain/Tests/ShouldMarkAllDayUseCaseTests.swift +++ b/Projects/TDDomain/Tests/ShouldMarkAllDayUseCaseTests.swift @@ -80,7 +80,8 @@ final class ShouldMarkAllDayUseCaseTests: XCTestCase { place: nil, memo: nil, isFinished: false, - scheduleRecords: nil + scheduleRecords: nil, + source: .server ) } } diff --git a/Projects/TDPresentation/Sources/AppFlow/MainFlow/Calendar/ToduckCalendarCoordinator.swift b/Projects/TDPresentation/Sources/AppFlow/MainFlow/Calendar/ToduckCalendarCoordinator.swift index 5e30854af..18f92f2fc 100644 --- a/Projects/TDPresentation/Sources/AppFlow/MainFlow/Calendar/ToduckCalendarCoordinator.swift +++ b/Projects/TDPresentation/Sources/AppFlow/MainFlow/Calendar/ToduckCalendarCoordinator.swift @@ -17,7 +17,7 @@ final class ToduckCalendarCoordinator: Coordinator { } func start() { - let fetchScheduleListUseCase = injector.resolve(FetchScheduleListUseCase.self) + let fetchScheduleListUseCase = injector.resolve(FetchServerScheduleListUseCase.self) let finishScheduleUseCase = DIContainer.shared.resolve(FinishScheduleUseCase.self) let deleteScheduleUseCase = injector.resolve(DeleteScheduleUseCase.self) let toduckCalendarViewModel = ToduckCalendarViewModel( diff --git a/Projects/TDPresentation/Sources/AppFlow/MainFlow/Calendar/ToduckCalendarViewModel.swift b/Projects/TDPresentation/Sources/AppFlow/MainFlow/Calendar/ToduckCalendarViewModel.swift index d4066aa3d..c5f8eea57 100644 --- a/Projects/TDPresentation/Sources/AppFlow/MainFlow/Calendar/ToduckCalendarViewModel.swift +++ b/Projects/TDPresentation/Sources/AppFlow/MainFlow/Calendar/ToduckCalendarViewModel.swift @@ -20,7 +20,7 @@ final class ToduckCalendarViewModel: BaseViewModel { } // MARK: - Properties - private let fetchScheduleListUseCase: FetchScheduleListUseCase + private let fetchScheduleListUseCase: FetchServerScheduleListUseCase private let finishScheduleUseCase: FinishScheduleUseCase private let deleteScheduleUseCase: DeleteScheduleUseCase private let output = PassthroughSubject() @@ -30,7 +30,7 @@ final class ToduckCalendarViewModel: BaseViewModel { var selectedDate = Date() init( - fetchScheduleListUseCase: FetchScheduleListUseCase, + fetchScheduleListUseCase: FetchServerScheduleListUseCase, finishScheduleUseCase: FinishScheduleUseCase, deleteScheduleUseCase: DeleteScheduleUseCase ) { @@ -127,7 +127,8 @@ final class ToduckCalendarViewModel: BaseViewModel { place: updatedSchedule.place, memo: updatedSchedule.memo, isFinished: toggledFinished, - scheduleRecords: updatedSchedule.scheduleRecords + scheduleRecords: updatedSchedule.scheduleRecords, + source: .server ) schedules[index] = updatedSchedule monthScheduleDict[key] = schedules @@ -149,7 +150,8 @@ final class ToduckCalendarViewModel: BaseViewModel { place: updated.place, memo: updated.memo, isFinished: toggledFinished, - scheduleRecords: updated.scheduleRecords + scheduleRecords: updated.scheduleRecords, + source: .server ) currentDayScheduleList[index] = updated } diff --git a/Projects/TDPresentation/Sources/AppFlow/MainFlow/Home/EventMakor/TodoCreatorViewModel.swift b/Projects/TDPresentation/Sources/AppFlow/MainFlow/Home/EventMakor/TodoCreatorViewModel.swift index 4e964b117..9d9c132ca 100644 --- a/Projects/TDPresentation/Sources/AppFlow/MainFlow/Home/EventMakor/TodoCreatorViewModel.swift +++ b/Projects/TDPresentation/Sources/AppFlow/MainFlow/Home/EventMakor/TodoCreatorViewModel.swift @@ -282,7 +282,8 @@ final class TodoCreatorViewModel: BaseViewModel { place: location, memo: memo, isFinished: false, - scheduleRecords: nil + scheduleRecords: nil, + source: .server ) return schedule diff --git a/Projects/TDPresentation/Sources/AppFlow/MainFlow/Home/HomeViewController.swift b/Projects/TDPresentation/Sources/AppFlow/MainFlow/Home/HomeViewController.swift index 46531e5ca..a606e9d2a 100644 --- a/Projects/TDPresentation/Sources/AppFlow/MainFlow/Home/HomeViewController.swift +++ b/Projects/TDPresentation/Sources/AppFlow/MainFlow/Home/HomeViewController.swift @@ -187,17 +187,23 @@ final class HomeViewController: BaseViewController { } let newViewController: UIViewController - let fetchScheduleListUseCase = DIContainer.shared.resolve(FetchScheduleListUseCase.self) + let fetchServerScheduleListUseCase = DIContainer.shared.resolve(FetchServerScheduleListUseCase.self) + let fetchLocalCalendarScheduleListUseCase = DIContainer.shared.resolve(FetchLocalCalendarScheduleListUseCase.self) + let fetchAllSchedulesUseCase = FetchAllSchedulesUseCaseImpl( + serverUseCase: fetchServerScheduleListUseCase, + localUseCase: fetchLocalCalendarScheduleListUseCase + ) switch index { case 0: let shouldMarkAllDayUseCase = DIContainer.shared.resolve(ShouldMarkAllDayUseCase.self) let viewModel = ToduckViewModel( - fetchScheduleListUseCase: fetchScheduleListUseCase, + fetchAllSchedulesUseCase: fetchAllSchedulesUseCase, shouldMarkAllDayUseCase: shouldMarkAllDayUseCase ) let toduckViewController = ToduckViewController(viewModel: viewModel) toduckViewController.delegate = self newViewController = toduckViewController + case 1: let createScheduleUseCase = DIContainer.shared.resolve(CreateScheduleUseCase.self) let createRoutineUseCase = DIContainer.shared.resolve(CreateRoutineUseCase.self) @@ -211,7 +217,7 @@ final class HomeViewController: BaseViewController { let viewModel = TodoViewModel( createScheduleUseCase: createScheduleUseCase, createRoutineUseCase: createRoutineUseCase, - fetchScheduleListUseCase: fetchScheduleListUseCase, + fetchAllSchedulesUseCase: fetchAllSchedulesUseCase, fetchRoutineListForDatesUseCase: fetchRoutineListForDatesUseCase, fetchRoutineUseCase: fetchRoutineUseCase, finishScheduleUseCase: finishScheduleUseCase, @@ -232,11 +238,9 @@ final class HomeViewController: BaseViewController { } private func replaceCurrentViewController(with newViewController: UIViewController) { - // 기존 뷰 컨트롤러 제거 currentViewController?.view.removeFromSuperview() currentViewController?.removeFromParent() - // 새 뷰 컨트롤러 추가 addChild(newViewController) view.addSubview(newViewController.view) diff --git a/Projects/TDPresentation/Sources/AppFlow/MainFlow/Home/Todo/TodoViewModel.swift b/Projects/TDPresentation/Sources/AppFlow/MainFlow/Home/Todo/TodoViewModel.swift index 6d986aeac..e94c90b29 100644 --- a/Projects/TDPresentation/Sources/AppFlow/MainFlow/Home/Todo/TodoViewModel.swift +++ b/Projects/TDPresentation/Sources/AppFlow/MainFlow/Home/Todo/TodoViewModel.swift @@ -24,7 +24,7 @@ final class TodoViewModel: BaseViewModel { private let createScheduleUseCase: CreateScheduleUseCase private let createRoutineUseCase: CreateRoutineUseCase - private let fetchScheduleListUseCase: FetchScheduleListUseCase + private let fetchAllSchedulesUseCase: FetchAllSchedulesUseCase private let fetchRoutineListForDatesUseCase: FetchRoutineListForDatesUseCase private let fetchRoutineUseCase: FetchRoutineUseCase private let finishScheduleUseCase: FinishScheduleUseCase @@ -43,7 +43,7 @@ final class TodoViewModel: BaseViewModel { init( createScheduleUseCase: CreateScheduleUseCase, createRoutineUseCase: CreateRoutineUseCase, - fetchScheduleListUseCase: FetchScheduleListUseCase, + fetchAllSchedulesUseCase: FetchAllSchedulesUseCase, fetchRoutineListForDatesUseCase: FetchRoutineListForDatesUseCase, fetchRoutineUseCase: FetchRoutineUseCase, finishScheduleUseCase: FinishScheduleUseCase, @@ -54,7 +54,7 @@ final class TodoViewModel: BaseViewModel { ) { self.createScheduleUseCase = createScheduleUseCase self.createRoutineUseCase = createRoutineUseCase - self.fetchScheduleListUseCase = fetchScheduleListUseCase + self.fetchAllSchedulesUseCase = fetchAllSchedulesUseCase self.fetchRoutineListForDatesUseCase = fetchRoutineListForDatesUseCase self.fetchRoutineUseCase = fetchRoutineUseCase self.finishScheduleUseCase = finishScheduleUseCase @@ -121,7 +121,7 @@ final class TodoViewModel: BaseViewModel { // MARK: - 투두 리스트 가져오기 private func fetchWeeklyTodoList(startDate: String, endDate: String) async { do { - let fetchedWeeklyScheduleList = try await fetchScheduleListUseCase.execute( + let fetchedWeeklyScheduleList = try await fetchAllSchedulesUseCase.execute( startDate: startDate, endDate: endDate ) diff --git a/Projects/TDPresentation/Sources/AppFlow/MainFlow/Home/Toduck/ToduckViewModel.swift b/Projects/TDPresentation/Sources/AppFlow/MainFlow/Home/Toduck/ToduckViewModel.swift index 956d6ee67..d1b858de8 100644 --- a/Projects/TDPresentation/Sources/AppFlow/MainFlow/Home/Toduck/ToduckViewModel.swift +++ b/Projects/TDPresentation/Sources/AppFlow/MainFlow/Home/Toduck/ToduckViewModel.swift @@ -17,7 +17,7 @@ final class ToduckViewModel: BaseViewModel { case failure(error: String) } - private let fetchScheduleListUseCase: FetchScheduleListUseCase + private let fetchAllSchedulesUseCase: FetchAllSchedulesUseCase private let shouldMarkAllDayUseCase: ShouldMarkAllDayUseCase private let output = PassthroughSubject() private var cancellables = Set() @@ -41,10 +41,10 @@ final class ToduckViewModel: BaseViewModel { } init( - fetchScheduleListUseCase: FetchScheduleListUseCase, + fetchAllSchedulesUseCase: FetchAllSchedulesUseCase, shouldMarkAllDayUseCase: ShouldMarkAllDayUseCase ) { - self.fetchScheduleListUseCase = fetchScheduleListUseCase + self.fetchAllSchedulesUseCase = fetchAllSchedulesUseCase self.shouldMarkAllDayUseCase = shouldMarkAllDayUseCase } @@ -63,35 +63,38 @@ final class ToduckViewModel: BaseViewModel { do { let todayNormalizedDate = Date().normalized let todayFormat = todayNormalizedDate.convertToString(formatType: .yearMonthDay) - let fetchedTodaySchedules = try await fetchScheduleListUseCase.execute( + + let allSchedules = try await fetchAllSchedulesUseCase.execute( startDate: todayFormat, endDate: todayFormat ) - if let todaySchedules = fetchedTodaySchedules[todayNormalizedDate] { + if let todaySchedules = allSchedules[todayNormalizedDate], !todaySchedules.isEmpty { isAllDays = shouldMarkAllDayUseCase.execute(with: todaySchedules) - currentSchedules = todaySchedules - .sorted { - Date.timeSortKey($0.time) < Date.timeSortKey($1.time) - } + let sortedSchedules = todaySchedules.sorted { + Date.timeSortKey($0.time) < Date.timeSortKey($1.time) + } - uncompletedSchedules = todaySchedules - .filter { schedule in - guard let records = schedule.scheduleRecords, !records.isEmpty else { - return true // 오늘 기록이 없으면 완료 안한 상태 - } - - if let todayRecord = records.first(where: { $0.recordDate == todayFormat }) { - return !todayRecord.isComplete // 기록이 있다면, 완료안된 것만 포함 - } else { - return true // 오늘 기록이 없으면 완료 안한 상태 - } + currentSchedules = sortedSchedules + + uncompletedSchedules = sortedSchedules.filter { schedule in + if schedule.source == .localCalendar { return true } + + guard let records = schedule.scheduleRecords, !records.isEmpty else { + return true // 오늘 기록이 없으면 완료 안한 상태 } - .sorted { - Date.timeSortKey($0.time) < Date.timeSortKey($1.time) + + if let todayRecord = records.first(where: { $0.recordDate == todayFormat }) { + return !todayRecord.isComplete // 기록이 있다면, 완료안된 것만 포함 + } else { + return true // 오늘 기록이 없으면 완료 안한 상태 } + } + output.send(.fetchedScheduleList(isEmpty: false)) } else { + currentSchedules = [] + uncompletedSchedules = [] output.send(.fetchedScheduleList(isEmpty: true)) } } catch { diff --git a/Projects/TDPresentation/Sources/AppFlow/MainFlow/MainTabBarCoordinator.swift b/Projects/TDPresentation/Sources/AppFlow/MainFlow/MainTabBarCoordinator.swift index 6693c42c2..59c578488 100644 --- a/Projects/TDPresentation/Sources/AppFlow/MainFlow/MainTabBarCoordinator.swift +++ b/Projects/TDPresentation/Sources/AppFlow/MainFlow/MainTabBarCoordinator.swift @@ -1,3 +1,4 @@ +import EventKit import UIKit import TDCore @@ -40,7 +41,55 @@ final class MainTabBarCoordinator: Coordinator { navigationController.setNavigationBarHidden(true, animated: false) tabBarController.setViewControllers(viewControllers, animated: false) navigationController.viewControllers = [tabBarController] + configurePushNotification() + checkCalendarPermissions() + } + + private func configurePushNotification() { + UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in + if granted { + DispatchQueue.main.async { + UIApplication.shared.registerForRemoteNotifications() + } + + if UserDefaults.standard.object(forKey: UserDefaultsConstant.pushEnabledKey) == nil { + UserDefaults.standard.set(true, forKey: UserDefaultsConstant.pushEnabledKey) + } + } else { + TDLogger.info("❌ 푸시 알림 권한 거부 또는 오류: \(error?.localizedDescription ?? "unknown error")") + UserDefaults.standard.set(false, forKey: "PushEnabled") + } + } + } + + private func checkCalendarPermissions() { + let eventStore = EKEventStore() + let status = EKEventStore.authorizationStatus(for: .event) + + switch status { + case .authorized: + TDLogger.info("✅ 캘린더 접근 권한이 이미 승인되었습니다.") + case .notDetermined: + eventStore.requestAccess(to: .event) { (granted, error) in + if let error = error { + TDLogger.error("🚨 캘린더 권한 요청 중 오류 발생: \(error.localizedDescription)") + return + } + + DispatchQueue.main.async { + if granted { + TDLogger.info("✅ 캘린더 접근 권한이 승인되었습니다.") + } else { + TDLogger.info("❌ 캘린더 접근 권한이 거부되었습니다.") + } + } + } + case .denied, .restricted: + TDLogger.info("❌ 캘린더 접근 권한이 거부되었거나 제한되었습니다.") + default: + TDLogger.info("Unhandled EKAuthorizationStatus case") + } } private func createNavigationController(for item: MainTabbarItem) -> UINavigationController { @@ -169,24 +218,3 @@ extension MainTabBarCoordinator: MainTabBarControllerDelegate { } } } - -extension MainTabBarCoordinator { - // MARK: - 푸시 알림 권한 요청 - - private func configurePushNotification() { - UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in - if granted { - DispatchQueue.main.async { - UIApplication.shared.registerForRemoteNotifications() - } - - if UserDefaults.standard.object(forKey: UserDefaultsConstant.pushEnabledKey) == nil { - UserDefaults.standard.set(true, forKey: UserDefaultsConstant.pushEnabledKey) - } - } else { - print("❌ 푸시 알림 권한 거부 또는 오류: \(error?.localizedDescription ?? "unknown error")") - UserDefaults.standard.set(false, forKey: "PushEnabled") - } - } - } -} diff --git a/Projects/TDStorage/Sources/ScheduleStorageImpl.swift b/Projects/TDStorage/Sources/ScheduleStorageImpl.swift new file mode 100644 index 000000000..15a470527 --- /dev/null +++ b/Projects/TDStorage/Sources/ScheduleStorageImpl.swift @@ -0,0 +1,24 @@ +import Foundation +import EventKit +import TDCore +import TDData + +final class ScheduleStorageImpl: ScheduleStorage { + private let eventStore = EKEventStore() + + func fetchEvents(from startDate: Date, to endDate: Date) async throws -> [EKEvent] { + guard EKEventStore.authorizationStatus(for: .event) == .authorized else { + throw TDDataError.permissionDenied + } + + let predicate = eventStore.predicateForEvents( + withStart: startDate, + end: endDate, + calendars: nil + ) + + let events = eventStore.events(matching: predicate) + + return events + } +} diff --git a/Projects/TDStorage/Sources/StorageAssembly.swift b/Projects/TDStorage/Sources/StorageAssembly.swift index b41fd6483..99a66b010 100644 --- a/Projects/TDStorage/Sources/StorageAssembly.swift +++ b/Projects/TDStorage/Sources/StorageAssembly.swift @@ -17,5 +17,9 @@ public struct StorageAssembly: Assembly { container.register(TimerStorage.self) { _ in TimerStorageImpl() } + + container.register(ScheduleStorage.self) { _ in + ScheduleStorageImpl() + } } } diff --git a/Projects/toduck/SupportingFiles/Info.plist b/Projects/toduck/SupportingFiles/Info.plist index ab2ed134b..548f31345 100644 --- a/Projects/toduck/SupportingFiles/Info.plist +++ b/Projects/toduck/SupportingFiles/Info.plist @@ -59,6 +59,8 @@ https://$(SERVER_URL) + NSCalendarsUsageDescription + 토덕에서 생성한 일정을 캘린더 앱에 자동으로 등록하고 관리하기 위해 사용자의 iOS 캘린더에 접근합니다. UILaunchStoryboardName LaunchScreen LSRequiresIPhoneOS