Skip to content
Open
3 changes: 3 additions & 0 deletions Projects/TDCore/Sources/Error/TDDataError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ public enum TDDataError: Error, Equatable {
case parsingError
case createRequestFailure
case fetchRequestFailure
case permissionDenied

/// 로그인
case invalidIDOrPassword
Expand Down Expand Up @@ -56,6 +57,8 @@ extension TDDataError: CustomStringConvertible {
"요청 생성 실패"
case .fetchRequestFailure:
"요청 실패"
case .permissionDenied:
"권한이 거부되었습니다."

/// 로그인 관련
case .invalidIDOrPassword:
Expand Down
3 changes: 2 additions & 1 deletion Projects/TDData/Sources/DTO/ScheduleListResponseDTO.swift
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,8 @@ public extension ScheduleHeadDTO {
place: location,
memo: memo,
isFinished: false,
scheduleRecords: records
scheduleRecords: records,
source: .server
)
}
}
5 changes: 4 additions & 1 deletion Projects/TDData/Sources/DataAssembly.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
63 changes: 43 additions & 20 deletions Projects/TDData/Sources/Repository/ScheduleRepositoryImpl.swift
Original file line number Diff line number Diff line change
@@ -1,48 +1,75 @@
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 {
let scheduleRequestDTO = ScheduleRequestDTO(schedule: schedule)
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,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

여기 hashValue 값이랑 서버에서 저장하고있는 id값이랑 겹치는 일은 없겠죠 ?

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
)
}
Comment on lines +48 to +66
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

⚠️ Potential issue

hashValue 기반 로컬 이벤트 ID는 비결정적입니다 — 안정적/충돌-회피 ID로 교체 필요

  • event.eventIdentifier.hashValue는 Swift의 구현 상세상 프로세스/빌드마다 바뀔 수 있어, 앱 재시작 후 동일 이벤트를 동일 ID로 식별하지 못할 수 있습니다. 머지/선택/스크롤 포지션 보존/디프 계산에서 문제가 됩니다. 또한 서버 일정(Int ID)과의 충돌 가능성도 배제하기 어렵습니다.
  • 권장: 이벤트 식별자(String)를 안정적 해시(SHA-256 등)로 변환해 결정적 Int를 만들고, 서버 ID와 충돌을 피하기 위해 음수로 네임스페이스 분리.

제안 diff(해당 라인 교체):

-                id: event.eventIdentifier.hashValue,
+                id: makeLocalCalendarId(eventIdentifier: event.eventIdentifier),

지도 함수(파일 내 private 영역에 추가):

// CryptoKit 필요: import CryptoKit
private func makeLocalCalendarId(eventIdentifier: String) -> Int {
    // 결정적 양의 해시값 생성
    let digest = SHA256.hash(data: Data(eventIdentifier.utf8))
    let v = digest.prefix(8).reduce(UInt64(0)) { ($0 << 8) | UInt64($1) } // 상위 8바이트 -> 64비트
    let positive = Int(truncatingIfNeeded: v & 0x7fffffffffffffff)
    // 서버(Int 양수로 가정)와 충돌 회피: 음수 영역으로 강제
    return positive > 0 ? -positive : positive
}

추가적으로(선택):

  • 카테고리 색상: EKCalendarcgColor를 노출합니다. 변환 유틸이 있다면 해당 색상으로 매핑해 주면 UX 일관성이 좋아집니다. 없으면 현행 기본값 유지도 OK입니다.
  • all-day/다일 이벤트: 현재는 단일 startDate/endDate만 설정합니다. 다일 확장(여러 날짜에 분할 표시)은 UseCase/UI에서 처리 중인지 확인해 주세요.

필요하시면 위 변경에 대한 유닛 테스트(같은 eventIdentifier → 항상 같은 ID, 프로세스 간 일관성, 음수 보장)를 작성해 드릴 수 있습니다.

}

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(
Expand All @@ -61,8 +88,4 @@ public final class ScheduleRepositoryImpl: ScheduleRepository {
queryDate: queryDate
)
}

public func moveTomorrowSchedule(scheduleId: Int) async throws {

}
}
15 changes: 15 additions & 0 deletions Projects/TDData/Sources/StorageProtocol/ScheduleStorage.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import Foundation
import EventKit

Comment on lines +1 to +3
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

플랫폼/권한 변화 대응 확인 요청

iOS 17 이후 EventKit 권한 상태 값이 확장되었습니다. 구현부(ScheduleStorageImpl)에서 .authorized만 허용하는지, 읽기 권한이 필요한 시나리오에서 최신 권한 상태들을 충분히 커버하는지 재확인 부탁드립니다.

아래 스캔으로 권한 분기 로직들을 점검해 주세요:


🏁 Script executed:

#!/bin/bash
# EventKit 권한 체크 사용처 점검
rg -nP --type=swift -C2 'EKEventStore\.authorizationStatus\('
rg -nP --type=swift -C2 '\.requestAccess\(\s*to:\s*\.event'

Length of output: 1908


iOS 17용 EventKit 권한 분기 로직 보강 필요

iOS 17부터 .authorized가 더 이상 전체 권한(full access)을 대표하지 않고,
.fullAccess (전체 읽기/쓰기)
.writeOnly (쓰기 전용)
가 새로 도입되었습니다.
현재 구현에서는 오직 .authorized만 허용하고 있어, iOS 17 기기에서 읽기 권한(full access)을 제대로 검증하지 못합니다.

다음 항목을 반영해 주세요:

• ScheduleStorageImpl.swift (fetchEvents)
EKEventStore.authorizationStatus(for: .event) 결과를
• iOS 17 이상: .fullAccess
• 그 외(iOS 16 이하): .authorized
두 상태 모두 허용하도록 분기 보강
– 예시(diff):

func fetchEvents(from startDate: Date, to endDate: Date) async throws -> [EKEvent] {
-    guard EKEventStore.authorizationStatus(for: .event) == .authorized else {
-        throw TDDataError.permissionDenied
-    }
+    let status = EKEventStore.authorizationStatus(for: .event)
+    switch status {
+    case .authorized, .fullAccess:
+        break
+    default:
+        throw TDDataError.permissionDenied
+    }
    // …이하 기존 로직
}

• MainTabBarCoordinator.swift (권한 요청)
– iOS 17 이상에서는 requestFullAccessToEvents / requestWriteOnlyAccessToEvents API 사용
– 기존 eventStore.requestAccess(to:.event) 대신 아래처럼 분기 처리

if #available(iOS 17, *) {
    eventStore.requestFullAccessToEvents { granted, error in
        // granted==true → full access 승인
    }
} else {
    eventStore.requestAccess(to: .event) { granted, error in
        // 기존 처리
    }
}

• Info.plist
– iOS 17 이상에서 전체 접근 요청에 필요한 NSCalendarsFullAccessUsageDescription 키 추가
– (쓰기만 필요하면 NSCalendarsUsageDescription + requestWriteOnlyAccessToEvents 사용)

위 변경 사항을 적용해 iOS 17 권한 상태(.fullAccess, .writeOnly 등)와 요청 API 모두 정상 동작하도록 확인 부탁드립니다.

/// 캘린더 데이터 소스(Storage)가 수행해야 하는 기능을 정의하는 프로토콜입니다.
/// EventKit 프레임워크에 직접 접근하여 원시 데이터인 `EKEvent`를 가져오는 역할을 합니다.
public protocol ScheduleStorage {

/// 지정된 기간에 해당하는 모든 캘린더 이벤트를 가져옵니다.
/// - Parameters:
/// - startDate: 조회를 시작할 날짜
/// - endDate: 조회를 종료할 날짜
/// - Returns: EventKit의 원시 데이터 모델인 `EKEvent`의 배열
/// - Throws: 권한이 없거나 데이터를 가져오는 중 오류가 발생하면 에러를 던집니다.
func fetchEvents(from startDate: Date, to endDate: Date) async throws -> [EKEvent]
}
15 changes: 4 additions & 11 deletions Projects/TDDomain/Sources/DomainAssembly.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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가 등록되어 있지 않습니다.")
Expand Down
10 changes: 9 additions & 1 deletion Projects/TDDomain/Sources/Entity/Schedule.swift
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -48,6 +55,7 @@ public struct Schedule: TodoItem {
self.memo = memo
self.isFinished = isFinished
self.scheduleRecords = scheduleRecords
self.source = source
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
@@ -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
}
}

This file was deleted.

Original file line number Diff line number Diff line change
@@ -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) {
Expand All @@ -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)

Expand Down Expand Up @@ -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
}
}
}
Loading