Skip to content
1 change: 1 addition & 0 deletions practice/HIGPractice/HIGPractice/Learning/LEARNING_LOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ For each completed scope, append one row:
| 2026-03-04 | Phase 1 | ActivityKit | DeliveryTracker (Local lifecycle + Widget UI + Debug logging) | #11 | - | - | Separated app control plane vs widget rendering plane, unified shared models, and validated local start/update/end debugging flow. |
| 2026-03-10 | Phase 1 | App Intents | SiriTodo sample adaptation, intents, shortcuts, concurrency troubleshooting | #16 | #18 | - | Reframed a standalone sample into an in-app learning flow, clarified `nonisolated` vs `MainActor.run`, and reduced App Shortcut registration to a valid minimal set. |
| 2026-03-12 | Phase 1 | SwiftUI | TaskMaster scaffold, shared layer, navigation hookup, concept notes | #20 | #22 | - | Reframed a standalone sample app into a navigable in-app demo and clarified `some View`, accessibility, `ContentUnavailableView`, and `static ModelContainer` responsibilities. |
| 2026-03-13 | Phase 1 | SwiftData | TaskMaster data flow review, CRUD path tracing, service/query notes | #23 | - | - | Clarified how `@Model`, `ModelContainer`, `@Query`, `@Bindable`, `FetchDescriptor`, and relationships connect storage changes to SwiftUI updates. |

## Weekly Reflection (Optional)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
- [x] ActivityKit
- [ ] App Intents
- [ ] SwiftUI
- [ ] SwiftData
- [x] SwiftData
- [ ] Observation
- [ ] Foundation Models

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# [학습] Phase 1 - SwiftData

- label: `learning`
- branch: `learning/swiftdata-taskmaster`
- issue: `#23`

## 학습 Phase
- Phase 1: App Frameworks

## Framework 이름
- SwiftData

## 이번 학습 목표
- `TaskMaster`를 기준으로 SwiftData의 앱 연결 지점을 설명할 수 있다.
- `@Model`, `ModelContainer`, `ModelContext`, `@Query`, `#Predicate`의 역할을 구분할 수 있다.
- `Create / Read / Update / Delete` 흐름이 `TaskMaster` 안에서 어디서 일어나는지 정리한다.
- SwiftData와 `UserDefaults`, `Keychain`의 저장 목적과 적용 범위를 비교 설명할 수 있다.
- 이후 `Observation` 학습으로 자연스럽게 넘어갈 수 있도록 데이터 흐름 메모를 남긴다.

## 작업 체크리스트
- [ ] site 개념 확인
- [ ] tutorials 실습
- [ ] samples 구조 비교
- [ ] `TaskMasterApp.swift` 읽고 container/context 연결 방식 정리
- [ ] `TaskItem.swift`, `Category.swift` 읽고 모델/관계 정의 정리
- [ ] `ContentView.swift`에서 `@Query`와 메모리 필터링 경계 정리
- [ ] `AddTaskView.swift`에서 create 흐름 정리
- [ ] `TaskDetailView.swift`에서 update/delete 흐름 정리
- [ ] `DataService.swift`에서 `FetchDescriptor`, `#Predicate`, 정렬 패턴 정리
- [ ] SwiftData vs UserDefaults vs Keychain 비교 메모 작성
- [ ] PR 생성 및 CI 확인
- [ ] 머지 후 `LEARNING_LOG`/Velog 기록

## 완료 조건 (Definition of Done)
- `TaskMaster` 기준 SwiftData 핵심 파일 7개의 역할을 스스로 설명할 수 있다.
- `@Query`와 `FetchDescriptor`의 사용 기준을 말할 수 있다.
- 왜 SwiftData가 `UserDefaults`나 `Keychain`의 대체재가 아닌지 설명할 수 있다.
- `SwiftData.md`에 파일별 메모와 비교 정리가 남아 있다.

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,18 @@ import SwiftData
struct AddTaskView: View {
// MARK: - 환경

// 실제 SwiftData 등록은 modelContext를 통해 수행한다.
@Environment(\.modelContext) private var modelContext
@Environment(\.dismiss) private var dismiss

// 카테고리 선택 UI를 구성하기 위해 저장소에서 목록을 읽는다.
@Query(sort: \Category.order)
private var categories: [Category]

// MARK: - 상태

// 사용자의 폼 입력은 우선 @State에 모아 둔다.
// 아직 "저장 확정" 전이므로 처음부터 TaskItem 모델에 직접 쓰지 않는다.
@State private var title = ""
@State private var hasDueDate = false
@State private var dueDate = Date()
Expand Down Expand Up @@ -174,17 +178,22 @@ struct AddTaskView: View {
// MARK: - 액션

private func addTask() {
// 저장 직전에 한 번 더 입력값을 정리한다.
let trimmedTitle = title.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedTitle.isEmpty else { return }

// 이 시점에만 SwiftData 모델 인스턴스를 만든다.
// 즉 폼 편집 상태와 저장 모델 생성을 분리한 구조다.
let task = TaskItem(
title: trimmedTitle,
dueDate: hasDueDate ? dueDate : nil,
priority: priority.rawValue,
notes: notes.trimmingCharacters(in: .whitespacesAndNewlines),
// 선택된 Category를 넘기면 관계도 함께 연결된다.
category: selectedCategory
)

// insert 이후에는 SwiftData가 변경 추적과 autosave 흐름을 이어받는다.
modelContext.insert(task)
dismiss()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,18 @@ import SwiftData
struct TaskDetailView: View {
// MARK: - 환경

// 삭제처럼 명시적인 데이터 작업은 modelContext를 통해 수행한다.
@Environment(\.modelContext) private var modelContext
@Environment(\.dismiss) private var dismiss

// 카테고리 선택용 목록 조회
@Query(sort: \Category.order)
private var categories: [Category]

// MARK: - 속성

// @Bindable 덕분에 TaskItem의 프로퍼티를
// TextField, Picker 같은 SwiftUI 입력과 직접 바인딩할 수 있다.
@Bindable var task: TaskItem

// MARK: - 상태
Expand All @@ -42,6 +46,7 @@ struct TaskDetailView: View {
// 기본 정보 섹션
Section("기본 정보") {
// 제목
// 모델 프로퍼티와 직접 바인딩되므로 입력값이 곧바로 task.title에 반영된다.
TextField("제목", text: $task.title)

// 우선순위
Expand Down Expand Up @@ -147,6 +152,7 @@ struct TaskDetailView: View {
"마감일",
selection: Binding(
get: { dueDate },
// optional Date를 다루기 위해 직접 Binding을 만들어 연결한다.
set: { task.dueDate = $0 }
),
displayedComponents: [.date, .hourAndMinute]
Expand Down Expand Up @@ -208,6 +214,8 @@ struct TaskDetailView: View {
// MARK: - 액션

private func deleteTask() {
// 삭제는 즉시 반영되는 편집과 다르게 명시적인 파괴 작업이므로
// 별도 alert 확인 뒤 modelContext.delete로 수행한다.
modelContext.delete(task)
dismiss()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,17 @@ import SwiftData
struct TaskMasterView: View {
// MARK: - 환경 & 쿼리

// modelContext는 "데이터 작업 창구"다.
// 읽기는 주로 @Query가 맡고, delete/insert/fetch 같은 변경 작업은 여기서 실행한다.
@Environment(\.modelContext) private var modelContext

// 전체 Task를 생성일 역순으로 읽는다.
// 이 시점의 조회는 SwiftData 저장소 기준이고,
// 아래 filteredTasks에서 화면 상태에 따라 한 번 더 메모리 필터링한다.
@Query(sort: \TaskItem.createdAt, order: .reverse)
private var allTasks: [TaskItem]

// Category도 저장소에서 바로 읽어오며, order 기준으로 정렬한다.
@Query(sort: \Category.order)
private var categories: [Category]

Expand All @@ -35,6 +41,8 @@ struct TaskMasterView: View {
// MARK: - 필터링된 할일

private var filteredTasks: [TaskItem] {
// 1차 데이터 소스는 @Query 결과다.
// 여기서부터는 DB 쿼리가 아니라 View 안에서 수행하는 메모리 필터링이다.
var tasks = allTasks

// 완료 상태 필터
Expand All @@ -57,6 +65,7 @@ struct TaskMasterView: View {
tasks = tasks.filter { $0.title.localizedCaseInsensitiveContains(searchText) }
}

// 최종적으로 화면에 그릴 배열을 반환한다.
return tasks
}

Expand Down Expand Up @@ -238,12 +247,15 @@ struct TaskMasterView: View {

private func deleteTask(_ task: TaskItem) {
withAnimation {
// 같은 modelContext에서 삭제하면 @Query가 연결된 목록도 함께 갱신된다.
modelContext.delete(task)
}
}

private func deleteCommpltedTasks() {
withAnimation {
// 복잡한 삭제 로직은 서비스로 위임하고,
// View는 "언제 실행할지"만 결정한다.
DataService.shared.deleteCompletedTasks(from: modelContext)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,17 +20,28 @@ final class Category {
/// 카테고리 이름
var name: String

/// 카테고리 대표 색상
/// - 실제 저장은 Color 자체가 아니라 hex 문자열로 한다.
var colorHex: String

/// 카테고리를 나타내는 SF Symbol 이름
var iconName: String

/// 생성 시각
var createdAt: Date

/// 목록에서의 정렬 순서
var order: Int

/// 이 카테고리에 속한 할일들
/// - Category -> TaskItem의 1:N 관계
/// - deleteRule이 .nullify라서 Category를 삭제해도 TaskItem까지 함께 삭제되지는 않는다.
/// - 대신 연결만 끊기므로, 각 TaskItem의 category는 nil이 된다.
@Relationship(deleteRule: .nullify)
var tasks: [TaskItem] = []

// Category 자체의 고유 데이터(name, colorHex, iconName)와
// 다른 모델과의 연결 데이터(tasks)를 나눠서 보면 관계 모델이 더 잘 보인다.
init(
name: String,
colorHex: String = "#007AFF",
Expand All @@ -49,11 +60,13 @@ final class Category {

extension Category {
/// SwiftUI Color 접근
/// - 저장된 hex 문자열을 화면에서 쓰기 쉬운 Color로 변환한다.
var color: Color {
Color(hex: colorHex) ?? .blue
}

/// 색상 설정
/// - View에서 선택한 Color를 다시 저장 가능한 hex 문자열로 바꾼다.
func setColor(_ color: Color) {
self.colorHex = color.toHex() ?? "#007AFF"
}
Expand All @@ -62,6 +75,7 @@ extension Category {
// MARK: - 통계
extension Category {
/// 완료되지 않은 할일 수
/// - 별도 저장값이 아니라 tasks 관계를 기준으로 매번 계산한다.
var pendingTaskCount: Int {
tasks.filter { !$0.isCompleted }.count
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,15 @@ final class DataService {
notes: notes,
category: category
)
// View 바깥에서도 insert는 결국 ModelContext가 담당한다.
context.insert(task)
return task
}

/// 모든 할일 조회
func fetchAllTasks(from context: ModelContext) -> [TaskItem] {
// FetchDescriptor는 "코드에서 직접 만드는 쿼리 객체"다.
// View 안의 @Query와 달리 서비스/로직 계층에서도 사용할 수 있다.
let descriptor = FetchDescriptor<TaskItem>(
sortBy: [SortDescriptor(\.createdAt, order: .reverse)]
)
Expand All @@ -52,6 +55,8 @@ final class DataService {

/// 미완료 할일만 조회
func fetchPendingTasks(from context: ModelContext) -> [TaskItem] {
// #Predicate는 타입 안전한 필터 조건이다.
// 문자열 NSPredicate보다 Swift 코드와 더 자연스럽게 연결된다.
let predicate = #Predicate<TaskItem> { !$0.isCompleted }
let descriptor = FetchDescriptor<TaskItem>(
predicate: predicate,
Expand All @@ -76,6 +81,8 @@ final class DataService {
let startOfDay = calendar.startOfDay(for: Date())
let endOfDay = calendar.date(byAdding: .day, value: 1, to: startOfDay)!

// predicate와 sort를 조합하면 "화면 상태"가 아닌
// 도메인 규칙에 가까운 조회를 서비스 함수로 캡슐화할 수 있다.
let predicate = #Predicate<TaskItem> { task in
task.dueDate! >= startOfDay &&
task.dueDate! < endOfDay
Expand Down Expand Up @@ -152,6 +159,7 @@ final class DataService {

/// 전체 통계 조회
func fetchStatistics(from context: ModelContext) -> TaskStatistics {
// 통계처럼 여러 조회/가공이 섞이는 로직은 View보다 서비스에 두는 편이 읽기 쉽다.
let all = fetchAllTasks(from: context)
let pending = all.filter { !$0.isCompleted }
let completed = all.filter { $0.isCompleted }
Expand Down Expand Up @@ -236,4 +244,3 @@ extension DataService {
task.toggleCompletion()
}
}

Loading