diff --git a/practice/HIGPractice/HIGPractice.xcodeproj/project.pbxproj b/practice/HIGPractice/HIGPractice.xcodeproj/project.pbxproj index 8cc9ddb4..fdc1ecdb 100644 --- a/practice/HIGPractice/HIGPractice.xcodeproj/project.pbxproj +++ b/practice/HIGPractice/HIGPractice.xcodeproj/project.pbxproj @@ -543,13 +543,14 @@ REGISTER_APP_GROUPS = YES; SDKROOT = auto; STRING_CATALOG_GENERATE_SYMBOLS = YES; - SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; + SUPPORTS_MACCATALYST = NO; SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2,7"; + TARGETED_DEVICE_FAMILY = "1,2"; XROS_DEPLOYMENT_TARGET = 26.2; }; name = Debug; @@ -588,13 +589,14 @@ REGISTER_APP_GROUPS = YES; SDKROOT = auto; STRING_CATALOG_GENERATE_SYMBOLS = YES; - SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; + SUPPORTS_MACCATALYST = NO; SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2,7"; + TARGETED_DEVICE_FAMILY = "1,2"; XROS_DEPLOYMENT_TARGET = 26.2; }; name = Release; diff --git a/practice/HIGPractice/HIGPractice/Features/Home/Views/PracticeHomeView.swift b/practice/HIGPractice/HIGPractice/Features/Home/Views/PracticeHomeView.swift index a92b3879..77e16ca6 100644 --- a/practice/HIGPractice/HIGPractice/Features/Home/Views/PracticeHomeView.swift +++ b/practice/HIGPractice/HIGPractice/Features/Home/Views/PracticeHomeView.swift @@ -38,6 +38,8 @@ struct PracticeHomeView: View { private func destination(for item: FrameworkItem) -> some View { if item.id == "appintents" { SiriTodoSampleRootView() + } else if item.id == "swiftui" { + TaskMasterRootView() } else { FrameworkDetailView(item: item) } diff --git a/practice/HIGPractice/HIGPractice/Learning/LEARNING_LOG.md b/practice/HIGPractice/HIGPractice/Learning/LEARNING_LOG.md index ec35d32a..e16cb9a4 100644 --- a/practice/HIGPractice/HIGPractice/Learning/LEARNING_LOG.md +++ b/practice/HIGPractice/HIGPractice/Learning/LEARNING_LOG.md @@ -23,6 +23,7 @@ For each completed scope, append one row: | 2026-02-26 | Phase 1 | WidgetKit | Theory review & architecture notes | #5 | #9 | https://devyuseong.tistory.com/38 | Clarified TimelineEntry/date, provider lifecycle, family-based density, and iOS 17 interactive widget patterns. | | 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. | ## Weekly Reflection (Optional) diff --git a/practice/HIGPractice/HIGPractice/Learning/Phase-01-AppFrameworks/AppIntents/SiriTodo/Shared/Models/Tag.swift b/practice/HIGPractice/HIGPractice/Learning/Phase-01-AppFrameworks/AppIntents/SiriTodo/Shared/Models/Tag.swift index d363e683..aed6e944 100644 --- a/practice/HIGPractice/HIGPractice/Learning/Phase-01-AppFrameworks/AppIntents/SiriTodo/Shared/Models/Tag.swift +++ b/practice/HIGPractice/HIGPractice/Learning/Phase-01-AppFrameworks/AppIntents/SiriTodo/Shared/Models/Tag.swift @@ -167,25 +167,3 @@ final class TagStore: ObservableObject { tags = decoded } } - -// MARK: - Color 확장 (Hex 지원) -extension Color { - - /// Hex 문자열로부터 Color 생성 - init?(hex: String) { - var hexSanitized = hex.trimmingCharacters(in: .whitespacesAndNewlines) - hexSanitized = hexSanitized.replacingOccurrences(of: "#", with: "") - - var rgb: UInt64 = 0 - - guard Scanner(string: hexSanitized).scanHexInt64(&rgb) else { - return nil - } - - let r = Double((rgb & 0xFF0000) >> 16) / 255.0 - let g = Double((rgb & 0x00FF00) >> 8) / 255.0 - let b = Double(rgb & 0x0000FF) / 255.0 - - self.init(red: r, green: g, blue: b) - } -} diff --git a/practice/HIGPractice/HIGPractice/Learning/Phase-01-AppFrameworks/SwiftUI/SwiftUI.md b/practice/HIGPractice/HIGPractice/Learning/Phase-01-AppFrameworks/SwiftUI/SwiftUI.md new file mode 100644 index 00000000..648eb544 --- /dev/null +++ b/practice/HIGPractice/HIGPractice/Learning/Phase-01-AppFrameworks/SwiftUI/SwiftUI.md @@ -0,0 +1,358 @@ +# SwiftUI + +## 학습 소스 +- site: `site/swiftui/01-tutorial.html` +- taskmaster folder: `practice/HIGPractice/HIGPractice/Learning/Phase-01-AppFrameworks/SwiftUI/TaskMaster` +- issue: 예정 +- branch: 예정 + +## 이번 학습 구조 +- `TaskMaster` + - `site/swiftui/01-tutorial.html`의 예제를 직접 카피하면서 SwiftUI 문법과 UI 구조를 익히는 학습용 폴더 + - 단순 문법 복습이 아니라, 실제 Todo 성격의 화면을 조합하면서 상태/리스트/네비게이션을 같이 익히는 단계 + +## 이번 학습 방식 +- 이번 SwiftUI 학습은 `Tutorial`과 `Sample`을 분리하지 않고, `TaskMaster` 안에서 코드를 카피하면서 익히는 방식으로 진행한다. +- 즉 흐름은: + 1. 사이트 예제를 읽는다 + 2. `TaskMaster` 안에 직접 코드를 옮긴다 + 3. 카피한 코드를 현재 프로젝트 기준으로 조금씩 수정한다 + 4. 최종적으로는 SwiftUI 구조와 상태 흐름을 스스로 설명할 수 있어야 한다 + +## Ring 1 — SwiftUI 개요 +### 개념 요약 +- SwiftUI는 선언형 방식으로 UI를 구성하는 Apple의 UI 프레임워크다. +- 핵심은 "어떻게 그릴지"보다 "현재 상태에서 무엇이 보여야 하는지"를 표현하는 데 있다. +- 코드가 UI 구조와 상태 변화를 함께 설명한다. + +### 내가 이해한 바 +- SwiftUI는 화면을 직접 갱신하는 프레임워크라기보다, 상태가 바뀌면 UI가 다시 계산되는 구조에 가깝다. +- 그래서 처음부터 커스텀 디자인을 만드는 것보다 `View 구조`, `상태`, `데이터 흐름`을 먼저 이해해야 한다. + +## SwiftUI 개념 메모 + +### 왜 `struct MyView: View`는 `var body: some View`가 필요할까 +- `View`는 프로토콜이고, SwiftUI는 각 View 타입이 자기 화면 구조를 `body`로 설명하길 기대한다. +- 즉 `body`는 "이 View가 실제로 어떤 하위 View들로 구성되는가"를 정의하는 핵심 속성이다. +- SwiftUI는 상태가 바뀌면 이 `body`를 다시 계산해 새로운 화면 구조를 만든다. + +### `some View`는 무엇인가 +- `some View`는 "구체 타입은 하나로 정해져 있지만, 그 타입 이름은 숨긴다"는 뜻이다. +- 예를 들어 `Text("Hello")`를 반환하면 실제 타입은 `Text`지만, 바깥에는 `some View`라고만 드러난다. +- 중요한 점은 `some View`가 "아무 View나 가능하다"는 뜻이 아니라, 실제로는 하나의 고정된 구체 타입이라는 점이다. + +### `any View`와의 차이 +- `any View`는 "`View` 프로토콜을 따르는 타입이면 무엇이든 담을 수 있는 프로토콜 타입"이다. +- 즉 `some View`는 "정체는 하나인데 숨긴 것"이고, +- `any View`는 "`View` 계열이면 무엇이든 담을 수 있는 큰 상자"에 가깝다. + +### 쉽게 비유하면 +- `some View` + - 봉투 안에 물건이 하나 들어 있고, 그 정체는 이미 정해져 있다. + - 다만 바깥에서 타입 이름만 숨긴 상태다. +- `any View` + - 상자 안에 `Text`, `Image`, `VStack`처럼 여러 종류의 View를 넣을 수 있다. + - 공통점은 전부 `View` 프로토콜을 따른다는 것뿐이다. + +### 왜 SwiftUI는 `some View`를 더 선호하나 +- SwiftUI는 View 타입 정보를 많이 활용해서 렌더링 구조와 변경 지점을 추적한다. +- 그래서 타입이 더 선명한 `some View`가 SwiftUI의 기본 구조와 잘 맞는다. +- 반대로 `any View`는 "일단 View이긴 한데 정확한 타입 정보는 감춘 상태"라서 SwiftUI가 정적 타입 정보를 덜 활용하게 된다. + +### 그럼 `body` 안에서 `if/else`는 왜 될까 +- 겉으로 보면 `if`에서는 `ProgressView`, `else`에서는 `Text`처럼 서로 다른 타입을 반환하는 것처럼 보인다. +- 그런데 `body`는 SwiftUI가 `@ViewBuilder`로 조립해주는 특별한 문맥이라, +- 여러 View 분기와 조합을 결국 하나의 View 타입으로 묶어준다. + +### `@ViewBuilder`는 무엇을 해주나 +- 여러 View 조각을 하나의 View 결과처럼 조립해주는 빌더다. +- 그래서 `body` 안에서는 `if`, `switch`, 여러 줄 View 선언이 자연스럽게 동작한다. +- 일반 함수에서는 이 처리가 없으면 서로 다른 View 타입을 `some View`로 바로 반환하기 어렵다. + +### `AnyView`는 언제 쓰나 +- `AnyView`는 서로 다른 View 타입을 같은 박스에 넣어 하나처럼 다루고 싶을 때 쓴다. +- 다만 SwiftUI에서는 가능하면 `some View`와 `@ViewBuilder`를 먼저 쓰고, +- 정말 타입을 지워야 할 때만 `AnyView`를 쓰는 편이 일반적이다. + +### 한 줄 정리 +- `body` = 이 View의 실제 화면 정의 +- `some View` = 구체 타입은 하나지만 이름은 숨김 +- `any View` = `View`면 무엇이든 담을 수 있는 프로토콜 타입 +- `@ViewBuilder` = 여러 View 조각을 하나의 View처럼 조립해주는 장치 + +## 접근성 메모 + +### SwiftUI의 accessibility 속성은 무엇을 하나 +- `accessibility...` 계열 modifier는 주로 `VoiceOver` 같은 보조기술이 화면 요소를 더 정확히 읽도록 돕는다. +- 즉 시각적으로 보이는 정보(색상, 선택 상태, 배지 숫자)를 텍스트 정보로 다시 전달하는 역할에 가깝다. + +### 자주 쓰는 속성 +- `accessibilityLabel` + - 이 요소의 이름을 정한다. + - 예: 화면에는 `업무`라고만 보여도, 보조기술에는 `업무 카테고리`라고 더 명확하게 읽히게 할 수 있다. +- `accessibilityValue` + - 현재 상태값을 전달한다. + - 예: `선택됨`, `3개 미완료` +- `accessibilityHint` + - 사용자가 이 요소를 조작하면 어떤 일이 일어나는지 설명한다. + - 예: `탭하면 업무 카테고리로 필터링합니다` +- `accessibilityAddTraits` + - 이 요소의 성격을 추가로 알려준다. + - 예: 현재 선택된 칩이면 `.isSelected`를 붙여 "선택된 항목"으로 인식하게 한다. + +### 왜 필요한가 +- 시각적으로는 색상, 배경, 배지, 볼드 처리로 상태를 표현할 수 있다. +- 하지만 VoiceOver 사용자는 그 시각적 차이를 직접 볼 수 없기 때문에, +- SwiftUI 접근성 속성으로 같은 의미를 텍스트/상태 정보로 전달해야 한다. + +### 이번 TaskMaster 예시로 보면 +- `CategoryChip`은 단순 버튼이 아니라 + - 어떤 카테고리인지 + - 몇 개가 남아 있는지 + - 지금 선택된 상태인지 + - 누르면 어떤 필터가 적용되는지 + 를 함께 설명해야 한다. +- 그래서 `accessibilityLabel`, `Value`, `Hint`, `Traits`를 같이 붙이는 것이 좋다. + +### 음성 명령과의 차이 +- 이 속성들은 주로 `VoiceOver`처럼 화면을 읽어주는 보조기술을 위한 것이다. +- 즉 `Siri`나 `App Intents`처럼 "말로 앱 기능을 실행하는 구조"와는 목적이 다르다. +- 정리하면: + - 접근성 속성 = UI 요소를 보조기술이 더 잘 읽게 만드는 정보 + - App Intents = 앱 기능을 시스템에 외부 액션으로 노출하는 구조 + +## 빈 상태 UI 메모 + +### `ContentUnavailableView`는 무엇인가 +- `ContentUnavailableView`는 보여줄 데이터가 없을 때 사용하는 SwiftUI의 시스템 기본 빈 상태 화면이다. +- 예를 들어: + - 할일 목록이 비어 있을 때 + - 검색 결과가 없을 때 + - 아직 데이터가 생성되지 않았을 때 + 같은 상황에 적합하다. + +### 왜 쓰는가 +- 빈 `List`나 빈 `ScrollView`만 보여주면 사용자는 "왜 아무것도 안 보이는지" 이해하기 어렵다. +- `ContentUnavailableView`를 쓰면 + - 현재 상태가 왜 비어 있는지 + - 다음에 무엇을 하면 되는지 + 를 시스템 스타일에 맞게 전달할 수 있다. + +### 어떤 요소로 구성되나 +- 제목 +- 시스템 이미지 +- 설명 텍스트 +- 필요하면 액션 버튼 + +### 예시 +```swift +ContentUnavailableView( + "할 일이 없습니다", + systemImage: "checklist", + description: Text("새 할 일을 추가해보세요.") +) +``` + +### TaskMaster에서의 사용 위치 +- `TaskMasterView`에서 전체 할일이 0개일 때 +- 검색 결과가 0개일 때 +- 특정 카테고리 필터 결과가 비어 있을 때 +- 즉 "콘텐츠가 없는 이유를 사용자에게 바로 설명해야 하는 화면"에 쓰는 게 좋다. + +### 한 줄 정리 +- `ContentUnavailableView` = 데이터가 없을 때 보여주는 SwiftUI 기본 empty state UI + +## 타입 / 인스턴스 / static 메모 + +### 타입과 인스턴스란 +- 타입은 설계도에 가깝다. +- 인스턴스는 그 설계도로 실제 만들어진 값 하나다. +- 비유하면: + - 타입 = 붕어빵 틀 + - 인스턴스 = 실제 만들어진 붕어빵 1개 + +### 인스턴스 프로퍼티와 타입 프로퍼티의 차이 +- 인스턴스 프로퍼티 + - 각 인스턴스가 자기 값을 가진다. + - 예: `task.title`, `task.isCompleted` +- 타입 프로퍼티(`static`) + - 타입 전체가 하나의 값을 공유한다. + - 예: `TaskMasterRootView.sharedModelContainer` + +### 왜 `static`이 중요한가 +- SwiftUI의 `View`는 `struct` 기반이라 값 타입이고, 새로 계산되거나 재생성될 수 있다. +- 그런데 `ModelContainer`처럼 생성 비용이 크고 여러 화면이 같이 써야 하는 자원은 + - 각 View 인스턴스가 따로 가지는 값보다 + - 타입 전체가 공유하는 값으로 두는 편이 더 자연스럽다. +- 그래서 이런 자원은 일반 `let`보다 `static let`이 더 적합하다. + +### `private let` vs `private static let` +- `private let` + - 인스턴스 소속 고정 값 + - View 인스턴스가 자기 값을 가지는 구조 +- `private static let` + - 타입 전체가 공유하는 고정 값 + - 무거운 공용 자원, 공통 설정, 공용 컨테이너에 적합 + +### `ModelContainer`는 무엇인가 +- SwiftData에서 실제 저장소 역할을 하는 객체다. +- `TaskItem`, `Category` 같은 모델을 어떤 schema로 저장할지 정하고, +- 하위 View들이 `modelContext`와 `@Query`를 쓸 수 있도록 데이터 환경을 제공한다. + +### `.modelContainer(...)`는 무엇을 하나 +- 특정 `ModelContainer`를 View 계층에 주입한다. +- 이 modifier가 있어야 하위 View에서: + - `@Environment(\\.modelContext)` + - `@Query` + 가 같은 SwiftData 저장소를 기준으로 동작할 수 있다. + +### 왜 `TaskMasterRootView`에 `sharedModelContainer`를 두는가 +- `TaskMasterApp.swift`의 `@main` 역할 전체를 그대로 가져오는 것이 아니라, +- 앱 진입 시점에 하던 SwiftData 준비 작업만 `RootView`로 옮긴다. +- 즉 `TaskMasterRootView`는: + - `ModelContainer` 생성 + - 기본 카테고리 초기화 + - `TaskMasterView`에 SwiftData 환경 주입 + 을 담당하는 래퍼 역할이다. + +### 한 줄 정리 +- 타입 = 설계도 +- 인스턴스 = 실제 값 하나 +- `static let` = 타입 전체가 공유하는 공용 자원 +- `ModelContainer` = SwiftData 저장소 본체 +- `.modelContainer(...)` = 그 저장소를 View 트리에 주입하는 작업 + +## Ring 2 — 기본 View 구조와 Modifier +### 개념 요약 +- 모든 화면은 `View` 프로토콜을 따르는 타입으로 시작한다. +- `VStack`, `HStack`, `ZStack`, `Text`, `Image`, `Button` 같은 작은 View를 조합해 화면을 만든다. +- Modifier는 View의 모양과 동작을 단계적으로 바꾼다. + +### 이번에 먼저 볼 것 +- `Text`, `Image`, `Button` +- `padding`, `font`, `foregroundStyle`, `background` +- 커스텀 `ViewModifier` + +### 검증 +- [ ] 하나의 카드형 UI를 `VStack + modifier` 조합으로 만들기 +- [ ] 같은 스타일을 커스텀 modifier로 추출하기 + +## Ring 3 — State와 Binding +### 개념 요약 +- SwiftUI의 핵심은 상태 기반 렌더링이다. +- `@State`는 View 내부의 로컬 상태다. +- `$binding`은 상태를 하위 View나 입력 컴포넌트와 연결할 때 사용한다. + +### 이번에 먼저 볼 것 +- `@State` +- `TextField`, `SecureField` +- `disabled` +- 입력값과 버튼 활성화 연결 + +### 내가 이해한 바 +- SwiftUI에서 중요한 건 "UI를 바꾸는 코드"보다 "상태를 바꾸는 코드"다. +- 상태가 바뀌면 UI는 다시 계산되므로, 로직 중심으로 생각하는 습관이 더 중요하다. + +### 검증 +- [ ] 입력 폼 하나를 만들고 버튼 활성화 조건 연결 +- [ ] 카운터/토글처럼 상태 변화가 즉시 UI에 반영되는지 확인 + +## Ring 4 — List, ForEach, Navigation +### 개념 요약 +- SwiftUI 앱의 대부분은 "목록 -> 상세" 구조를 가진다. +- `List`와 `ForEach`는 컬렉션 기반 UI의 기본이다. +- `NavigationStack`은 현재 iOS 기준 기본 네비게이션 컨테이너다. + +### 이번에 먼저 볼 것 +- `Identifiable` +- `List` +- `ForEach` +- `NavigationStack` +- `NavigationLink` + +### 검증 +- [ ] Todo 형태의 리스트를 렌더링 +- [ ] 리스트 항목 탭 시 상세 화면으로 이동 +- [ ] 삭제/토글 같은 간단한 액션 붙이기 + +### 회고 포인트 +- 값 기반 navigation과 destination 연결은 구조를 단순하게 유지해야 디버깅이 쉽다. +- 작은 샘플이라도 `목록 -> 상세` 흐름을 먼저 고정하면 이후 확장이 편하다. + +## Ring 5 — 애니메이션과 스타일 +### 개념 요약 +- SwiftUI 애니메이션은 "상태 변화에 따라 어떤 전환을 보여줄지"를 선언하는 방식이다. +- 과한 모션보다 상태 변화를 보조하는 수준이 좋다. +- HIG 관점에서는 여백, 계층, 가독성, 터치 영역이 먼저고 애니메이션은 그 다음이다. + +### 이번에 먼저 볼 것 +- `.animation(..., value:)` +- `spring` +- 회전/크기 변화 +- 버튼 스타일, 카드 스타일 + +### 검증 +- [ ] 상태 토글에 따른 크기/회전 애니메이션 추가 +- [ ] 버튼 스타일과 카드 스타일을 공통화 + +## Ring 6 — 상태 관리 확장 +### 개념 요약 +- 단순 화면 상태는 `@State` +- 여러 화면/도메인 상태는 별도 모델 계층으로 분리 +- iOS 17+에서는 `@Observable` 중심 설계가 권장된다. + +### 이번에 먼저 볼 것 +- `@Observable` +- `ObservableObject`와 차이 +- 어떤 상태를 View 안에 두고, 어떤 상태를 외부 모델로 뺄지 기준 세우기 + +### 내가 이해한 바 +- SwiftUI 학습에서 가장 중요한 건 화면 코드보다 "상태를 어디에 둘 것인가"다. +- `SwiftUI`와 `Observation`을 섞어 이해하기보다, + - SwiftUI는 View 계층 + - Observation은 상태 관리 계층 + 로 나눠서 보는 게 덜 헷갈린다. + +## site → TaskMaster 적용 플로우 + +### 1) 사이트 예제로 개념을 빠르게 익힌다 +- View 구조 +- Modifier +- State / Binding +- List / Navigation +- Animation + +### 2) `TaskMaster` 안에 코드를 직접 옮긴다 +- 할일 목록 화면 +- 입력 폼 화면 +- 상세 화면 +- 공통 카드 스타일 + +### 3) 카피한 코드를 보면서 다시 체크할 질문 +- 이 화면 상태는 `@State`인가, 외부 모델인가? +- 이 View는 너무 많은 책임을 가지지 않는가? +- 공통 스타일/레이아웃을 추출해야 하는가? +- Navigation 구조가 단순하게 유지되는가? + +## SwiftUI 실무 적용 원칙 +- 작은 View를 조합해 큰 화면을 만든다. +- 상태를 먼저 정의하고 UI는 그 상태를 반영하게 만든다. +- Modifier 중복은 공통 스타일로 추출한다. +- `List`, `NavigationStack`, `Form`, `Section` 같은 시스템 컴포넌트를 우선 활용한다. +- 커스텀 디자인은 구조와 데이터 흐름이 안정화된 뒤 붙인다. + +## 이번 학습의 목표 +- [ ] `TaskMaster` 안에서 SwiftUI 핵심 문법 예제를 직접 옮기며 다시 정리 +- [ ] Todo 스타일 샘플 화면 1세트를 `TaskMaster` 안에서 구현 +- [ ] `@State`와 `@Observable`의 경계를 실전 기준으로 이해 +- [ ] HIG 관점에서 여백, 계층, 터치 영역을 체크 + +## 최종 정리 +- 오늘 배울 핵심 3가지: + 1. SwiftUI는 선언형 UI 프레임워크이고, 상태가 UI를 결정한다. + 2. View 구조보다 더 중요한 것은 상태와 데이터 흐름의 위치다. + 3. 사이트 예제를 `TaskMaster`에 직접 옮겨보면서 구조를 손으로 익혀야 학습이 굳는다. +- 다음 액션: + 1. `TaskMaster` 폴더에 기초 예제부터 순서대로 정리 + 2. `TaskMaster` 안에 Todo 기반 화면 샘플 구성 + 3. 이후 `Observation` 학습과 연결해 상태 관리 경계를 다시 점검 diff --git a/practice/HIGPractice/HIGPractice/Learning/Phase-01-AppFrameworks/SwiftUI/TaskMaster/App/AddTaskView.swift b/practice/HIGPractice/HIGPractice/Learning/Phase-01-AppFrameworks/SwiftUI/TaskMaster/App/AddTaskView.swift new file mode 100644 index 00000000..a8036eea --- /dev/null +++ b/practice/HIGPractice/HIGPractice/Learning/Phase-01-AppFrameworks/SwiftUI/TaskMaster/App/AddTaskView.swift @@ -0,0 +1,251 @@ +// +// AddTaskView.swift +// HIGPractice +// +// Created by YuSeongChoi on 3/10/26. +// + +import SwiftUI +import SwiftData + +// MARK: - 할일 추가 뷰 + +/// 새로운 할일을 추가하는 Sheet 뷰 +/// - 제목 입력 (필수) +/// - 마감일 설정 (선택) +/// - 우선순위 선택 +/// - 카테고리 선택 +/// - 메모 입력 +struct AddTaskView: View { + // MARK: - 환경 + + @Environment(\.modelContext) private var modelContext + @Environment(\.dismiss) private var dismiss + + @Query(sort: \Category.order) + private var categories: [Category] + + // MARK: - 상태 + + @State private var title = "" + @State private var hasDueDate = false + @State private var dueDate = Date() + @State private var priority: TaskPriority = .none + @State private var selectedCategory: Category? + @State private var notes = "" + + @FocusState private var isTitleFocused: Bool + + // MARK: - 유효성 검사 + + private var isValid: Bool { + !title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + // MARK: - 뷰 본문 + + var body: some View { + NavigationStack { + Form { + // 제목 섹션 + Section { + TextField("할일 제목", text: $title) + .focused($isTitleFocused) + } header: { + Text("제목") + } footer: { + if title.isEmpty { + Text("제목을 입력해주세요") + .foregroundStyle(.secondary) + } + } + + // 마감일ㄹ 섹션 + Section("마감일") { + Toggle("마감일 설정", isOn: $hasDueDate.animation()) + + if hasDueDate { + DatePicker( + "마감일", + selection: $dueDate, + displayedComponents: [.date, .hourAndMinute] + ) + .datePickerStyle(.graphical) + + // 빠른 선택 버튼 + quickDatePicker + } + } + + // 우선순위 섹션 + Section("우선순위") { + Picker("우선순위", selection: $priority) { + ForEach(TaskPriority.allCases) { p in + Label(p.name, systemImage: p.symbol) + .tag(p) + } + } + .pickerStyle(.segmented) + } + + // 카테고리 섹션 + Section("카테고리") { + if categories.isEmpty { + Text("카테고리가 없습니다") + .foregroundStyle(.secondary) + } else { + categoryPicker + } + } + + // 메모 섹션 + Section("메모") { + TextField("메모 (선택)", text: $notes, axis: .vertical) + .lineLimit(3...6) + } + } + .navigationTitle("새 할일") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("취소") { + dismiss() + } + } + + ToolbarItem(placement: .confirmationAction) { + Button("추가") { + addTask() + } + .fontWeight(.semibold) + .disabled(!isValid) + } + } + .onAppear { + isTitleFocused = true + } + } + } + + // MARK: - 서브뷰: 빠른 날짜 선택 + + private var quickDatePicker: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + QuickDateButton(title: "오늘", date: Date()) { dueDate = $0 } + QuickDateButton(title: "내일", date: Calendar.current.date(byAdding: .day, value: 1, to: Date())!) { dueDate = $0 } + QuickDateButton(title: "다음 주", date: Calendar.current.date(byAdding: .weekOfYear, value: 1, to: Date())!) { dueDate = $0 } + QuickDateButton(title: "다음 달", date: Calendar.current.date(byAdding: .month, value: 1, to: Date())!) { dueDate = $0 } + } + } + } + + // MARK: - 서브뷰: 카테고리 선택 + + private var categoryPicker: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + // "없음" 옵션 + CategorySelectButton( + name: "없음", + color: .gray, + iconName: "minus.circle", + isSelected: selectedCategory == nil + ) { + selectedCategory = nil + } + + // 카테고리 목록 + ForEach(categories) { category in + CategorySelectButton( + name: category.name, + color: category.color, + iconName: category.iconName, + isSelected: selectedCategory?.persistentModelID == category.persistentModelID + ) { + selectedCategory = category + } + } + } + .padding(.vertical, 4) + } + } + + // MARK: - 액션 + + private func addTask() { + let trimmedTitle = title.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedTitle.isEmpty else { return } + + let task = TaskItem( + title: trimmedTitle, + dueDate: hasDueDate ? dueDate : nil, + priority: priority.rawValue, + notes: notes.trimmingCharacters(in: .whitespacesAndNewlines), + category: selectedCategory + ) + + modelContext.insert(task) + dismiss() + } + +} + +// MARK: - 빠른 날짜 버튼 + +struct QuickDateButton: View { + let title: String + let date: Date + let action: (Date) -> Void + + var body: some View { + Button { + action(date) + } label: { + Text(title) + .font(.caption) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(Color.accentColor.opacity(0.1)) + .foregroundStyle(.black) + .clipShape(Capsule()) + } + .buttonStyle(.plain) + } +} + +// MARK: - 카테고리 선택 버튼 + +struct CategorySelectButton: View { + let name: String + let color: Color + let iconName: String + let isSelected: Bool + let action: () -> Void + + var body: some View { + Button(action: action) { + HStack(spacing: 6) { + Image(systemName: iconName) + .font(.caption) + Text(name) + .font(.subheadline) + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(isSelected ? color : color.opacity(0.1)) + .foregroundStyle(isSelected ? .white : color) + .clipShape(Capsule()) + .overlay( + Capsule() + .strokeBorder(isSelected ? color : .clear, lineWidth: 2) + ) + } + .buttonStyle(.plain) + } +} + +#Preview { + AddTaskView() + .modelContainer(.preview) +} diff --git a/practice/HIGPractice/HIGPractice/Learning/Phase-01-AppFrameworks/SwiftUI/TaskMaster/App/TaskDetailView.swift b/practice/HIGPractice/HIGPractice/Learning/Phase-01-AppFrameworks/SwiftUI/TaskMaster/App/TaskDetailView.swift new file mode 100644 index 00000000..7799823b --- /dev/null +++ b/practice/HIGPractice/HIGPractice/Learning/Phase-01-AppFrameworks/SwiftUI/TaskMaster/App/TaskDetailView.swift @@ -0,0 +1,271 @@ +// +// TaskDetailView.swift +// HIGPractice +// +// Created by YuSeongChoi on 3/10/26. +// + +import SwiftUI +import SwiftData + +// MARK: - 할일 상세/편집 뷰 + +/// 할일의 상세 정보를 표시하고 편집하는 뷰 +/// - 모든 속성 편집 가능 +/// - 완료/삭제 액션 +struct TaskDetailView: View { + // MARK: - 환경 + + @Environment(\.modelContext) private var modelContext + @Environment(\.dismiss) private var dismiss + + @Query(sort: \Category.order) + private var categories: [Category] + + // MARK: - 속성 + + @Bindable var task: TaskItem + + // MARK: - 상태 + + @State private var showingDeleteAlert = false + + // MARK: - 뷰 본문 + + var body: some View { + Form { + // 상태 섹션 + Section { + statusRow + } + + // 기본 정보 섹션 + Section("기본 정보") { + // 제목 + TextField("제목", text: $task.title) + + // 우선순위 + Picker("우선순위", selection: $task.taskPriority) { + ForEach(TaskPriority.allCases) { priority in + Label(priority.name, systemImage: priority.symbol) + .tag(priority) + } + } + } + + // 마감일 섹션 + Section("마감일") { + dueDateSection + } + + // 카테고리 섹션 + Section("카테고리") { + categorySection + } + + // 메모 섹션 + Section("메모") { + TextField("메모", text: $task.notes, axis: .vertical) + .lineLimit(3...10) + } + + // 정보 섹션 + Section("정보") { + infoRow(title: "생성일", value: formattedDate(task.createdAt)) + + if let completedAt = task.completedAt { + infoRow(title: "완료일", value: formattedDate(completedAt)) + } + } + + // 삭제 섹션 + Section { + Button(role: .destructive) { + showingDeleteAlert = true + } label: { + HStack { + Spacer() + Label("할일 삭제", systemImage: "trash") + Spacer() + } + } + } + } + .navigationTitle("할일 상세") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { + task.toggleCompletion() + } label: { + Image(systemName: task.isCompleted ? "arrow.uturn.backward.circle" : "checkmark.circle.fill") + } + } + } + .alert("할일 삭제", isPresented: $showingDeleteAlert) { + Button("취소", role: .cancel) { } + Button("삭제", role: .destructive) { + deleteTask() + } + } message: { + Text("'\(task.title)'을(를) 삭제하시겠습니까?") + } + } + + // MARK: - 서브뷰: 상태 Row + + private var statusRow: some View { + HStack { + // 완료 상태 뱃지 + HStack(spacing: 6) { + Image(systemName: task.isCompleted ? "checkmark.circle.fill" : "circle") + .foregroundStyle(task.isCompleted ? .green : .orange) + Text(task.isCompleted ? "완료됨" : "진행 중") + .fontWeight(.medium) + } + + Spacer() + + // 마감 상태 뱃지 + if !task.isCompleted { + if task.isOverdue { + StatusBadge(text: "마감 지남", color: .red) + } else if task.isDueSoon { + StatusBadge(text: "마감 임박", color: .orange) + } + } + } + .padding(.vertical, 4) + } + + // MARK: - 서브뷰: 마감일 섹션 + + private var dueDateSection: some View { + Group { + if let dueDate = task.dueDate { + DatePicker( + "마감일", + selection: Binding( + get: { dueDate }, + set: { task.dueDate = $0 } + ), + displayedComponents: [.date, .hourAndMinute] + ) + + Button("마감일 제거", role: .destructive) { + task.dueDate = nil + } + } else { + Button("마감일 추가") { + task.dueDate = Date() + } + } + } + } + + // MARK: - 서브뷰: 카테고리 섹션 + + private var categorySection: some View { + Group { + if categories.isEmpty { + Text("사용 가능한 카테고리가 없습니다") + .foregroundStyle(.secondary) + } else { + Picker("카테고리", selection: $task.category) { + Text("없음") + .tag(nil as Category?) + + ForEach(categories) { category in + Label(category.name, systemImage: category.iconName) + .tag(category as Category?) + } + } + } + } + } + + // MARK: - 서브뷰: 정보 Row + + private func infoRow(title: String, value: String) -> some View { + HStack { + Text(title) + .foregroundStyle(.secondary) + Spacer() + Text(value) + } + } + + // MARK: - 헬퍼 + + private func formattedDate(_ date: Date) -> String { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "ko_KR") + formatter.dateStyle = .medium + formatter.timeStyle = .short + return formatter.string(from: date) + } + + // MARK: - 액션 + + private func deleteTask() { + modelContext.delete(task) + dismiss() + } + +} + +// MARK: - 상태 뱃지 + +struct StatusBadge: View { + let text: String + let color: Color + + var body: some View { + Text(text) + .font(.caption) + .fontWeight(.medium) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(color.opacity(0.15)) + .foregroundStyle(color) + .clipShape(Capsule()) + } +} + + +// MARK: - 프리뷰 + +#Preview("진행 중") { + NavigationStack { + TaskDetailView(task: TaskItem( + title: "SwiftData 문서 읽기", + dueDate: Calendar.current.date(byAdding: .day, value: 1, to: Date()), + priority: 3, + notes: "WWDC23 세션 영상도 함께 보기\n공식 문서 링크 확인" + )) + } + .modelContainer(.preview) +} + +#Preview("완료됨") { + NavigationStack { + let task = TaskItem( + title: "완료된 할일", + isCompleted: true, + priority: 1 + ) + TaskDetailView(task: task) + } + .modelContainer(.preview) +} + +#Preview("마감 지남") { + NavigationStack { + TaskDetailView(task: TaskItem( + title: "마감 지난 할일", + dueDate: Calendar.current.date(byAdding: .day, value: -2, to: Date()), + priority: 2 + )) + } + .modelContainer(.preview) +} diff --git a/practice/HIGPractice/HIGPractice/Learning/Phase-01-AppFrameworks/SwiftUI/TaskMaster/App/TaskMasterRootView.swift b/practice/HIGPractice/HIGPractice/Learning/Phase-01-AppFrameworks/SwiftUI/TaskMaster/App/TaskMasterRootView.swift new file mode 100644 index 00000000..40b66437 --- /dev/null +++ b/practice/HIGPractice/HIGPractice/Learning/Phase-01-AppFrameworks/SwiftUI/TaskMaster/App/TaskMasterRootView.swift @@ -0,0 +1,83 @@ +// +// TaskMasterRootView.swift +// HIGPractice +// +// Created by YuSeongChoi on 3/10/26. +// + +import SwiftUI +import SwiftData + +// MARK: - TaskMaster 진입점 + +/// TaskMaster 앱의 메인 에트리 포인트 +/// - SwiftData ModelContainer 설정 +/// - 기본 카테고리 초기화 +struct TaskMasterRootView: View { + + /// SwiftData 모델 컨테이너 + /// - TaskItem, Category 모델을 포함 + private static let sharedModelContainer: ModelContainer = { + let schema = Schema([ + TaskItem.self, + Category.self + ]) + let modelConfiguration = ModelConfiguration( + schema: schema, + isStoredInMemoryOnly: false // 영구 저장 + ) + + do { + return try ModelContainer( + for: schema, + configurations: [modelConfiguration] + ) + } catch { + // 치명적 오류 - 앱 실행 불가 + fatalError("ModelContainer 생성 실패: \(error)") + } + }() + + var body: some View { + TaskMasterView() + .onAppear { + // 첫 실행 시 기본 카테고리 초기화 + initializeDefaultData() + } + .modelContainer(Self.sharedModelContainer) + } + + @MainActor + private func initializeDefaultData() { + let context = Self.sharedModelContainer.mainContext + DataService.shared.initializeDefaultCategories(in: context) + } +} + +// MARK: - 프리뷰용 컨테이너 + +/// SwiftUI 프리뷰를 위한 인메모리 ModelContainer +extension ModelContainer { + @MainActor + static var preview: ModelContainer { + let schema = Schema([TaskItem.self, Category.self]) + let configuration = ModelConfiguration( + schema: schema, + isStoredInMemoryOnly: true // 메모리에만 저장 (프리뷰용) + ) + + do { + let container = try ModelContainer( + for: schema, + configurations: [configuration] + ) + + // 샘플 데이터 추가 + DataService.shared.createSampleData(in: container.mainContext) + + return container + } catch { + fatalError("Preview ModelContainer 생성 실패: \(error)") + } + } +} diff --git a/practice/HIGPractice/HIGPractice/Learning/Phase-01-AppFrameworks/SwiftUI/TaskMaster/App/TaskMasterView.swift b/practice/HIGPractice/HIGPractice/Learning/Phase-01-AppFrameworks/SwiftUI/TaskMaster/App/TaskMasterView.swift new file mode 100644 index 00000000..b9ead225 --- /dev/null +++ b/practice/HIGPractice/HIGPractice/Learning/Phase-01-AppFrameworks/SwiftUI/TaskMaster/App/TaskMasterView.swift @@ -0,0 +1,308 @@ +// +// TaskMasterView.swift +// HIGPractice +// +// Created by YuSeongChoi on 3/10/26. +// + +import SwiftUI +import SwiftData + +// MARK: - 메인 콘텐츠 뷰 + +/// 할일 목록을 표시하는 메인 뷰 +/// - 필터링 (전체/미완료/완료) +/// - 카테괼별 필터 +/// - 정렬 옵션 +struct TaskMasterView: View { + // MARK: - 환경 & 쿼리 + + @Environment(\.modelContext) private var modelContext + + @Query(sort: \TaskItem.createdAt, order: .reverse) + private var allTasks: [TaskItem] + + @Query(sort: \Category.order) + private var categories: [Category] + + // MARK: - 상태 + + @State private var showingAddTask = false + @State private var selectedFilter: TaskFilter = .pending + @State private var selectedCategory: Category? + @State private var searchText = "" + + // MARK: - 필터링된 할일 + + private var filteredTasks: [TaskItem] { + var tasks = allTasks + + // 완료 상태 필터 + switch selectedFilter { + case .all: + break + case .pending: + tasks = tasks.filter { !$0.isCompleted } + case .completed: + tasks = tasks.filter { $0.isCompleted } + } + + // 카테고리 필터 + if let category = selectedCategory { + tasks = tasks.filter { $0.category?.persistentModelID == category.persistentModelID } + } + + // 검색 필터 + if !searchText.isEmpty { + tasks = tasks.filter { $0.title.localizedCaseInsensitiveContains(searchText) } + } + + return tasks + } + + // MARK: - 뷰 본문 + + var body: some View { + NavigationStack { + VStack(spacing: 0) { + // 필터 피커 + filterPicker + + // 카테고리 스크롤 + categoryScroll + + // 할일 목록 또는 빈 상태 + if filteredTasks.isEmpty { + emptyStateView + } else { + taskList + } + } + .navigationTitle("TaskMaster") + .searchable(text: $searchText, prompt: "할일 검색") + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { + showingAddTask = true + } label: { + Image(systemName: "plus.circle.fill") + .font(.title2) + } + .accessibilityLabel("새 할일 추가") + .accessibilityHint("탭하면 새 할일을 추가할 수 있습니다") + } + + ToolbarItem(placement: .topBarLeading) { + Menu { + Button("완료된 항목 삭제", role: .destructive) { + deleteCommpltedTasks() + } + } label: { + Image(systemName: "ellipsis.circle") + } + .accessibilityLabel("추가 옵션") + } + } + .sheet(isPresented: $showingAddTask) { + AddTaskView() + } + } + } + + // MARK: - 서브뷰: 필터 피커 + + private var filterPicker: some View { + Picker("필터", selection: $selectedFilter) { + ForEach(TaskFilter.allCases) { filter in + Text(filter.name) + .tag(filter) + } + } + .pickerStyle(.segmented) + .padding(.horizontal) + .padding(.vertical, 8) + } + + // MARK: - 서브뷰: 카테고리 스크롤 + + private var categoryScroll: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + // "전체" 버튼 + CategoryChip( + name: "전체", + color: .gray, + isSelected: selectedCategory == nil + ) { + selectedCategory = nil + } + + // 각 카테고리 버튼 + ForEach(categories) { category in + CategoryChip( + name: category.name, + color: category.color, + isSelected: selectedCategory?.persistentModelID == category.persistentModelID) + { + if selectedCategory?.persistentModelID == category.persistentModelID { + selectedCategory = nil + } else { + selectedCategory = category + } + } + } + } + .padding(.horizontal) + } + .padding(.bottom, 8) + } + + // MARK: - 서브뷰: 할일 목록 + + private var taskList: some View { + List { + ForEach(filteredTasks) { task in + NavigationLink { + TaskDetailView(task: task) + } label: { + TaskRowView(task: task) + } + .swipeActions(edge: .trailing, allowsFullSwipe: true) { + Button(role: .destructive) { + deleteTask(task) + } label: { + Label("삭제", systemImage: "trash") + } + } + .swipeActions(edge: .leading, allowsFullSwipe: true) { + Button { + task.toggleCompletion() + } label: { + Label( + task.isCompleted ? "미완료" : "완료", + systemImage: task.isCompleted ? "arrow.uturn.backward" : "checkmark" + ) + } + .tint(task.isCompleted ? .orange : .green) + } + } + } + .listStyle(.plain) + } + + // MARK: - 서브뷰: 빈 상태 + + private var emptyStateView: some View { + ContentUnavailableView { + Label(emptyStateTitle, systemImage: emptyStateIcon) + } description: { + Text(emptyStateDescription) + } actions: { + if selectedFilter != .completed { + Button("새 할일 추가") { + showingAddTask = true + } + .buttonStyle(.borderedProminent) + } + } + } + + private var emptyStateTitle: String { + if !searchText.isEmpty { return "검색 결과 없음" } + switch selectedFilter { + case .all: return "할일이 없습니다" + case .pending: return "미완료 할일이 없습니다" + case .completed: return "완료된 할일이 없습니다" + } + } + + private var emptyStateIcon: String { + if !searchText.isEmpty { return "magnifyingglass" } + switch selectedFilter { + case .all: return "checklist" + case .pending: return "checkmark.circle" + case .completed: return "tray" + } + } + + private var emptyStateDescription: String { + if !searchText.isEmpty { return "다른 검색어를 시도해보세요" } + switch selectedFilter { + case .all: return "새 할일을 추가해보세요" + case .pending: return "모든 할일을 완료했습니다! 🎉" + case .completed: return "완료된 할일이 여기에 표시됩니다" + } + } + + // MARK: - 액션 + + private func deleteTask(_ task: TaskItem) { + withAnimation { + modelContext.delete(task) + } + } + + private func deleteCommpltedTasks() { + withAnimation { + DataService.shared.deleteCompletedTasks(from: modelContext) + } + } +} + +// MARK: - 필터 열거형 + +enum TaskFilter: String, CaseIterable, Identifiable { + case all = "전체" + case pending = "미완료" + case completed = "완료" + + var id: String { rawValue } + var name: String { rawValue } +} + +// MARK: - 카테고리 칩 + +struct CategoryChip: View { + let name: String + let color: Color + var count: Int = 0 + var isSelected: Bool + let action: () -> Void + + var body: some View { + Button(action: action) { + HStack(spacing: 4) { + Text(name) + .font(.subheadline) + .fontWeight(isSelected ? .semibold : .regular) + + if count > 0 { + Text("\(count)") + .font(.caption2) + .fontWeight(.bold) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(isSelected ? .white.opacity(0.3) : color.opacity(0.2)) + .clipShape(Capsule()) + } + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(isSelected ? color : color.opacity(0.1)) + .foregroundStyle(isSelected ? .white: color) + .clipShape(Capsule()) + } + .buttonStyle(.plain) + .accessibilityLabel("\(name) 카테고리") + .accessibilityValue(isSelected ? "선택됨" : (count > 0 ? "\(count)개 미완료" : "")) + .accessibilityHint("탭하면 \(name) 카테고리로 필터링합니다") + .accessibilityAddTraits(isSelected ? .isSelected : []) + } +} + +// MARK: - 프리뷰 + +#Preview { + TaskMasterView() + .modelContainer(.preview) +} diff --git a/practice/HIGPractice/HIGPractice/Learning/Phase-01-AppFrameworks/SwiftUI/TaskMaster/App/TaskRowView.swift b/practice/HIGPractice/HIGPractice/Learning/Phase-01-AppFrameworks/SwiftUI/TaskMaster/App/TaskRowView.swift new file mode 100644 index 00000000..20f5610b --- /dev/null +++ b/practice/HIGPractice/HIGPractice/Learning/Phase-01-AppFrameworks/SwiftUI/TaskMaster/App/TaskRowView.swift @@ -0,0 +1,202 @@ +// +// TaskRowView.swift +// HIGPractice +// +// Created by YuSeongChoi on 3/10/26. +// + +import SwiftUI +import SwiftData + +// MARK: - 할일 Row View + +/// 할일 목록의 개별 아이템을 표시하는 뷰 +/// - 완료 체크박스 +/// - 제목 및 우선순위 +/// - 마감일 및 카테고리 표시 +struct TaskRowView: View { + // MARK: - 속성 + + @Bindable var task: TaskItem + + // MARK: - 뷰 본문 + + var body: some View { + HStack(spacing: 12) { + // 완료 토글 버튼 + completionToggle + + // 메인 콘텐츠 + VStack(alignment: .leading, spacing: 4) { + // 제목 행 + HStack(spacing: 6) { + // 우선순위 표시 + if task.priority > 0 { + priorityBadge + } + + // 제목 + Text(task.title) + .font(.body) + .strikethrough(task.isCompleted) + .foregroundStyle(task.isCompleted ? .secondary : .primary) + .lineLimit(2) + } + + // 서브 정보 (마감일, 카테고리) + HStack(spacing: 8) { + // 마감일 + if let dueDate = task.dueDate { + dueDateLabel(for: dueDate) + } + + // 카테고리 + if let category = task.category { + categoryLabel(for: category) + } + } + } + + Spacer() + } + .padding(.vertical, 4) + .contentShape(Rectangle()) + } + + // MARK: - 서브뷰: 완료 토글 + + private var completionToggle: some View { + Button { + withAnimation(.spring(response: 0.3, dampingFraction: 0.6)) { + task.toggleCompletion() + } + } label: { + Image(systemName: task.isCompleted ? "checkmark.circle.fill" : "circle") + .font(.title2) + .foregroundStyle(task.isCompleted ? .green : .gray) + .symbolEffect(.bounce, value: task.isCompleted) + } + .buttonStyle(.plain) + } + + // MARK: - 서브뷰: 우선순위 뱃지 + + private var priorityBadge: some View { + let priority = task.taskPriority + + return Image(systemName: priority.symbol) + .font(.caption) + .fontWeight(.bold) + .foregroundStyle(priorityColor(for: priority)) + } + + private func priorityColor(for priority: TaskPriority) -> Color { + switch priority { + case .none: .gray + case .low: .blue + case .medium: .orange + case .high: .red + } + } + + // MARK: - 서브뷰: 마감일 라벨 + + private func dueDateLabel(for date: Date) -> some View { + HStack(spacing: 2) { + Image(systemName: "calendar") + .font(.caption2) + + Text(formattedDueDate(date)) + .font(.caption) + } + .foregroundStyle(dueDateColor) + } + + private var dueDateColor: Color { + if task.isOverdue { return .red } + if task.isDueSoon { return .orange } + return .secondary + } + + private func formattedDueDate(_ date: Date) -> String { + let calendar = Calendar.current + + if calendar.isDateInToday(date) { + return "오늘" + } else if calendar.isDateInTomorrow(date) { + return "내일" + } else if calendar.isDateInYesterday(date) { + return "어제" + } else { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "ko_KR") + + // 올해인지 확인 + if calendar.component(.year, from: date) == calendar.component(.year, from: Date()) { + formatter.dateFormat = "M월 d일" + } else { + formatter.dateFormat = "yyyy.M.d" + } + + return formatter.string(from: date) + } + } + + // MARK: - 서브뷰: 카테고리 라벨 + + private func categoryLabel(for category: Category) -> some View { + HStack(spacing: 2) { + Image(systemName: category.iconName) + .font(.caption2) + + Text(category.name) + .font(.caption2) + } + .foregroundStyle(category.color) + } +} + +// MARK: - 프리뷰 + +#Preview("기본") { + let container = ModelContainer.preview + + return List { + TaskRowView(task: TaskItem( + title: "SwiftData 문서 읽기", + dueDate: Date(), + priority: 3 + )) + + TaskRowView(task: TaskItem( + title: "장보기 - 우유, 계란, 빵, 과일", + dueDate: Calendar.current.date(byAdding: .day, value: 1, to: Date()), + priority: 2 + )) + + TaskRowView(task: TaskItem( + title: "완료된 할일", + isCompleted: true, + priority: 0 + )) + + TaskRowView(task: TaskItem( + title: "마감 지난 할일", + dueDate: Calendar.current.date(byAdding: .day, value: -2, to: Date()), + priority: 1 + )) + } + .modelContainer(container) +} + +#Preview("다크 모드") { + List { + TaskRowView(task: TaskItem( + title: "다크 모드 테스트", + dueDate: Date(), + priority: 3 + )) + } + .preferredColorScheme(.dark) + .modelContainer(.preview) +} diff --git a/practice/HIGPractice/HIGPractice/Learning/Phase-01-AppFrameworks/SwiftUI/TaskMaster/Shared/Category.swift b/practice/HIGPractice/HIGPractice/Learning/Phase-01-AppFrameworks/SwiftUI/TaskMaster/Shared/Category.swift new file mode 100644 index 00000000..a12e937b --- /dev/null +++ b/practice/HIGPractice/HIGPractice/Learning/Phase-01-AppFrameworks/SwiftUI/TaskMaster/Shared/Category.swift @@ -0,0 +1,191 @@ +// +// Category.swift +// HIGPractice +// +// Created by YuSeongChoi on 3/10/26. +// + +import Foundation +import SwiftData +import SwiftUI + +// MARK: - 카테고리 모델 + +/// 할일 카테고리 데이터 모델 +/// - 이름, 색상, 아이콘 및 소속된 할일들을 관리 +@Model +final class Category { + // MARK: - 속성 + + /// 카테고리 이름 + var name: String + + var colorHex: String + + var iconName: String + + var createdAt: Date + + var order: Int + + @Relationship(deleteRule: .nullify) + var tasks: [TaskItem] = [] + + init( + name: String, + colorHex: String = "#007AFF", + iconName: String = "folder.fill", + order: Int = 0 + ) { + self.name = name + self.colorHex = colorHex + self.iconName = iconName + self.createdAt = Date() + self.order = order + } +} + +// MARK: - 색상 변환 + +extension Category { + /// SwiftUI Color 접근 + var color: Color { + Color(hex: colorHex) ?? .blue + } + + /// 색상 설정 + func setColor(_ color: Color) { + self.colorHex = color.toHex() ?? "#007AFF" + } +} + +// MARK: - 통계 +extension Category { + /// 완료되지 않은 할일 수 + var pendingTaskCount: Int { + tasks.filter { !$0.isCompleted }.count + } + + /// 완료된 할일 수 + var completedTaskCount: Int { + tasks.filter { $0.isCompleted }.count + } + + /// 완료율 (0.0 ~ 1.0) + var completionRate: Double { + guard !tasks.isEmpty else { return 0 } + return Double(completedTaskCount) / Double(tasks.count) + } +} + +// MARK: - 기본 카테고리 + +extension Category { + /// 기본 카테고리 생성 + static func createDefaults() -> [Category] { + [ + Category(name: "개인", colorHex: "#007AFF", iconName: "person.fill", order: 0), + Category(name: "업무", colorHex: "#FF9500", iconName: "briefcase.fill", order: 1), + Category(name: "쇼핑", colorHex: "#34C759", iconName: "cart.fill", order: 2), + Category(name: "건강", colorHex: "#FF2D55", iconName: "heart.fill", order: 3), + ] + } +} + +// MARK: - Color 확장 (Hex 변환) + +extension Color { + /// Hex 문자열로 Color 생성 + init?(hex: String) { + var hexSanitized = hex.trimmingCharacters(in: .whitespacesAndNewlines) + hexSanitized = hexSanitized.replacingOccurrences(of: "#", with: "") + + guard hexSanitized.count == 6 else { return nil } + + var rgb: UInt64 = 0 + Scanner(string: hexSanitized).scanHexInt64(&rgb) + + let red = Double((rgb & 0xFF0000) >> 16) / 255.0 + let green = Double((rgb & 0x00FF00) >> 8) / 255.0 + let blue = Double(rgb & 0x0000FF) / 255.0 + + self.init(red: red, green: green, blue: blue) + } + + /// Color를 Hex 문자열로 변환 + func toHex() -> String? { + guard let components = UIColor(self).cgColor.components else { return nil } + + let r = Int(components[0] * 255) + let g = Int(components[1] * 255) + let b = Int(components[2] * 255) + + return String(format: "#%02X%02X%02X", r, g, b) + } +} + +// MARK: - 사용 가능한 색상 + +/// 카테고리에 사용 가능한 색상 목록 +enum CategoryColor: String, CaseIterable { + case blue = "#007AFF" + case green = "#34C759" + case orange = "#FF9500" + case red = "#FF2D55" + case purple = "#AF52DE" + case teal = "#5AC8FA" + case indigo = "#5856D6" + case pink = "#FF6B6B" + + var color: Color { + Color(hex: rawValue) ?? .blue + } + + var name: String { + switch self { + case .blue: "파랑" + case .green: "초록" + case .orange: "주황" + case .red: "빨강" + case .purple: "보라" + case .teal: "청록" + case .indigo: "남색" + case .pink: "분홍" + } + } +} + +// MARK: - 사용 가능한 아이콘 + +/// 카테고리에 사용 가능한 아이콘 목록 +enum CategoryIcon: String, CaseIterable { + case folder = "folder.fill" + case person = "person.fill" + case briefcase = "briefcase.fill" + case cart = "cart.fill" + case heart = "heart.fill" + case star = "star.fill" + case house = "house.fill" + case book = "book.fill" + case gamecontroller = "gamecontroller.fill" + case airplane = "airplane" + case car = "car.fill" + case gift = "gift.fill" + + var name: String { + switch self { + case .folder: "폴더" + case .person: "개인" + case .briefcase: "업무" + case .cart: "쇼핑" + case .heart: "건강" + case .star: "중요" + case .house: "집" + case .book: "학습" + case .gamecontroller: "취미" + case .airplane: "여행" + case .car: "이동" + case .gift: "선물" + } + } +} diff --git a/practice/HIGPractice/HIGPractice/Learning/Phase-01-AppFrameworks/SwiftUI/TaskMaster/Shared/DataService.swift b/practice/HIGPractice/HIGPractice/Learning/Phase-01-AppFrameworks/SwiftUI/TaskMaster/Shared/DataService.swift new file mode 100644 index 00000000..c61a4f90 --- /dev/null +++ b/practice/HIGPractice/HIGPractice/Learning/Phase-01-AppFrameworks/SwiftUI/TaskMaster/Shared/DataService.swift @@ -0,0 +1,239 @@ +// +// DataService.swift +// HIGPractice +// +// Created by YuSeongChoi on 3/10/26. +// + +import Foundation +import SwiftData + +// MARK: - 데이터 서비스 + +/// SwiftData CRUD 작업을 위한 헬퍼 서비스 +/// - ModelContext를 사용하여 데이터 생성, 조회, 수정, 삭제를 수행 +@MainActor +final class DataService { + + // MARK: - 싱글톤 (선택적 사용) + + static let shared = DataService() + private init() {} + + // MARK: - TaskItem CRUD + + /// 새 할일 생성 + func createTask( + in context: ModelContext, + title: String, + dueDate: Date? = nil, + priority: TaskPriority = .none, + notes: String = "", + category: Category? = nil + ) -> TaskItem { + let task = TaskItem( + title: title, + dueDate: dueDate, + priority: priority.rawValue, + notes: notes, + category: category + ) + context.insert(task) + return task + } + + /// 모든 할일 조회 + func fetchAllTasks(from context: ModelContext) -> [TaskItem] { + let descriptor = FetchDescriptor( + sortBy: [SortDescriptor(\.createdAt, order: .reverse)] + ) + return (try? context.fetch(descriptor)) ?? [] + } + + /// 미완료 할일만 조회 + func fetchPendingTasks(from context: ModelContext) -> [TaskItem] { + let predicate = #Predicate { !$0.isCompleted } + let descriptor = FetchDescriptor( + predicate: predicate, + sortBy: [SortDescriptor(\.completedAt, order: .reverse)] + ) + return (try? context.fetch(descriptor)) ?? [] + } + + /// 완료된 할일만 조회 + func fetchCompletedTasks(from context: ModelContext) -> [TaskItem] { + let predicate = #Predicate { $0.isCompleted } + let descriptor = FetchDescriptor( + predicate: predicate, + sortBy: [SortDescriptor(\.completedAt, order: .reverse)] + ) + return (try? context.fetch(descriptor)) ?? [] + } + + /// 오늘 마감인 할일 조회 + func fetchTodayTasks(from context: ModelContext) -> [TaskItem] { + let calendar = Calendar.current + let startOfDay = calendar.startOfDay(for: Date()) + let endOfDay = calendar.date(byAdding: .day, value: 1, to: startOfDay)! + + let predicate = #Predicate { task in + task.dueDate! >= startOfDay && + task.dueDate! < endOfDay + } + + let descriptor = FetchDescriptor( + predicate: predicate, + sortBy: [SortDescriptor(\.priority, order: .reverse)] + ) + + return (try? context.fetch(descriptor)) ?? [] + } + + /// 할일 삭제 + func deleteTask(_ task: TaskItem, from context: ModelContext) { + context.delete(task) + } + + /// 여러 할일 삭제 + func deleteTasks(_ tasks: [TaskItem], from context: ModelContext) { + tasks.forEach { context.delete($0) } + } + + func deleteCompletedTasks(from context: ModelContext) { + let completed = fetchCompletedTasks(from: context) + deleteTasks(completed, from: context) + } + + // MARK: - Category CRUD + + /// 새 카테고리 생성 + func createCategory( + in context: ModelContext, + name: String, + colorHex: String = "#007AFF", + iconName: String = "folder.fill" + ) -> Category { + let categories = fetchAllCategories(from: context) + let order = categories.count + + let category = Category( + name: name, + colorHex: colorHex, + iconName: iconName, + order: order + ) + context.insert(category) + return category + } + + /// 모든 카테고리 조회 + func fetchAllCategories(from context: ModelContext) -> [Category] { + let descriptor = FetchDescriptor( + sortBy: [SortDescriptor(\.order)] + ) + return (try? context.fetch(descriptor)) ?? [] + } + + /// 카테고리 삭제 (소속 할일의 카테고리는 nil로 설정됨) + func deleteCategory(_ category: Category, from context: ModelContext) { + context.delete(category) + } + + /// 기본 카테고리 초기화 (첫 실행 시) + func initializeDefaultCategories(in context: ModelContext) { + let existing = fetchAllCategories(from: context) + guard existing.isEmpty else { return } + + let defaults = Category.createDefaults() + defaults.forEach { context.insert($0) } + } + + // MARK: - 통계 + + /// 전체 통계 조회 + func fetchStatistics(from context: ModelContext) -> TaskStatistics { + let all = fetchAllTasks(from: context) + let pending = all.filter { !$0.isCompleted } + let completed = all.filter { $0.isCompleted } + let overdue = pending.filter { $0.isOverdue } + let dueSoon = pending.filter { $0.isDueSoon } + + return TaskStatistics( + totalCount: all.count, + pendingCount: pending.count, + completedCount: completed.count, + overdueCount: overdue.count, + dueSoonCount: dueSoon.count + ) + } + +} + +// MARK: - 통계 데이터 구조 + +/// 할일 통계 정보 +struct TaskStatistics { + let totalCount: Int + let pendingCount: Int + let completedCount: Int + let overdueCount: Int + let dueSoonCount: Int + + /// 완료율 (0.0 ~ 1.0) + var completionRate: Double { + guard totalCount > 0 else { return 0 } + return Double(completedCount) / Double(totalCount) + } + + var completionPercentage: String { + String(format: "%.0f%%", completionRate * 100) + } +} + +// MARK: - Preview / 샘플 데이터 + +extension DataService { + /// 샘플 데이터 생성 (프리뷰/테스트용) + func createSampleData(in context: ModelContext) { + // 카테고리 생성 + let personal = createCategory(in: context, name: "개인", colorHex: "#007AFF", iconName: "person.fill") + let work = createCategory(in: context, name: "업무", colorHex: "#FF9500", iconName: "briefcase.fill") + let shopping = createCategory(in: context, name: "쇼핑", colorHex: "#34C759", iconName: "cart.fill") + + // 할일 생성 + let _ = createTask( + in: context, + title: "SwiftData 문서 읽기", + dueDate: Calendar.current.date(byAdding: .day, value: 1, to: Date()), + priority: .high, + notes: "WWDC23 세션 영상도 함께 보기", + category: work + ) + + let _ = createTask( + in: context, + title: "장보기", + dueDate: Date(), + priority: .medium, + notes: "우유, 계란, 빵", + category: shopping + ) + + let _ = createTask( + in: context, + title: "운동하기", + dueDate: nil, + priority: .low, + category: personal + ) + + let task = createTask( + in: context, + title: "완료된 할일 예시", + priority: .none, + category: personal + ) + task.toggleCompletion() + } +} + diff --git a/practice/HIGPractice/HIGPractice/Learning/Phase-01-AppFrameworks/SwiftUI/TaskMaster/Shared/TaskItem.swift b/practice/HIGPractice/HIGPractice/Learning/Phase-01-AppFrameworks/SwiftUI/TaskMaster/Shared/TaskItem.swift new file mode 100644 index 00000000..7b798c9b --- /dev/null +++ b/practice/HIGPractice/HIGPractice/Learning/Phase-01-AppFrameworks/SwiftUI/TaskMaster/Shared/TaskItem.swift @@ -0,0 +1,153 @@ +// +// TaskItem.swift +// HIGPractice +// +// Created by YuSeongChoi on 3/10/26. +// + +import Foundation +import SwiftData + +// MARK: - 할일 아이템 모델 + +/// SwiftData를 사용한 할일 데이터 모델 +/// - 제목, 오나료 상태, 마감일, 우선순위, 카테고리 등을 관리 +@Model +final class TaskItem { + // MARK: - 속성 + + /// 할일 제목 + var title: String + + /// 완료 여부 + var isCompleted: Bool + + /// 마감일 (선택) + var dueDate: Date? + + /// 우선순위 (0: 없음, 1: 낮음, 2: 중간, 3: 높음) + var priority: Int + + /// 메모 / 상세 내용 + var notes: String + + /// 생성 일시 + var createdAt: Date + + /// 완료 일시 + var completedAt: Date? + + /// 소속 카테고리 (역관계) + @Relationship(inverse: \Category.tasks) + var category: Category? + + // MARK: - 초기화 + + init( + title: String, + isCompleted: Bool = false, + dueDate: Date? = nil, + priority: Int = 0, + notes: String = "", + category: Category? = nil + ) { + self.title = title + self.isCompleted = isCompleted + self.dueDate = dueDate + self.priority = priority + self.notes = notes + self.category = category + self.createdAt = Date() + self.completedAt = nil + } +} + +// MARK: - 우선순위 열거형 + +enum TaskPriority: Int, CaseIterable, Identifiable { + case none = 0 + case low = 1 + case medium = 2 + case high = 3 + + var id: Int { rawValue } + + /// 표시 이름 + var name: String { + switch self { + case .none: "없음" + case .low: "낮음" + case .medium: "중간" + case .high: "높음" + } + } + + /// SF Symbol 아이콘 + var symbol: String { + switch self { + case .none: "minus" + case .low: "arrow.down" + case .medium: "equal" + case .high: "arrow.up" + } + } + + /// 색상 + var color: String { + switch self { + case .none: "gray" + case .low: "blue" + case .medium: "orange" + case .high: "red" + } + } +} + +// MARK: - 편의 확장 + +extension TaskItem { + /// 우선순위 열거형 접근 + var taskPriority: TaskPriority { + get { TaskPriority(rawValue: priority) ?? .none } + set { priority = newValue.rawValue } + } + + /// 마감일까지 남은 일수 + var daysUntilDue: Int? { + guard let dueDate else { return nil } + let calendar = Calendar.current + let today = calendar.startOfDay(for: Date()) + let due = calendar.startOfDay(for: dueDate) + return calendar.dateComponents([.day], from: today, to: due).day + } + + /// 마감 임박 여부 (3일 이내) + var isDueSoon: Bool { + guard let days = daysUntilDue else { return false } + return days >= 0 && days <= 3 + } + + /// 마감 지남 여부 + var isOverdue: Bool { + guard let days = daysUntilDue else { return false } + return days < 0 && !isCompleted + } + + /// 완료 토글 + func toggleCompletion() { + isCompleted.toggle() + completedAt = isCompleted ? Date() : nil + } +} + +// MARK: - 정렬 + +extension TaskItem { + /// 정렬 기준 + enum SortOption: String, CaseIterable { + case createdAt = "생성일" + case dueDate = "마감일" + case priority = "우선순위" + case title = "제목" + } +}