diff --git a/practice/HIGPractice/HIGPractice.xcodeproj/project.pbxproj b/practice/HIGPractice/HIGPractice.xcodeproj/project.pbxproj index fdc1ecdb..42919251 100644 --- a/practice/HIGPractice/HIGPractice.xcodeproj/project.pbxproj +++ b/practice/HIGPractice/HIGPractice.xcodeproj/project.pbxproj @@ -60,6 +60,26 @@ /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + 4D26A0013B16AA1100B7A001 /* Exceptions for "HIGPractice" folder in "HIGPractice" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Learning/LEARNING_LOG.md, + "Learning/Phase-01-AppFrameworks/ActivityKit/ActivityKit.md", + "Learning/Phase-01-AppFrameworks/AppIntents/AppIntents.md", + "Learning/Phase-01-AppFrameworks/Observation/ISSUE_DRAFT.md", + "Learning/Phase-01-AppFrameworks/Observation/Observation.md", + "Learning/Phase-01-AppFrameworks/Phase-01-AppFrameworks.md", + "Learning/Phase-01-AppFrameworks/SwiftData/ISSUE_DRAFT.md", + "Learning/Phase-01-AppFrameworks/SwiftData/SwiftData.md", + "Learning/Phase-01-AppFrameworks/SwiftUI/SwiftUI.md", + "Learning/Phase-01-AppFrameworks/WidgetKit/WidgetKit.md", + "Learning/Phase-02-AppServices/Phase-02-AppServices.md", + "Learning/Phase-03-GraphicsMedia/Phase-03-GraphicsMedia.md", + "Learning/Phase-04-SystemNetwork/Phase-04-SystemNetwork.md", + "Learning/Phase-05-iOS26/Phase-05-iOS26.md", + ); + target = 5DCF8F3A2F4BE4D5008EA555 /* HIGPractice */; + }; 5DCF91002F4D8400008EA555 /* Exceptions for "HIGPracticeWidget" folder in "HIGPracticeWidgetExtension" target */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( @@ -72,6 +92,9 @@ /* Begin PBXFileSystemSynchronizedRootGroup section */ 5DCF8F3D2F4BE4D5008EA555 /* HIGPractice */ = { isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + 4D26A0013B16AA1100B7A001 /* Exceptions for "HIGPractice" folder in "HIGPractice" target */, + ); path = HIGPractice; sourceTree = ""; }; diff --git a/practice/HIGPractice/HIGPractice/Data/PracticeCatalog.swift b/practice/HIGPractice/HIGPractice/Data/PracticeCatalog.swift index bd3aa0f6..544fb2b9 100644 --- a/practice/HIGPractice/HIGPractice/Data/PracticeCatalog.swift +++ b/practice/HIGPractice/HIGPractice/Data/PracticeCatalog.swift @@ -12,7 +12,7 @@ enum PracticeCatalog { makeItem(id: "appintents", name: "App Intents", description: "Siri/Shortcuts 액션 노출", phase: .appFrameworks, symbolName: "waveform.badge.mic", tint: .pink, completed: false, blogSlug: "appintents", docsSlug: "appintents", tutorialPath: "tutorials/appintents", samplePath: "samples/SiriTodo", aiDoc: "appintents.md"), makeItem(id: "swiftui", name: "SwiftUI", description: "선언형 UI 설계와 데이터 흐름", phase: .appFrameworks, symbolName: "rectangle.3.group.bubble.left.fill", tint: .indigo, completed: true, blogSlug: "swiftui", docsSlug: "swiftui", tutorialPath: "tutorials/swiftui", samplePath: "samples/TaskMaster", aiDoc: "swiftui.md"), makeItem(id: "swiftdata", name: "SwiftData", description: "@Model 기반 로컬 데이터 영속화", phase: .appFrameworks, symbolName: "externaldrive.fill.badge.icloud", tint: .teal, completed: false, blogSlug: "swiftdata", docsSlug: "swiftdata", tutorialPath: "tutorials/swiftdata", samplePath: "samples/TaskMaster", aiDoc: "swiftdata.md"), - makeItem(id: "observation", name: "Observation", description: "@Observable 상태 관리", phase: .appFrameworks, symbolName: "eye.fill", tint: .mint, completed: false, blogSlug: "observation", docsSlug: "observation", tutorialPath: "tutorials/observation", samplePath: "samples/TaskMaster", aiDoc: "swiftui-observation.md"), + makeItem(id: "observation", name: "Observation", description: "@Observable 상태 관리", phase: .appFrameworks, symbolName: "eye.fill", tint: .mint, completed: true, blogSlug: "observation", docsSlug: "observation", tutorialPath: "tutorials/observation", samplePath: "samples/CartFlow", aiDoc: "swiftui-observation.md"), makeItem(id: "foundationmodels", name: "Foundation Models", description: "온디바이스 LLM 앱 설계", phase: .appFrameworks, symbolName: "brain.head.profile", tint: .cyan, completed: false, blogSlug: "foundationmodels", docsSlug: "foundationmodels", tutorialPath: "tutorials/foundationmodels", samplePath: "samples/AIChatbot", aiDoc: "foundation-models.md"), makeItem(id: "tipkit", name: "TipKit", description: "맥락형 힌트/온보딩 UX", phase: .appFrameworks, symbolName: "lightbulb.max.fill", tint: .teal, completed: false, blogSlug: "tipkit", docsSlug: "tipkit", tutorialPath: "tutorials/tipkit", samplePath: "samples/TipShowcase", aiDoc: "tipkit.md"), diff --git a/practice/HIGPractice/HIGPractice/Features/Home/Views/PracticeHomeView.swift b/practice/HIGPractice/HIGPractice/Features/Home/Views/PracticeHomeView.swift index 77e16ca6..885c42bf 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 == "observation" { + CartRootView() } else if item.id == "swiftui" { TaskMasterRootView() } else { diff --git a/practice/HIGPractice/HIGPractice/Learning/LEARNING_LOG.md b/practice/HIGPractice/HIGPractice/Learning/LEARNING_LOG.md index e1b93590..ce19a16a 100644 --- a/practice/HIGPractice/HIGPractice/Learning/LEARNING_LOG.md +++ b/practice/HIGPractice/HIGPractice/Learning/LEARNING_LOG.md @@ -25,6 +25,7 @@ For each completed scope, append one row: | 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. | +| 2026-03-17 | Phase 1 | Observation | CartFlow Views 구현 및 Apple Pay 결제 흐름 UI 정리 | #26 | #27 | - | Connected `@Observable` store reads to actual SwiftUI screens, and clarified where `@Environment`, `@Bindable`, and local `@State` each own or project state. | ## Weekly Reflection (Optional) diff --git a/practice/HIGPractice/HIGPractice/Learning/Phase-01-AppFrameworks/Observation/CartFlow/Services/ApplePayService.swift b/practice/HIGPractice/HIGPractice/Learning/Phase-01-AppFrameworks/Observation/CartFlow/Services/ApplePayService.swift new file mode 100644 index 00000000..d05f5641 --- /dev/null +++ b/practice/HIGPractice/HIGPractice/Learning/Phase-01-AppFrameworks/Observation/CartFlow/Services/ApplePayService.swift @@ -0,0 +1,781 @@ +// +// ApplePayService.swift +// HIGPractice +// +// Created by YuSeongChoi on 3/16/26. +// + +import Foundation +internal import PassKit +import Observation +#if os(iOS) +import UIKit +#endif + +// MARK: - Apple Pay 서비스 +/// Apple Pay 결제를 처리하는 핵심 서비스 클래스 +/// +/// ## 개요 +/// `ApplePayService`는 PassKit 프레임워크를 활용하여 +/// Apple Pay 결제 프로세스 전체를 관리합니다. +/// +/// ## 주요 기능 +/// - 결제 가능 여부 확인 +/// - 결제 요청 생성 및 표시 +/// - 배송 정보 업데이트 처리 +/// - 결제 승인 및 완료 처리 +/// +/// ## 사용 예시 +/// ```swift +/// let service = ApplePayService() +/// +/// // 결제 가능 여부 확인 +/// guard service.canMakePayments else { return } +/// +/// // 결제 실행 +/// do { +/// let result = try await service.processPayment( +/// for: cartItems, +/// shippingMethod: .express +/// ) +/// print("결제 성공: \(result.transactionId)") +/// } catch let error as PaymentError { +/// print("결제 실패: \(error.localizedDescription)") +/// } +/// ``` +/// +/// ## 아키텍처 +/// ``` +/// ┌─────────────────────────────────────────────────┐ +/// │ ApplePayService │ +/// ├─────────────────────────────────────────────────┤ +/// │ ┌──────────────┐ ┌───────────────────────┐ │ +/// │ │ Configuration│ │ PKPaymentAuthorization│ │ +/// │ │ │───▶│ Controller │ │ +/// │ └──────────────┘ └───────────────────────┘ │ +/// │ │ │ │ +/// │ ▼ ▼ │ +/// │ ┌──────────────┐ ┌───────────────────────┐ │ +/// │ │ PaymentRequest│ │ Delegate │ │ +/// │ │ Builder │ │ Handler │ │ +/// │ └──────────────┘ └───────────────────────┘ │ +/// │ │ │ │ +/// │ └───────────┬───────────┘ │ +/// │ ▼ │ +/// │ ┌─────────────────┐ │ +/// │ │ PaymentResult │ │ +/// │ └─────────────────┘ │ +/// └─────────────────────────────────────────────────┘ +/// ``` + +@Observable +@MainActor +final class ApplePayService: NSObject { + + // MARK: - 상태 + + /// 현재 결제 진행 상태 + private(set) var paymentState: PaymentState = .idle + + /// 선택된 배송 방법 + private(set) var selectedShippingMethod: ShippingMethod? + + /// 선택된 배송 연락처 + private(set) var selectedShippingContact: PKContact? + + /// 마지막 오류 + private(set) var lastError: PaymentError? + + // MARK: - 의존성 + + /// 결제 설정 + let configuration: PaymentConfiguration + + // MARK: - 내부 상태 + + /// 결제 완료 continuation (async/await 지원) + private var paymentContinuation: CheckedContinuation? + + /// 현재 결제 항목들 + private var currentCartItems: [CartItem] = [] + + /// 현재 적용된 쿠폰 코드 + private var appliedCouponCode: String? + + /// 쿠폰 할인 금액 + private var couponDiscount: Int = 0 + + // MARK: - 초기화 + + /// 기본 설정으로 초기화 + init(configuration: PaymentConfiguration = .default) { + self.configuration = configuration + super.init() + } + + // MARK: - 결제 가능 여부 + + /// Apple Pay 사용 가능 여부 + var canMakePayments: Bool { + configuration.canMakePayments + } + + /// 등록된 카드로 결제 가능 여부 + var canMakePaymentsWithRegisteredCards: Bool { + configuration.canMakePaymentsWithRegisteredCards + } + + /// 결제 가능 상태 + var paymentAvailability: PaymentConfiguration.PaymentAvailability { + configuration.paymentAvailability + } + + /// Apple Pay 설정이 필요한지 여부 + var needsSetup: Bool { + configuration.needsSetup + } + + // MARK: - 결제 처리 + + /// 장바구니 아이템으로 결제 처리 + /// - Parameters: + /// - items: 장바구니 아이템 배열 + /// - shippingMethod: 선택된 배송 방법 (기본: 일반 배송) + /// - Returns: 결제 결과 + /// - Throws: PaymentError + func processPayment( + for items: [CartItem], + shippingMethod: ShippingMethod = .standardPaid + ) async throws -> PaymentResult { + // 상태 검증 + guard paymentState == .idle else { + throw PaymentError.duplicatePayment + } + + guard !items.isEmpty else { + throw PaymentError.emptyPaymentItems + } + + // 결제 가능 여부 확인 + switch paymentAvailability { + case .notSupported: + throw PaymentError.applePayNotSupported + case .needsSetup: + throw PaymentError.applePaySetupRequired + case .available: + break + } + + // 상태 업데이트 + paymentState = .preparing + currentCartItems = items + selectedShippingMethod = shippingMethod + lastError = nil + + // 결제 요청 생성 + let paymentRequest = createPaymentRequest( + for: items, + shippingMethod: shippingMethod + ) + + // 결제 컨트롤러 생성 및 표시 + return try await withCheckedThrowingContinuation { continuation in + self.paymentContinuation = continuation + + let controller = PKPaymentAuthorizationController(paymentRequest: paymentRequest) + controller.delegate = self + + paymentState = .authorizing + + controller.present { [weak self] presented in + if !presented { + self?.paymentState = .failed + self?.lastError = .authorizationFailed(underlyingError: nil) + continuation.resume(throwing: PaymentError.authorizationFailed(underlyingError: nil)) + self?.paymentContinuation = nil + } + } + } + } + + /// 금액으로 직접 결제 처리 + /// - Parameter amount: 결제 금액 + /// - Returns: 결제 결과 + func processPayment(amount: Int) async throws -> PaymentResult { + guard paymentState == .idle else { + throw PaymentError.duplicatePayment + } + + guard amount > 0 else { + throw PaymentError.invalidAmount(Decimal(amount)) + } + + paymentState = .preparing + lastError = nil + + let paymentRequest = configuration.createPaymentRequest(for: amount) + + return try await withCheckedThrowingContinuation { continuation in + self.paymentContinuation = continuation + + let controller = PKPaymentAuthorizationController(paymentRequest: paymentRequest) + controller.delegate = self + + paymentState = .authorizing + + controller.present { [weak self] presented in + if !presented { + self?.paymentState = .failed + self?.lastError = .authorizationFailed(underlyingError: nil) + continuation.resume(throwing: PaymentError.authorizationFailed(underlyingError: nil)) + self?.paymentContinuation = nil + } + } + } + } + + /// 결제 상태 초기화 + func resetPayment() { + paymentState = .idle + selectedShippingMethod = nil + selectedShippingContact = nil + currentCartItems = [] + appliedCouponCode = nil + couponDiscount = 0 + lastError = nil + paymentContinuation = nil + } + + // MARK: - 결제 요청 생성 + + /// 장바구니 아이템으로 결제 요청 생성 + private func createPaymentRequest( + for items: [CartItem], + shippingMethod: ShippingMethod + ) -> PKPaymentRequest { + var summaryItems: [PKPaymentSummaryItem] = [] + + // 개별 상품 항목 + for item in items { + let label = item.quantity > 1 + ? "\(item.product.name) x \(item.quantity)" + : item.product.name + let amount = NSDecimalNumber(value: item.totalPrice) + summaryItems.append( + PKPaymentSummaryItem(label: label, amount: amount, type: .final) + ) + } + + // 쿠폰 할인 (있는 경우) + if couponDiscount > 0 { + summaryItems.append( + PKPaymentSummaryItem( + label: "쿠폰 할인", + amount: NSDecimalNumber(value: -couponDiscount), + type: .final + ) + ) + } + + // 배송비 + let subtotal = items.reduce(0) { $0 + $1.totalPrice } + let shippingCost = shippingMethod.calculatePrice(for: subtotal) + if shippingCost > 0 { + summaryItems.append( + PKPaymentSummaryItem( + label: shippingMethod.name, + amount: NSDecimalNumber(value: shippingCost), + type: .final + ) + ) + } + + // 총액 + let total = subtotal + shippingCost - couponDiscount + summaryItems.append( + PKPaymentSummaryItem( + label: configuration.merchantDisplayName, + amount: NSDecimalNumber(value: max(0, total)), + type: .final + ) + ) + + // 배송 옵션 + let shippingMethods = ShippingMethod.toPKShippingMethods( + ShippingMethod.defaultMethods, + for: subtotal + ) + + return configuration.createPaymentRequest( + items: summaryItems, + shippingMethods: shippingMethods + ) + } + + /// 결제 항목 업데이트 + private func updatedPaymentSummaryItems( + for shippingMethod: PKShippingMethod? + ) -> [PKPaymentSummaryItem] { + var summaryItems: [PKPaymentSummaryItem] = [] + + // 상품 항목 + for item in currentCartItems { + let label = item.quantity > 1 + ? "\(item.product.name) x \(item.quantity)" + : item.product.name + summaryItems.append( + PKPaymentSummaryItem( + label: label, + amount: NSDecimalNumber(value: item.totalPrice), + type: .final + ) + ) + } + + // 쿠폰 할인 + if couponDiscount > 0 { + summaryItems.append( + PKPaymentSummaryItem( + label: "쿠폰 할인", + amount: NSDecimalNumber(value: -couponDiscount), + type: .final + ) + ) + } + + // 배송비 + let subtotal = currentCartItems.reduce(0) { $0 + $1.totalPrice } + let shippingCost = shippingMethod?.amount.intValue ?? 0 + + if shippingCost > 0 { + summaryItems.append( + PKPaymentSummaryItem( + label: shippingMethod?.label ?? "배송", + amount: shippingMethod?.amount ?? NSDecimalNumber.zero, + type: .final + ) + ) + } + + // 총액 + let total = subtotal + shippingCost - couponDiscount + summaryItems.append( + PKPaymentSummaryItem( + label: configuration.merchantDisplayName, + amount: NSDecimalNumber(value: max(0, total)), + type: .final + ) + ) + + return summaryItems + } +} + +// MARK: - PKPaymentAuthorizationControllerDelegate + +extension ApplePayService: PKPaymentAuthorizationControllerDelegate { + + /// 결제 승인 완료 + nonisolated func paymentAuthorizationController( + _ controller: PKPaymentAuthorizationController, + didAuthorizePayment payment: PKPayment, + handler completion: @escaping (PKPaymentAuthorizationResult) -> Void + ) { + Task { @MainActor in + paymentState = .processing + + do { + // 실제 결제 처리 (서버 통신) + let result = try await processPaymentWithServer(payment: payment) + + paymentState = .completed + completion(PKPaymentAuthorizationResult(status: .success, errors: nil)) + + // 성공 결과 반환 + paymentContinuation?.resume(returning: result) + paymentContinuation = nil + + } catch let error as PaymentError { + paymentState = .failed + lastError = error + + // 에러 정보와 함께 실패 반환 + let pkErrors = error.toPKPaymentErrors() + completion(PKPaymentAuthorizationResult(status: .failure, errors: pkErrors)) + + paymentContinuation?.resume(throwing: error) + paymentContinuation = nil + + } catch { + paymentState = .failed + let paymentError = PaymentError.unknown(underlyingError: error) + lastError = paymentError + + completion(PKPaymentAuthorizationResult(status: .failure, errors: nil)) + + paymentContinuation?.resume(throwing: paymentError) + paymentContinuation = nil + } + } + } + + /// 결제 완료/취소 + nonisolated func paymentAuthorizationControllerDidFinish( + _ controller: PKPaymentAuthorizationController + ) { + Task { @MainActor in + controller.dismiss() + + // 사용자가 취소한 경우 + if paymentState == .authorizing { + paymentState = .cancelled + lastError = .userCancelled + + paymentContinuation?.resume(throwing: PaymentError.userCancelled) + paymentContinuation = nil + } + } + } + + /// 배송 연락처 선택/변경 + nonisolated func paymentAuthorizationController( + _ controller: PKPaymentAuthorizationController, + didSelectShippingContact contact: PKContact, + handler completion: @escaping (PKPaymentRequestShippingContactUpdate) -> Void + ) { + Task { @MainActor in + selectedShippingContact = contact + + // 주소 검증 + guard let postalCode = contact.postalAddress?.postalCode else { + let error = PaymentError.invalidShippingAddress(reason: "우편번호를 확인할 수 없습니다.") + completion(PKPaymentRequestShippingContactUpdate( + errors: error.toPKPaymentErrors(), + paymentSummaryItems: updatedPaymentSummaryItems(for: nil), + shippingMethods: [] + )) + return + } + + // 해당 지역에서 사용 가능한 배송 방법 필터링 + let availableMethods = ShippingMethod.availableMethods( + from: ShippingMethod.defaultMethods, + for: postalCode + ) + + let subtotal = currentCartItems.reduce(0) { $0 + $1.totalPrice } + let pkShippingMethods = ShippingMethod.toPKShippingMethods( + availableMethods, + for: subtotal + ) + + // 첫 번째 배송 방법을 기본값으로 선택 + let defaultMethod = pkShippingMethods.first + + completion(PKPaymentRequestShippingContactUpdate( + errors: nil, + paymentSummaryItems: updatedPaymentSummaryItems(for: defaultMethod), + shippingMethods: pkShippingMethods + )) + } + } + + /// 배송 방법 선택/변경 + nonisolated func paymentAuthorizationController( + _ controller: PKPaymentAuthorizationController, + didSelectShippingMethod shippingMethod: PKShippingMethod, + handler completion: @escaping (PKPaymentRequestShippingMethodUpdate) -> Void + ) { + Task { @MainActor in + // ShippingMethod 찾기 + if let identifier = shippingMethod.identifier, + let method = ShippingMethod.allMethods.first(where: { $0.id == identifier }) { + selectedShippingMethod = method + } + + let items = updatedPaymentSummaryItems(for: shippingMethod) + completion(PKPaymentRequestShippingMethodUpdate( + paymentSummaryItems: items + )) + } + } + + /// 쿠폰 코드 입력 + nonisolated func paymentAuthorizationController( + _ controller: PKPaymentAuthorizationController, + didChangeCouponCode couponCode: String, + handler completion: @escaping (PKPaymentRequestCouponCodeUpdate) -> Void + ) { + Task { @MainActor in + // 쿠폰 코드 검증 + let result = await validateCouponCode(couponCode) + + switch result { + case .success(let discount): + appliedCouponCode = couponCode + couponDiscount = discount + + completion(PKPaymentRequestCouponCodeUpdate( + errors: nil, + paymentSummaryItems: updatedPaymentSummaryItems(for: nil), + shippingMethods: [] + )) + + case .failure(let error): + appliedCouponCode = nil + couponDiscount = 0 + + completion(PKPaymentRequestCouponCodeUpdate( + errors: error.toPKPaymentErrors(), + paymentSummaryItems: updatedPaymentSummaryItems(for: nil), + shippingMethods: [] + )) + } + } + } +} + +// MARK: - 서버 통신 + +extension ApplePayService { + + /// 서버에 결제 처리 요청 + /// - Parameter payment: Apple Pay 결제 정보 + /// - Returns: 결제 결과 + /// + /// - Important: 실제 구현에서는 결제 게이트웨이 서버와 통신해야 함 + private func processPaymentWithServer(payment: PKPayment) async throws -> PaymentResult { + // 결제 토큰 데이터 + let paymentData = payment.token.paymentData + + // 결제 네트워크 및 카드 타입 + let paymentMethod = payment.token.paymentMethod + let network = paymentMethod.network?.rawValue ?? "Unknown" + let cardType = paymentMethod.type.description + + // 트랜잭션 식별자 + let transactionId = payment.token.transactionIdentifier + + // 배송 정보 + let shippingContact = payment.shippingContact + let billingContact = payment.billingContact + + // ⚠️ 실제 구현: 결제 게이트웨이 API 호출 + // 여기서는 Mock 구현 + + // 네트워크 지연 시뮬레이션 + try await Task.sleep(for: .seconds(1)) + + // 90% 확률로 성공 (테스트용) + let success = Double.random(in: 0...1) > 0.1 + + if success { + // 주문 총액 계산 + let subtotal = currentCartItems.reduce(0) { $0 + $1.totalPrice } + let shippingCost = selectedShippingMethod?.calculatePrice(for: subtotal) ?? 0 + let total = subtotal + shippingCost - couponDiscount + + return PaymentResult( + transactionId: transactionId, + status: .success, + amount: total, + currency: configuration.currencyCode, + paymentNetwork: network, + cardType: cardType, + shippingContact: shippingContact, + billingContact: billingContact, + shippingMethod: selectedShippingMethod, + timestamp: Date(), + orderItems: currentCartItems.map { OrderItem(from: $0) } + ) + } else { + throw PaymentError.serverError( + statusCode: 500, + message: "결제 처리 중 서버 오류가 발생했습니다." + ) + } + } + + /// 쿠폰 코드 검증 + private func validateCouponCode(_ code: String) async -> Result { + // ⚠️ 실제 구현: 서버에서 쿠폰 검증 + // 여기서는 Mock 구현 + + try? await Task.sleep(for: .milliseconds(500)) + + let validCoupons: [String: Int] = [ + "SAVE10": 10000, + "SAVE20": 20000, + "WELCOME": 5000, + "FREESHIP": 3000 + ] + + let normalizedCode = code.uppercased().trimmingCharacters(in: .whitespaces) + + if let discount = validCoupons[normalizedCode] { + return .success(discount) + } else if normalizedCode == "EXPIRED" { + return .failure(.couponExpired(code)) + } else { + return .failure(.invalidCouponCode(code)) + } + } +} + +// MARK: - Apple Pay 설정 안내 + +extension ApplePayService { + + /// Wallet 앱으로 이동 (카드 추가 안내) + func openWalletSettings() { + if let url = URL(string: "shoebox://") { + // Wallet 앱 URL Scheme + Task { @MainActor in + #if os(iOS) + await UIApplication.shared.open(url) + #endif + } + } + } + + /// PKPassLibrary를 통한 카드 추가 시트 표시 + func presentAddCardSheet() { + let library = PKPassLibrary() + library.openPaymentSetup() + } +} + +// MARK: - 결제 상태 + +/// 결제 진행 상태 +enum PaymentState: String, Sendable { + /// 대기 상태 + case idle = "대기" + /// 결제 준비 중 + case preparing = "준비 중" + /// 결제 승인 대기 중 + case authorizing = "승인 대기" + /// 결제 처리 중 + case processing = "처리 중" + /// 결제 완료 + case completed = "완료" + /// 결제 취소됨 + case cancelled = "취소됨" + /// 결제 실패 + case failed = "실패" + + /// 결제 진행 중 여부 + var isInProgress: Bool { + switch self { + case .preparing, .authorizing, .processing: + return true + default: + return false + } + } + + /// 최종 상태인지 여부 + var isFinal: Bool { + switch self { + case .completed, .cancelled, .failed: + return true + default: + return false + } + } +} + +// MARK: - 결제 결과 + +/// 결제 완료 결과 +struct PaymentResult: Sendable { + /// 트랜잭션 식별자 + let transactionId: String + + /// 결제 상태 + let status: Status + + /// 결제 금액 + let amount: Int + + /// 통화 코드 + let currency: String + + /// 결제 네트워크 (Visa, Mastercard 등) + let paymentNetwork: String + + /// 카드 타입 (Credit, Debit 등) + let cardType: String + + /// 배송 연락처 + let shippingContact: PKContact? + + /// 청구 연락처 + let billingContact: PKContact? + + /// 배송 방법 + let shippingMethod: ShippingMethod? + + /// 결제 시각 + let timestamp: Date + + /// 주문 항목 + let orderItems: [OrderItem] + + /// 결제 상태 열거형 + enum Status: String, Sendable { + case success = "성공" + case pending = "처리 중" + case failed = "실패" + case refunded = "환불됨" + } + + /// 포맷팅된 금액 + var formattedAmount: String { + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + let formatted = formatter.string(from: NSNumber(value: amount)) ?? "\(amount)" + return "₩\(formatted)" + } + + /// 포맷팅된 날짜 + var formattedDate: String { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "ko_KR") + formatter.dateFormat = "yyyy년 M월 d일 HH:mm" + return formatter.string(from: timestamp) + } +} + +/// 주문 항목 +struct OrderItem: Sendable { + let productId: UUID + let productName: String + let quantity: Int + let unitPrice: Int + let totalPrice: Int + + init(from cartItem: CartItem) { + self.productId = cartItem.product.id + self.productName = cartItem.product.name + self.quantity = cartItem.quantity + self.unitPrice = cartItem.product.price + self.totalPrice = cartItem.totalPrice + } +} + +// MARK: - PKPaymentMethodType Extension + +extension PKPaymentMethodType { + var description: String { + switch self { + case .unknown: return "Unknown" + case .debit: return "Debit" + case .credit: return "Credit" + case .prepaid: return "Prepaid" + case .store: return "Store" + case .eMoney: return "eMoney" + @unknown default: return "Unknown" + } + } +} diff --git a/practice/HIGPractice/HIGPractice/Learning/Phase-01-AppFrameworks/Observation/CartFlow/Shared/CartItem.swift b/practice/HIGPractice/HIGPractice/Learning/Phase-01-AppFrameworks/Observation/CartFlow/Shared/CartItem.swift new file mode 100644 index 00000000..31b0e9da --- /dev/null +++ b/practice/HIGPractice/HIGPractice/Learning/Phase-01-AppFrameworks/Observation/CartFlow/Shared/CartItem.swift @@ -0,0 +1,59 @@ +// +// CartItem.swift +// HIGPractice +// +// Created by YuSeongChoi on 3/16/26. +// + +import Foundation + +// MARK: - 카트 아이템 모델 +/// 장바구니에 담긴 상품과 수량을 나타내는 모델 + +struct CartItem: Identifiable, Equatable { + let id: UUID + let product: Product + var quantity: Int + + init(id: UUID = UUID(), product: Product, quantity: Int = 1) { + self.id = id + self.product = product + self.quantity = max(1, quantity) // 최소 1개 + } + + // MARK: - 계산 속성 + + /// 해당 아이템의 총 금액 (상품 가격 x 수량) + var totalPrice: Int { + product.price * quantity + } + + /// 포맷팅된 총 금액 문자열 + var formattedTotalPrice: String { + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + let formatted = formatter.string(from: NSNumber(value: totalPrice)) ?? "\(totalPrice)" + return "₩\(formatted)" + } + + // MARK: - Equatable + + static func == (lhs: CartItem, rhs: CartItem) -> Bool { + lhs.id == rhs.id && lhs.quantity == rhs.quantity + } +} + +// MARK: - Prewview / Mock Data + +extension CartItem { + static let preview = CartItem( + product: .preview, + quantity: 2 + ) + + static let samples: [CartItem] = [ + CartItem(product: Product.samples[0], quantity: 1), + CartItem(product: Product.samples[3], quantity: 2), + CartItem(product: Product.samples[5], quantity: 3) + ] +} diff --git a/practice/HIGPractice/HIGPractice/Learning/Phase-01-AppFrameworks/Observation/CartFlow/Shared/CartStore.swift b/practice/HIGPractice/HIGPractice/Learning/Phase-01-AppFrameworks/Observation/CartFlow/Shared/CartStore.swift new file mode 100644 index 00000000..24e665cf --- /dev/null +++ b/practice/HIGPractice/HIGPractice/Learning/Phase-01-AppFrameworks/Observation/CartFlow/Shared/CartStore.swift @@ -0,0 +1,195 @@ +// +// CartStore.swift +// HIGPractice +// +// Created by YuSeongChoi on 3/16/26. +// + +import Foundation +import Observation + +// MARK: - CartStore (@Observable) +/// iOS 17+ Observation 프레임워크를 사용한 카트 상태 관리 +/// +/// ## @Observable vs ObservableObject 비교 +/// +/// ### 기존 방식 (ObservableObject): +/// ```swift +/// class CartStore: ObservableObject { +/// @Published var items: [CartItem] = [] +/// @Published var isLoading = false +/// } +/// ``` +/// - View에서 `@ObservedObject` 또는 `@StateObject` 필요 +/// - 모든 @Published 변경 시 전체 View 업데이트 +/// +/// ### 새로운 방식 (@Observable): +/// ```swift +/// @Observable +/// class CartStore { +/// var items: [CartItem] = [] +/// var isLoading = false +/// } +/// ``` +/// - View에서 별도 프로퍼티 래퍼 불필요 (자동 추적) +/// - 실제 사용하는 프로퍼티만 추적하여 성능 최적화 +/// - Macro 기반으로 보일러플레이트 제거 + +@Observable +class CartStore { + // MARK: - 상태 (자동 추적됨) + // stored property를 읽거나 바꾸는 순간 Observation이 접근을 추적한다. + + /// 장바구니 아이템 목록 + var items: [CartItem] = [] + + /// 로딩 상태 + var isLoading = false + + /// 결제 완료 상태 + var isCheckoutComplete = false + + /// 오류 메시지 + var errorMessage: String? + + // MARK: - 계산 속성 + + /// 카트 내 총 아이템 수량 + /// - 저장값이 아니라 items를 읽어 계산하는 파생 상태다. + /// - 이 값을 읽는 뷰는 결국 items 변화와 연결된다. + var totalItemCount: Int { + items.reduce(0) { $0 + $1.quantity } + } + + /// 카트 총 금액 + var totalPrice: Int { + items.reduce(0) { $0 + $1.totalPrice } + } + + /// 포맷팅된 총 금액 + var formattedTotalPrice: String { + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + let formatted = formatter.string(from: NSNumber(value: totalPrice)) ?? "\(totalPrice)" + return "₩\(formatted)" + } + + /// 카트가 비어있는지 여부 + var isEmpty: Bool { + items.isEmpty + } + + // MARK: - 카트 조작 메서드 + // 상태 변경 규칙을 View 밖이 아니라 Store 안에 둔다. + // 이렇게 해야 View는 "어떻게 바꿀지"보다 "무슨 행동을 요청할지"에 집중할 수 있다. + + /// 상품을 카트에 추가 + /// - Parameters: + /// - product: 추가할 상품 + /// - quantity: 수량 (기본값: 1) + func addToCart(_ product: Product, quantity: Int = 1) { + // 이미 카트에 있는 상품이면 수량 증가 + if let index = items.firstIndex(where: { $0.product.id == product.id }) { + items[index].quantity += quantity + } else { + // 새 상품이면 추가 + let newItem = CartItem(product: product, quantity: quantity) + items.append(newItem) + } + } + + /// 카트에서 상품 제거 + /// - Parameter product: 제거할 상품 + func removeFromCart(_ product: Product) { + items.removeAll { $0.product.id == product.id } + } + + /// 특정 아이템의 수량 변경 + /// - Parameters: + /// - item: 대상 카트 아이템 + /// - newQuantity: 새 수량 (0 이하면 제거) + func updatedQuantity(for item: CartItem, to newQuantity: Int) { + guard let index = items.firstIndex(where: { $0.id == item.id }) else { return } + + if newQuantity <= 0 { + items.remove(at: index) + } else { + items[index].quantity = newQuantity + } + } + + /// 수량 1 증가 + func incrementQuantity(for item: CartItem) { + updatedQuantity(for: item, to: item.quantity + 1) + } + + /// 수량 1 감소 (1개면 제거) + func decrementQuantity(for item: CartItem) { + updatedQuantity(for: item, to: item.quantity - 1) + } + + /// 카트 비우기 + func clearCart() { + items.removeAll() + isCheckoutComplete = false + errorMessage = nil + } + + /// 특정 상품이 카트에 있는지 확인 + func contains(_ product: Product) -> Bool { + items.contains { $0.product.id == product.id } + } + + /// 특정 상품의 카트 내 수량 + func quantity(of product: Product) -> Int { + items.first { $0.product.id == product.id }?.quantity ?? 0 + } + + // MARK: - 결제 시뮬레이션 + + /// 결제 처리 (Mock) + @MainActor + func checkout() async { + guard !isEmpty else { return } + + // Observation은 async 자체를 처리하지 않는다. + // 대신 async 작업 전후에 바뀐 상태(isLoading, errorMessage, items)를 추적한다. + isLoading = true + errorMessage = nil + + // 네트워크 요청 시뮬레이션 (2초) + try? await Task.sleep(for: .seconds(2)) + + // 90% 확률로 성공 + if Double.random(in: 0...1) > 0.1 { + items.removeAll() + isCheckoutComplete = true + } else { + errorMessage = "결제 처리 중 오류가 발생했습니다. 다시 시도해주세요." + } + + isLoading = false + } + + /// 결제 완료 상태 초기화 + func resetCheckout() { + isCheckoutComplete = false + errorMessage = nil + } +} + +// MARK: - Preview Support + +extension CartStore { + /// 미리보기용 샘플 데이터가 채워진 Store + static var preview: CartStore { + let store = CartStore() + store.items = CartItem.samples + return store + } + + /// 비어있는 Store + static var empty: CartStore { + CartStore() + } +} diff --git a/practice/HIGPractice/HIGPractice/Learning/Phase-01-AppFrameworks/Observation/CartFlow/Shared/PaymentConfiguration.swift b/practice/HIGPractice/HIGPractice/Learning/Phase-01-AppFrameworks/Observation/CartFlow/Shared/PaymentConfiguration.swift new file mode 100644 index 00000000..886a4b36 --- /dev/null +++ b/practice/HIGPractice/HIGPractice/Learning/Phase-01-AppFrameworks/Observation/CartFlow/Shared/PaymentConfiguration.swift @@ -0,0 +1,310 @@ +// +// PaymentConfiguration.swift +// HIGPractice +// +// Created by YuSeongChoi on 3/16/26. +// + +import Foundation +internal import PassKit + +// MARK: - 결제 설정 +/// Apple Pay 결제에 필요한 설정 값들을 관리하는 구조체 +/// +/// ## 사용 예시 +/// ```swift +/// let config = PaymentConfiguration.default +/// let request = config.createPaymentRequest(for: 50000) +/// ``` +/// +/// ## 중요 설정 항목 +/// - `merchantIdentifier`: Apple Developer에서 등록한 Merchant ID +/// - `supportedNetworks`: 지원하는 카드 네트워크 목록 +/// - `merchantCapabilities`: 3DS, EMV 등 지원 기능 + +struct PaymentConfiguration: Sendable { + + // MARK: - 기본 설정 + + /// Apple Developer에서 등록한 Merchant Identifier + /// - Important: 실제 앱에서는 Info.plist 또는 Configuration에서 로드해야 함 + let merchantIdentifier: String + + /// 상점 표시 이름 (결제 시트에 표시됨) + let merchantDisplayName: String + + /// 국가 코드 (ISO 3166-1 alpha-2) + let countryCode: String + + /// 통화 코드 (ISO 4217) + let currencyCode: String + + /// 지원하는 카드 네트워크 + let supportedNetworks: [PKPaymentNetwork] + + /// 판매자 처리 능력 (3D Secure 등) + let merchantCapabilities: PKMerchantCapability + + /// 필수 배송 정보 필드 + let requiredShippingContactFields: Set + + /// 필수 청구 정보 필드 + let requiredBillingContactFields: Set + + /// 배송 지원 여부 + let supportsShipping: Bool + + /// 쿠폰 코드 지원 여부 (iOS 15+) + let supportsCouponCode: Bool + + // MARK: - 초기화 + + /// 커스텀 설정으로 초기화 + /// - Parameters: + /// - merchantIdentifier: Merchant ID + /// - merchantDisplayName: 상점명 + /// - countryCode: 국가 코드 + /// - currencyCode: 통화 코드 + /// - supportedNetworks: 지원 카드 네트워크 + /// - merchantCapabilities: 판매자 기능 + /// - requiredShippingContactFields: 필수 배송 정보 + /// - requiredBillingContactFields: 필수 청구 정보 + /// - supportsShipping: 배송 지원 여부 + /// - supportsCouponCode: 쿠폰 지원 여부 + init( + merchantIdentifier: String, + merchantDisplayName: String, + countryCode: String = "KR", + currencyCode: String = "KRW", + supportedNetworks: [PKPaymentNetwork] = Self.defaultNetworks, + merchantCapabilities: PKMerchantCapability = Self.defaultCapabilities, + requiredShippingContactFields: Set = Self.defaultShippingFields, + requiredBillingContactFields: Set = Self.defaultBillingFields, + supportsShipping: Bool = true, + supportsCouponCode: Bool = true + ) { + self.merchantIdentifier = merchantIdentifier + self.merchantDisplayName = merchantDisplayName + self.countryCode = countryCode + self.currencyCode = currencyCode + self.supportedNetworks = supportedNetworks + self.merchantCapabilities = merchantCapabilities + self.requiredShippingContactFields = requiredShippingContactFields + self.requiredBillingContactFields = requiredBillingContactFields + self.supportsShipping = supportsShipping + self.supportsCouponCode = supportsCouponCode + } + + // MARK: - 기본 설정값 + + /// 한국에서 지원되는 기본 카드 네트워크 + /// - Note: 실제 지원 여부는 Merchant 계약에 따라 다름 + static let defaultNetworks: [PKPaymentNetwork] = [ + .visa, + .masterCard, + .amex, + .discover, + .JCB, + // iOS 14.5+ + .chinaUnionPay, + // iOS 16+ + .maestro + ] + + /// 기본 판매자 처리 능력 + /// - 3DS: 3D Secure 인증 지원 (EMV 3DS) + /// - EMV: EMV 규격 지원 + static let defaultCapabilities: PKMerchantCapability = [ + .threeDSecure, + .debit, + .credit + ] + + /// 기본 필수 배송 정보 필드 + static let defaultShippingFields: Set = [ + .name, + .postalAddress, + .phoneNumber + ] + + /// 기본 필수 청구 정보 필드 + static let defaultBillingFields: Set = [ + .name, + .postalAddress + ] + + /// 기본 설정 (데모/테스트용) + /// - Warning: 프로덕션에서는 실제 Merchant ID 사용 필요 + static let `default` = PaymentConfiguration( + merchantIdentifier: "merchant.com.example.cartflow", + merchantDisplayName: "CartFlow 스토어" + ) + + /// 배송 없는 디지털 상품을 설정 + static let digitalProducts = PaymentConfiguration( + merchantIdentifier: "merchant.com.example.cartflow.digital", + merchantDisplayName: "CartFlow 디지털", + requiredShippingContactFields: [.emailAddress], + requiredBillingContactFields: [.name], + supportsShipping: false, + supportsCouponCode: true + ) +} + +// MARK: - PKPaymentRequest 생성 + +extension PaymentConfiguration { + + /// 결제 요청 생성 + /// - Parameters: + /// - amount: 결제 금액 (원화) + /// - items: 결제 항목 목록 (선택) + /// - shippingMethods: 배송 옵션 (선택) + /// - Returns: 구성된 PKPaymentRequest + /// + /// ## 사용 예시 + /// ```swift + /// let config = PaymentConfiguration.default + /// let items = [ + /// PKPaymentSummaryItem(label: "상품A", amount: 10000), + /// PKPaymentSummaryItem(label: "배송비", amount: 3000) + /// ] + /// let request = config.createPaymentRequest(items: items) + /// ``` + func createPaymentRequest( + items: [PKPaymentSummaryItem], + shippingMethods: [PKShippingMethod]? = nil + ) -> PKPaymentRequest { + let request = PKPaymentRequest() + + // 기본 설정 + request.merchantIdentifier = merchantIdentifier + request.countryCode = countryCode + request.currencyCode = currencyCode + request.supportedNetworks = supportedNetworks + request.merchantCapabilities = merchantCapabilities + + // 결제 항목 + request.paymentSummaryItems = items + + // 연락처 필드 + if supportsShipping { + request.requiredShippingContactFields = requiredShippingContactFields + if let methods = shippingMethods { + request.shippingMethods = methods + request.shippingType = .shipping + } + } + + request.requiredBillingContactFields = requiredBillingContactFields + + // 쿠폰 코드 지원 (iOS 15+) + if supportsCouponCode { + request.supportsCouponCode = true + } + + return request + } + + /// 단순 금액으로 결제 요청 생성 + /// - Parameter totalAmount: 총 결제 금액 + /// - Returns: 구성된 PKPaymentRequest + func createPaymentRequest(for totalAmount: Int) -> PKPaymentRequest { + let amount = NSDecimalNumber(value: totalAmount) + let totalItem = PKPaymentSummaryItem( + label: merchantDisplayName, + amount: amount, + type: .final + ) + return createPaymentRequest(items: [totalItem]) + } +} + +// MARK: - 결제 가능 여부 확인 + +extension PaymentConfiguration { + + /// Apple Pay 사용 가능 여부 + /// - Returns: 디바이스에서 Apple Pay를 사용할 수 있는지 여부 + /// + /// ## 확인 항목 + /// 1. 디바이스의 Apple Pay 지원 여부 + /// 2. 지원 네트워크로 결제 가능한 카드 등록 여부 + var canMakePayments: Bool { + PKPaymentAuthorizationController.canMakePayments() + } + + /// 등록된 카드로 결제 가능 여부 + /// - Returns: 지원 네트워크의 카드가 등록되어 있는지 여부 + var canMakePaymentsWithRegisteredCards: Bool { + PKPaymentAuthorizationController.canMakePayments( + usingNetworks: supportedNetworks, + capabilities: merchantCapabilities + ) + } + + /// Apple Pay 설정 안내 필요 여부 + /// - Returns: 카드 설정이 필요한 경우 true + /// + /// 디바이스는 Apple Pay를 지원하지만 결제 가능한 카드가 없는 경우 + var needsSetup: Bool { + canMakePayments && !canMakePaymentsWithRegisteredCards + } + + /// 결제 가능 상태 열거형 + enum PaymentAvailability { + /// Apple Pay 사용 가능, 카드 등록됨 + case available + /// 카드 설정 필요 + case needsSetup + /// Apple Pay 미지원 디바이스 + case notSupported + } + + /// 현재 결제 가능 상태 + var paymentAvailability: PaymentAvailability { + if canMakePaymentsWithRegisteredCards { + return .available + } else if canMakePayments { + return .needsSetup + } else { + return .notSupported + } + } +} + +// MARK: - Equatable, Hashable + +extension PaymentConfiguration: Equatable { + static func == (lhs: PaymentConfiguration, rhs: PaymentConfiguration) -> Bool { + lhs.merchantIdentifier == rhs.merchantIdentifier && + lhs.countryCode == rhs.countryCode && + lhs.currencyCode == rhs.currencyCode + } +} + +extension PaymentConfiguration: Hashable { + func hash(into hasher: inout Hasher) { + hasher.combine(merchantIdentifier) + hasher.combine(countryCode) + hasher.combine(currencyCode) + } +} + +// MARK: - Debug Description + +extension PaymentConfiguration: CustomDebugStringConvertible { + var debugDescription: String { + """ + PaymentConfiguration( + merchantIdentifier: \(merchantIdentifier), + merchantDisplayName: \(merchantDisplayName), + countryCode: \(countryCode), + currencyCode: \(currencyCode), + supportedNetworks: \(supportedNetworks.map { $0.rawValue }), + supportsShipping: \(supportsShipping), + paymentAvailability: \(paymentAvailability) + ) + """ + } +} diff --git a/practice/HIGPractice/HIGPractice/Learning/Phase-01-AppFrameworks/Observation/CartFlow/Shared/PaymentError.swift b/practice/HIGPractice/HIGPractice/Learning/Phase-01-AppFrameworks/Observation/CartFlow/Shared/PaymentError.swift new file mode 100644 index 00000000..5beceeda --- /dev/null +++ b/practice/HIGPractice/HIGPractice/Learning/Phase-01-AppFrameworks/Observation/CartFlow/Shared/PaymentError.swift @@ -0,0 +1,510 @@ +// +// PaymentError.swift +// HIGPractice +// +// Created by YuSeongChoi on 3/16/26. +// + +import Foundation +internal import PassKit + +// MARK: - 결제 오류 +/// Apple Pay 결제 과정에서 발생할 수 있는 모든 오류 유형 +/// +/// ## 오류 카테고리 +/// - **구성 오류**: Merchant ID 누락, 잘못된 설정 등 +/// - **권한 오류**: Apple Pay 미지원, 카드 미등록 등 +/// - **결제 오류**: 사용자 취소, 결제 실패 등 +/// - **네트워크 오류**: 서버 통신 실패 +/// - **검증 오류**: 데이터 유효성 검사 실패 + +enum PaymentError: Error, Sendable { + + // MARK: - 구성 오류 (Configuration Errors) + + /// Merchant Identifier가 설정되지 않음 + case merchantIdentifierNotConfigured + + /// 지원하는 카드 네트워크가 없음 + case noSupportedNetworks + + /// 잘못된 통화 코드 + case invalidCurrencyCode(String) + + /// 잘못된 국가 코드 + case invalidCountryCode(String) + + /// 결제 금액이 유효하지 않음 + case invalidAmount(Decimal) + + /// 결제 항목이 비어있음 + case emptyPaymentItems + + // MARK: - 권한 오류 (Authorization Errors) + + /// 기기에서 Apple Pay를 지원하지 않음 + case applePayNotSupported + + /// 지원하는 네트워크의 카드가 등록되지 않음 + case noRegisteredCards + + /// Apple Pay 설정이 필요함 + case applePaySetupRequired + + /// 결제 권한이 거부됨 (Parental Controls 등) + case authorizationDenied + + // MARK: - 결제 처리 오류 (Payment Processing Errors) + + /// 사용자가 결제를 취소함 + case userCancelled + + /// 결제 승인 실패 + case authorizationFailed(underlyingError: Error?) + + /// 결제 토큰 생성 실패 + case tokenGenerationFailed + + /// 결제 처리 타임아웃 + case timeout + + /// 결제 세션이 만료됨 + case sessionExpired + + /// 중복 결제 시도 + case duplicatePayment + + // MARK: - 네트워크 오류 (Network Errors) + + /// 네트워크 연결 없음 + case noNetworkConnection + + /// 서버 응답 오류 + case serverError(statusCode: Int, message: String?) + + /// 서버 응답 파싱 실패 + case invalidServerResponse + + /// SSL/TLS 인증서 오류 + case sslError + + // MARK: - 검증 오류 (Validation Errors) + + /// 배송 주소가 유효하지 않음 + case invalidShippingAddress(reason: String?) + + /// 청구 주소가 유효하지 않음 + case invalidBillingAddress(reason: String?) + + /// 연락처 정보가 유효하지 않음 + case invalidContactInfo(field: String) + + /// 선택된 배송 방법이 유효하지 않음 + case invalidShippingMethod + + /// 쿠폰 코드가 유효하지 않음 + case invalidCouponCode(String) + + /// 쿠폰이 만료됨 + case couponExpired(String) + + // MARK: - 재고/상품 오류 (Inventory Errors) + + /// 재고 부족 + case insufficientStock(productId: String, available: Int, requested: Int) + + /// 상품을 찾을 수 없음 + case productNotFound(productId: String) + + /// 상품 가격이 변경됨 + case priceChanged(productId: String, oldPrice: Int, newPrice: Int) + + // MARK: - 기타 오류 + + /// 알 수 없는 오류 + case unknown(underlyingError: Error?) + + /// 시스템 오류 + case systemError(String) +} + +// MARK: - LocalizedError + +extension PaymentError: LocalizedError { + + /// 사용자에게 표시할 오류 설명 + var errorDescription: String? { + switch self { + // 구성 오류 + case .merchantIdentifierNotConfigured: + return "결제 설정이 완료되지 않았습니다. 앱 설정을 확인해주세요." + case .noSupportedNetworks: + return "지원하는 결제 수단이 없습니다." + case .invalidCurrencyCode(let code): + return "지원하지 않는 통화입니다: \(code)" + case .invalidCountryCode(let code): + return "지원하지 않는 국가입니다: \(code)" + case .invalidAmount(let amount): + return "결제 금액이 올바르지 않습니다: \(amount)" + case .emptyPaymentItems: + return "결제할 항목이 없습니다." + + // 권한 오류 + case .applePayNotSupported: + return "이 기기에서는 Apple Pay를 사용할 수 없습니다." + case .noRegisteredCards: + return "등록된 결제 카드가 없습니다. Wallet 앱에서 카드를 추가해주세요." + case .applePaySetupRequired: + return "Apple Pay 설정이 필요합니다." + case .authorizationDenied: + return "결제 권한이 거부되었습니다." + + // 결제 처리 오류 + case .userCancelled: + return "결제가 취소되었습니다." + case .authorizationFailed(let error): + if let error = error { + return "결제 승인에 실패했습니다: \(error.localizedDescription)" + } + return "결제 승인에 실패했습니다." + case .tokenGenerationFailed: + return "결제 정보 처리 중 오류가 발생했습니다." + case .timeout: + return "결제 처리 시간이 초과되었습니다. 다시 시도해주세요." + case .sessionExpired: + return "결제 세션이 만료되었습니다. 다시 시도해주세요." + case .duplicatePayment: + return "이미 결제가 진행 중입니다." + + // 네트워크 오류 + case .noNetworkConnection: + return "인터넷 연결을 확인해주세요." + case .serverError(let statusCode, let message): + if let message = message { + return "서버 오류 (\(statusCode)): \(message)" + } + return "서버 오류가 발생했습니다. (코드: \(statusCode))" + case .invalidServerResponse: + return "서버 응답을 처리할 수 없습니다." + case .sslError: + return "보안 연결에 문제가 있습니다." + + // 검증 오류 + case .invalidShippingAddress(let reason): + if let reason = reason { + return "배송 주소 오류: \(reason)" + } + return "배송 주소가 올바르지 않습니다." + case .invalidBillingAddress(let reason): + if let reason = reason { + return "청구 주소 오류: \(reason)" + } + return "청구 주소가 올바르지 않습니다." + case .invalidContactInfo(let field): + return "연락처 정보가 올바르지 않습니다: \(field)" + case .invalidShippingMethod: + return "선택한 배송 방법을 사용할 수 없습니다." + case .invalidCouponCode(let code): + return "유효하지 않은 쿠폰 코드입니다: \(code)" + case .couponExpired(let code): + return "만료된 쿠폰입니다: \(code)" + + // 재고/상품 오류 + case .insufficientStock(_, let available, let requested): + return "재고가 부족합니다. (요청: \(requested)개, 가능: \(available)개)" + case .productNotFound: + return "상품을 찾을 수 없습니다." + case .priceChanged(_, let oldPrice, let newPrice): + return "상품 가격이 변경되었습니다. (₩\(oldPrice.formatted()) → ₩\(newPrice.formatted()))" + + // 기타 + case .unknown(let error): + if let error = error { + return "오류가 발생했습니다: \(error.localizedDescription)" + } + return "알 수 없는 오류가 발생했습니다." + case .systemError(let message): + return "시스템 오류: \(message)" + } + } + + /// 오류 복구 제안 + var recoverySuggestion: String? { + switch self { + case .merchantIdentifierNotConfigured, .noSupportedNetworks: + return "개발자에게 문의해주세요." + case .applePayNotSupported: + return "다른 결제 수단을 이용해주세요." + case .noRegisteredCards, .applePaySetupRequired: + return "설정 > Wallet & Apple Pay에서 카드를 추가해주세요." + case .userCancelled: + return nil + case .timeout, .noNetworkConnection: + return "인터넷 연결을 확인하고 다시 시도해주세요." + case .insufficientStock: + return "수량을 줄이거나 다른 상품을 선택해주세요." + case .priceChanged: + return "장바구니를 새로고침 해주세요." + default: + return "잠시 후 다시 시도해주세요." + } + } + + /// 도움말 앵커 (문서 링크용) + var helpAnchor: String? { + switch self { + case .applePayNotSupported, .noRegisteredCards, .applePaySetupRequired: + return "apple-pay-setup" + case .noNetworkConnection: + return "network-troubleshooting" + default: + return nil + } + } +} + +// MARK: - 오류 분류 + +extension PaymentError { + + /// 오류 카테고리 + enum Category: String, Sendable { + case configuration = "설정 오류" + case authorization = "권한 오류" + case payment = "결제 오류" + case network = "네트워크 오류" + case validation = "검증 오류" + case inventory = "재고 오류" + case other = "기타 오류" + } + + /// 현재 오류의 카테고리 + var category: Category { + switch self { + case .merchantIdentifierNotConfigured, .noSupportedNetworks, + .invalidCurrencyCode, .invalidCountryCode, .invalidAmount, + .emptyPaymentItems: + return .configuration + case .applePayNotSupported, .noRegisteredCards, .applePaySetupRequired, + .authorizationDenied: + return .authorization + case .userCancelled, .authorizationFailed, .tokenGenerationFailed, + .timeout, .sessionExpired, .duplicatePayment: + return .payment + case .noNetworkConnection, .serverError, .invalidServerResponse, .sslError: + return .network + case .invalidShippingAddress, .invalidBillingAddress, .invalidContactInfo, + .invalidShippingMethod, .invalidCouponCode, .couponExpired: + return .validation + case .insufficientStock, .productNotFound, .priceChanged: + return .inventory + case .unknown, .systemError: + return .other + } + } + + /// 재시도 가능 여부 + var isRetryable: Bool { + switch self { + case .timeout, .noNetworkConnection, .serverError, .sessionExpired: + return true + case .userCancelled, .applePayNotSupported, .merchantIdentifierNotConfigured: + return false + default: + return false + } + } + + /// 사용자 입력으로 해결 가능한지 여부 + var isUserRecoverable: Bool { + switch self { + case .noRegisteredCards, .applePaySetupRequired, .invalidShippingAddress, + .invalidBillingAddress, .invalidContactInfo, .invalidCouponCode, + .insufficientStock: + return true + default: + return false + } + } + + /// 로깅 심각도 + enum Severity: String { + case info + case warning + case error + case critical + } + + var severity: Severity { + switch self { + case .userCancelled: + return .info + case .invalidCouponCode, .couponExpired, .insufficientStock: + return .warning + case .merchantIdentifierNotConfigured, .noSupportedNetworks, .sslError: + return .critical + default: + return .error + } + } +} + +// MARK: - PKPaymentAuthorizationResult 변환 + +extension PaymentError { + + /// PKPaymentAuthorizationResult 에러 배열로 변환 + /// - Returns: Apple Pay 결제 시트에 표시할 에러 배열 + func toPKPaymentErrors() -> [Error] { + switch self { + case .invalidShippingAddress(let reason): + let error = PKPaymentRequest.paymentShippingAddressInvalidError( + withKey: CNPostalAddressStreetKey, + localizedDescription: reason ?? "배송 주소가 올바르지 않습니다." + ) + return [error] + + case .invalidBillingAddress(let reason): + let error = PKPaymentRequest.paymentBillingAddressInvalidError( + withKey: CNPostalAddressStreetKey, + localizedDescription: reason ?? "청구 주소가 올바르지 않습니다." + ) + return [error] + + case .invalidContactInfo(let field): + let key: String + switch field { + case "phone", "전화번호": + key = CNContactPhoneNumbersKey + case "email", "이메일": + key = CNContactEmailAddressesKey + case "name", "이름": + key = CNContactGivenNameKey + default: + key = field + } + let error = PKPaymentRequest.paymentShippingAddressInvalidError( + withKey: key, + localizedDescription: "연락처 정보를 확인해주세요: \(field)" + ) + return [error] + + case .invalidCouponCode(let code): + let error = PKPaymentRequest.paymentCouponCodeInvalidError( + localizedDescription: "유효하지 않은 쿠폰입니다: \(code)" + ) + return [error] + + case .couponExpired(let code): + let error = PKPaymentRequest.paymentCouponCodeExpiredError( + localizedDescription: "만료된 쿠폰입니다: \(code)" + ) + return [error] + + default: + return [] + } + } +} + +// MARK: - Contacts Framework Import + +import Contacts + +// MARK: - CustomNSError + +extension PaymentError: CustomNSError { + + static var errorDomain: String { + "com.cartflow.payment" + } + + var errorCode: Int { + switch self { + // 구성 오류: 1000번대 + case .merchantIdentifierNotConfigured: return 1001 + case .noSupportedNetworks: return 1002 + case .invalidCurrencyCode: return 1003 + case .invalidCountryCode: return 1004 + case .invalidAmount: return 1005 + case .emptyPaymentItems: return 1006 + + // 권한 오류: 2000번대 + case .applePayNotSupported: return 2001 + case .noRegisteredCards: return 2002 + case .applePaySetupRequired: return 2003 + case .authorizationDenied: return 2004 + + // 결제 처리 오류: 3000번대 + case .userCancelled: return 3001 + case .authorizationFailed: return 3002 + case .tokenGenerationFailed: return 3003 + case .timeout: return 3004 + case .sessionExpired: return 3005 + case .duplicatePayment: return 3006 + + // 네트워크 오류: 4000번대 + case .noNetworkConnection: return 4001 + case .serverError: return 4002 + case .invalidServerResponse: return 4003 + case .sslError: return 4004 + + // 검증 오류: 5000번대 + case .invalidShippingAddress: return 5001 + case .invalidBillingAddress: return 5002 + case .invalidContactInfo: return 5003 + case .invalidShippingMethod: return 5004 + case .invalidCouponCode: return 5005 + case .couponExpired: return 5006 + + // 재고 오류: 6000번대 + case .insufficientStock: return 6001 + case .productNotFound: return 6002 + case .priceChanged: return 6003 + + // 기타: 9000번대 + case .unknown: return 9001 + case .systemError: return 9002 + } + } + + var errorUserInfo: [String: Any] { + var userInfo: [String: Any] = [ + NSLocalizedDescriptionKey: errorDescription ?? "Unknown error" + ] + + if let suggestion = recoverySuggestion { + userInfo[NSLocalizedRecoverySuggestionErrorKey] = suggestion + } + + if let anchor = helpAnchor { + userInfo[NSHelpAnchorErrorKey] = anchor + } + + // 추가 컨텍스트 정보 + switch self { + case .serverError(let statusCode, _): + userInfo["HTTPStatusCode"] = statusCode + case .insufficientStock(let productId, let available, let requested): + userInfo["ProductID"] = productId + userInfo["AvailableStock"] = available + userInfo["RequestedQuantity"] = requested + case .priceChanged(let productId, let oldPrice, let newPrice): + userInfo["ProductID"] = productId + userInfo["OldPrice"] = oldPrice + userInfo["NewPrice"] = newPrice + default: + break + } + + return userInfo + } +} + +// MARK: - Equatable + +extension PaymentError: Equatable { + static func == (lhs: PaymentError, rhs: PaymentError) -> Bool { + lhs.errorCode == rhs.errorCode + } +} diff --git a/practice/HIGPractice/HIGPractice/Learning/Phase-01-AppFrameworks/Observation/CartFlow/Shared/Product.swift b/practice/HIGPractice/HIGPractice/Learning/Phase-01-AppFrameworks/Observation/CartFlow/Shared/Product.swift new file mode 100644 index 00000000..1d3569e1 --- /dev/null +++ b/practice/HIGPractice/HIGPractice/Learning/Phase-01-AppFrameworks/Observation/CartFlow/Shared/Product.swift @@ -0,0 +1,165 @@ +// +// Product.swift +// HIGPractice +// +// Created by YuSeongChoi on 3/16/26. +// + +import Foundation + +// MARK: - 상품 모델 +/// 쇼핑 앱에서 사용하는 기본 상품 데이터 모델 + +struct Product: Identifiable, Hashable { + let id: UUID + let name: String + let description: String + let price: Int + let category: ProductCategory + let imageName: String + let stockCount: Int + + init( + id: UUID = UUID(), + name: String, + description: String, + price: Int, + category: ProductCategory, + imageName: String, + stockCount: Int = 100 + ) { + self.id = id + self.name = name + self.description = description + self.price = price + self.category = category + self.imageName = imageName + self.stockCount = stockCount + } +} + +// MARK: - 상품 카테고리 + +enum ProductCategory: String, CaseIterable, Identifiable { + case electronics = "전자기기" + case clothing = "의류" + case food = "식품" + case books = "도서" + case home = "홈/리빙" + + var id: String { rawValue } + + /// 카테고리별 SF Symbol + var symbol: String { + switch self { + case .electronics: "desktopcomputer" + case .clothing: "tshirt" + case .food: "carrot" + case .books: "book" + case .home: "house" + } + } +} + +extension Product { + /// 원화 포맷팅된 가격 문자열 + var formattedPrice: String { + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + let formatted = formatter.string(from: NSNumber(value: price)) ?? "\(price)" + return "₩\(formatted)" + } +} + +extension Product { + static let preview = Product( + name: "AirPods Pro", + description: "애플의 프리미엄 무선 이어폰. 액티브 노이즈 캔슬링과 공간 음향을 지원합니다.", + price: 359000, + category: .electronics, + imageName: "airpodspro" + ) + + static let samples: [Product] = [ + // 전자기기 + Product( + name: "AirPods Pro", + description: "액티브 노이즈 캔슬링과 공간 음향을 지원하는 무선 이어폰", + price: 359000, + category: .electronics, + imageName: "airpodspro" + ), + Product( + name: "iPad Air", + description: "M2 칩 탑재, 10.9인치 Liquid Retina 디스플레이", + price: 899000, + category: .electronics, + imageName: "ipad" + ), + Product( + name: "Apple Watch Series 9", + description: "건강 모니터링과 피트니스 추적이 가능한 스마트워치", + price: 599000, + category: .electronics, + imageName: "applewatch" + ), + + // 의류 + Product( + name: "캐시미어 니트", + description: "부드러운 100% 캐시미어 소재의 프리미엄 니트", + price: 189000, + category: .clothing, + imageName: "sweater" + ), + Product( + name: "데님 재킷", + description: "클래식한 디자인의 오버핏 데님 재킷", + price: 129000, + category: .clothing, + imageName: "jacket" + ), + + // 식품 + Product( + name: "유기농 그래놀라", + description: "건강한 아침을 위한 유기농 통곡물 그래놀라", + price: 15000, + category: .food, + imageName: "granola" + ), + Product( + name: "수제 잼 세트", + description: "딸기, 블루베리, 살구 수제 잼 3종 세트", + price: 28000, + category: .food, + imageName: "jam" + ), + + // 도서 + Product( + name: "SwiftUI 마스터북", + description: "SwiftUI의 기초부터 고급 기법까지 완벽 가이드", + price: 42000, + category: .books, + imageName: "book" + ), + + // 홈/리빙 + Product( + name: "아로마 디퓨저", + description: "은은한 조명과 함께하는 초음파 아로마 디퓨저", + price: 45000, + category: .home, + imageName: "diffuser" + ), + Product( + name: "머그컵 세트", + description: "북유럽 스타일 세라믹 머그컵 4개 세트", + price: 32000, + category: .home, + imageName: "mug" + ), + ] +} + diff --git a/practice/HIGPractice/HIGPractice/Learning/Phase-01-AppFrameworks/Observation/CartFlow/Shared/ProductService.swift b/practice/HIGPractice/HIGPractice/Learning/Phase-01-AppFrameworks/Observation/CartFlow/Shared/ProductService.swift new file mode 100644 index 00000000..6a65c542 --- /dev/null +++ b/practice/HIGPractice/HIGPractice/Learning/Phase-01-AppFrameworks/Observation/CartFlow/Shared/ProductService.swift @@ -0,0 +1,92 @@ +// +// ProductService.swift +// HIGPractice +// +// Created by YuSeongChoi on 3/16/26. +// + +import Foundation + +// MARK: - ProductService +/// Mock 상품 데이터 서비스 +/// 실제 앱에서는 네트워크 레이어로 교체 + +actor ProductService { + // MARK: - Singleton (선택적) + + static let shared = ProductService() + + // MARK: - 내부 저장소 + + private var products: [Product] = Product.samples + + // MARK: - 상품 조회 + + /// 모든 상품 목록 조회 + /// - Returns: 상품 배열 + func fetchAllProducts() async throws -> [Product] { + // 네트워크 지연 시뮬레이션 + try await Task.sleep(for: .milliseconds(500)) + return products + } + + /// 카테고리별 상품 조회 + /// - Parameter category: 상품 카테고리 + /// - Returns: 해당 카테고리의 상품 배열 + func fetchProducts(by category: ProductCategory) async throws -> [Product] { + try await Task.sleep(for: .milliseconds(300)) + return products.filter { $0.category == category } + } + + /// ID로 상품 조회 + /// - Parameter id: 상품 ID + /// - Returns: 상품 (없으면 nil) + func fetchProduct(id: UUID) async throws -> Product? { + try await Task.sleep(for: .milliseconds(200)) + return products.first { $0.id == id } + } + + /// 상품 검색 + /// - Parameter query: 검색어 + /// - Returns: 검색 결과 상품 배열 + func searchProducts(query: String) async throws -> [Product] { + try await Task.sleep(for: .milliseconds(400)) + + let lowercasedQuery = query.lowercased() + return products.filter { product in + product.name.lowercased().contains(lowercasedQuery) || + product.description.lowercased().contains(lowercasedQuery) + } + } + + // MARK: - 카테고리 정보 + + /// 카테고리별 상품 개수 + /// - Returns: [카테고리: 상품 수] 딕셔너리 + func productCountByCategory() async -> [ProductCategory: Int] { + var counts: [ProductCategory: Int] = [:] + for category in ProductCategory.allCases { + counts[category] = products.filter { $0.category == category }.count + } + return counts + } +} + +// MARK: - 에러 타입 + +enum ProductServiceError: LocalizedError { + case notFound + case networkError + case invalidResponse + + var errorDescription: String? { + switch self { + case .notFound: + return "상품을 찾을 수 없습니다." + case .networkError: + return "네트워크 오류가 발생했습니다." + case .invalidResponse: + return "잘못된 응답입니다." + } + } +} diff --git a/practice/HIGPractice/HIGPractice/Learning/Phase-01-AppFrameworks/Observation/CartFlow/Shared/ShippingMethod.swift b/practice/HIGPractice/HIGPractice/Learning/Phase-01-AppFrameworks/Observation/CartFlow/Shared/ShippingMethod.swift new file mode 100644 index 00000000..44b2f83e --- /dev/null +++ b/practice/HIGPractice/HIGPractice/Learning/Phase-01-AppFrameworks/Observation/CartFlow/Shared/ShippingMethod.swift @@ -0,0 +1,462 @@ +// +// ShippingMethod.swift +// HIGPractice +// +// Created by YuSeongChoi on 3/16/26. +// + +import Foundation +internal import PassKit + +// MARK: - 배송 방법 +/// Apple Pay 결제에서 사용하는 배송 옵션 모델 +/// +/// ## PKShippingMethod 연동 +/// Apple Pay 결제 시트에서 배송 옵션을 표시하기 위해 +/// `PKShippingMethod`로 변환하여 사용합니다. +/// +/// ## 사용 예시 +/// ```swift +/// let express = ShippingMethod.express +/// let pkMethod = express.toPKShippingMethod() +/// ``` + +struct ShippingMethod: Identifiable, Hashable, Sendable { + + // MARK: - 속성 + + /// 고유 식별자 + let id: String + + /// 배송 방법 이름 (예: "일반 배송") + let name: String + + /// 상세 설명 (예: "3-5 영업일 소요") + let detail: String + + /// 배송비 (원화) + let price: Int + + /// 배송 타입 + let type: ShippingType + + /// 예상 배송 기간 (영업일 기준) + let estimatedDeliveryDays: ClosedRange + + /// 무료 배송 최소 금액 (nil이면 무료 배송 미적용) + let freeShippingThreshold: Int? + + /// 지원 지역 (nil이면 전국) + let supportedRegions: [String]? + + /// 활성화 여부 + let isAvailable: Bool + + // MARK: - 초기화 + + init( + id: String = UUID().uuidString, + name: String, + detail: String, + price: Int, + type: ShippingType = .standard, + estimatedDeliveryDays: ClosedRange, + freeShippingThreshold: Int? = nil, + supportedRegions: [String]? = nil, + isAvailable: Bool = true + ) { + self.id = id + self.name = name + self.detail = detail + self.price = price + self.type = type + self.estimatedDeliveryDays = estimatedDeliveryDays + self.freeShippingThreshold = freeShippingThreshold + self.supportedRegions = supportedRegions + self.isAvailable = isAvailable + } + + // MARK: - 배송 타입 + + enum ShippingType: String, CaseIterable, Sendable { + /// 일반 배송 (3-5일) + case standard = "standard" + /// 빠른 배송 (1-2일) + case express = "express" + /// 당일 배송 + case sameDay = "sameDay" + /// 새벽 배송 + case dawn = "dawn" + /// 방문 수령 + case pickup = "pickup" + /// 편의점 수령 + case convenienceStore = "convenienceStore" + + /// 표시 이름 + var displayName: String { + switch self { + case .standard: return "일반 배송" + case .express: return "빠른 배송" + case .sameDay: return "당일 배송" + case .dawn: return "새벽 배송" + case .pickup: return "방문 수령" + case .convenienceStore: return "편의점 수령" + } + } + + /// SF Symbol 아이콘 + var symbol: String { + switch self { + case .standard: return "shippingbox" + case .express: return "hare" + case .sameDay: return "clock.badge.checkmark" + case .dawn: return "sunrise" + case .pickup: return "storefront" + case .convenienceStore: return "building.2" + } + } + + /// PKShippingType 변환 + var pkShippingType: PKShippingType { + switch self { + case .standard, .express, .dawn: + return .shipping + case .sameDay: + return .shipping + case .pickup, .convenienceStore: + return .storePickup + } + } + } +} + +// MARK: - 가격 계산 + +extension ShippingMethod { + + /// 주문 금액에 따른 실제 배송비 계산 + /// - Parameter orderAmount: 주문 총액 + /// - Returns: 실제 적용될 배송비 + func calculatePrice(for orderAmount: Int) -> Int { + // 무료 배송 조건 확인 + if let threshold = freeShippingThreshold, orderAmount >= threshold { + return 0 + } + return price + } + + /// 무료 배송까지 남은 금액 + /// - Parameter orderAmount: 현재 주문 총액 + /// - Returns: 무료 배송까지 필요한 추가 금액 (이미 무료면 nil) + func remainingForFreeShipping(orderAmount: Int) -> Int? { + guard let threshold = freeShippingThreshold else { return nil } + let remaining = threshold - orderAmount + return remaining > 0 ? remaining : nil + } + + /// 포맷팅된 배송비 + var formattedPrice: String { + if price == 0 { + return "무료" + } + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + let formatted = formatter.string(from: NSNumber(value: price)) ?? "\(price)" + return "₩\(formatted)" + } + + /// 주문 금액 기준 포맷팅된 배송비 + func formattedPrice(for orderAmount: Int) -> String { + let actualPrice = calculatePrice(for: orderAmount) + if actualPrice == 0 { + return "무료" + } + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + let formatted = formatter.string(from: NSNumber(value: actualPrice)) ?? "\(actualPrice)" + return "₩\(formatted)" + } +} + +// MARK: - 배송 기간 + +extension ShippingMethod { + + /// 예상 배송일 범위 문자열 + var estimatedDeliveryDescription: String { + let min = estimatedDeliveryDays.lowerBound + let max = estimatedDeliveryDays.upperBound + + if min == max { + if min == 0 { + return "오늘 도착" + } else if min == 1 { + return "내일 도착" + } + return "\(min)일 후 도착" + } + return "\(min)-\(max) 영업일" + } + + /// 예상 배송일 계산 + /// - Parameter from: 기준일 (기본값: 오늘) + /// - Returns: 예상 배송일 범위 + func estimatedDeliveryDate(from date: Date = Date()) -> (earliest: Date, latest: Date) { + let calendar = Calendar.current + let earliest = calendar.date( + byAdding: .day, + value: estimatedDeliveryDays.lowerBound, + to: date + ) ?? date + let latest = calendar.date( + byAdding: .day, + value: estimatedDeliveryDays.upperBound, + to: date + ) ?? date + return (earliest, latest) + } + + /// 포맷팅된 예상 배송일 + func formattedDeliveryDate(from date: Date = Date()) -> String { + let (earliest, latest) = estimatedDeliveryDate(from: date) + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "ko_KR") + formatter.dateFormat = "M월 d일 (E)" + + if earliest == latest { + return formatter.string(from: earliest) + } + + let shortFormatter = DateFormatter() + shortFormatter.locale = Locale(identifier: "ko_KR") + shortFormatter.dateFormat = "M/d" + + return "\(shortFormatter.string(from: earliest)) ~ \(formatter.string(from: latest))" + } +} + +// MARK: - PKShippingMethod 변환 + +extension ShippingMethod { + + /// PKShippingMethod로 변환 + /// - Parameter orderAmount: 주문 금액 (무료 배송 계산용) + /// - Returns: Apple Pay 배송 옵션 + func toPKShippingMethod(for orderAmount: Int = 0) -> PKShippingMethod { + let method = PKShippingMethod( + label: name, + amount: NSDecimalNumber(value: calculatePrice(for: orderAmount)) + ) + method.identifier = id + method.detail = detail + + // iOS 15+: 예상 배송일 표시 + let (earliest, latest) = estimatedDeliveryDate() + method.dateComponentsRange = PKDateComponentsRange( + start: Calendar.current.dateComponents([.year, .month, .day], from: earliest), + end: Calendar.current.dateComponents([.year, .month, .day], from: latest) + ) + + return method + } + + /// PKShippingMethod 배열로 변환 + /// - Parameters: + /// - methods: 변환할 배송 방법 배열 + /// - orderAmount: 주문 금액 + /// - Returns: PKShippingMethod 배열 + static func toPKShippingMethods( + _ methods: [ShippingMethod], + for orderAmount: Int = 0 + ) -> [PKShippingMethod] { + methods + .filter { $0.isAvailable } + .map { $0.toPKShippingMethod(for: orderAmount) } + } +} + +// MARK: - 지역 지원 확인 + +extension ShippingMethod { + + /// 특정 주소에서 사용 가능한지 확인 + /// - Parameter postalCode: 우편번호 + /// - Returns: 사용 가능 여부 + func isAvailable(for postalCode: String) -> Bool { + guard isAvailable else { return false } + guard let regions = supportedRegions else { return true } + + // 우편번호 앞 2자리로 지역 확인 (한국 기준) + let prefix = String(postalCode.prefix(2)) + return regions.contains(prefix) + } + + /// 특정 지역에서 사용 가능한 배송 방법 필터링 + /// - Parameters: + /// - methods: 배송 방법 배열 + /// - postalCode: 우편번호 + /// - Returns: 사용 가능한 배송 방법 배열 + static func availableMethods( + from methods: [ShippingMethod], + for postalCode: String + ) -> [ShippingMethod] { + methods.filter { $0.isAvailable(for: postalCode) } + } +} + +// MARK: - 미리 정의된 배송 옵션 + +extension ShippingMethod { + + /// 일반 배송 (무료, 3-5일) + static let standard = ShippingMethod( + id: "standard", + name: "일반 배송", + detail: "3-5 영업일 소요", + price: 0, + type: .standard, + estimatedDeliveryDays: 3...5, + freeShippingThreshold: nil + ) + + /// 일반 배송 (유료) + static let standardPaid = ShippingMethod( + id: "standard_paid", + name: "일반 배송", + detail: "3-5 영업일 소요 · 5만원 이상 무료", + price: 3000, + type: .standard, + estimatedDeliveryDays: 3...5, + freeShippingThreshold: 50000 + ) + + /// 빠른 배송 + static let express = ShippingMethod( + id: "express", + name: "빠른 배송", + detail: "1-2 영업일 소요", + price: 5000, + type: .express, + estimatedDeliveryDays: 1...2 + ) + + /// 당일 배송 (수도권) + static let sameDay = ShippingMethod( + id: "same_day", + name: "당일 배송", + detail: "오후 2시 이전 주문 시 당일 도착", + price: 8000, + type: .sameDay, + estimatedDeliveryDays: 0...0, + supportedRegions: ["01", "02", "03", "04", "05", "06", "07", "08", "09", "10", "11", "12", "13", "14", "15", "16", "17", "18"] + ) + + /// 새벽 배송 (수도권) + static let dawn = ShippingMethod( + id: "dawn", + name: "새벽 배송", + detail: "밤 12시 이전 주문 시 새벽 7시 전 도착", + price: 4000, + type: .dawn, + estimatedDeliveryDays: 1...1, + supportedRegions: ["01", "02", "03", "04", "05", "06", "07", "08", "09", "10", "11", "12", "13", "14", "15"] + ) + + /// 매장 수령 + static let storePickup = ShippingMethod( + id: "pickup", + name: "매장 방문 수령", + detail: "가까운 매장에서 직접 수령", + price: 0, + type: .pickup, + estimatedDeliveryDays: 1...2 + ) + + /// 편의점 수령 + static let convenienceStore = ShippingMethod( + id: "cvs_pickup", + name: "편의점 수령", + detail: "CU, GS25, 세븐일레븐에서 수령", + price: 2000, + type: .convenienceStore, + estimatedDeliveryDays: 2...3 + ) + + /// 기본 배송 옵션 세트 + static let defaultMethods: [ShippingMethod] = [ + .standardPaid, + .express, + .sameDay, + .dawn + ] + + /// 전체 배송 옵션 세트 + static let allMethods: [ShippingMethod] = [ + .standardPaid, + .express, + .sameDay, + .dawn, + .storePickup, + .convenienceStore + ] +} + +// MARK: - Codable + +extension ShippingMethod: Codable { + enum CodingKeys: String, CodingKey { + case id, name, detail, price, type + case estimatedDeliveryDaysMin = "estimated_delivery_days_min" + case estimatedDeliveryDaysMax = "estimated_delivery_days_max" + case freeShippingThreshold = "free_shipping_threshold" + case supportedRegions = "supported_regions" + case isAvailable = "is_available" + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decode(String.self, forKey: .id) + name = try container.decode(String.self, forKey: .name) + detail = try container.decode(String.self, forKey: .detail) + price = try container.decode(Int.self, forKey: .price) + type = try container.decode(ShippingType.self, forKey: .type) + let min = try container.decode(Int.self, forKey: .estimatedDeliveryDaysMin) + let max = try container.decode(Int.self, forKey: .estimatedDeliveryDaysMax) + estimatedDeliveryDays = min...max + freeShippingThreshold = try container.decodeIfPresent(Int.self, forKey: .freeShippingThreshold) + supportedRegions = try container.decodeIfPresent([String].self, forKey: .supportedRegions) + isAvailable = try container.decodeIfPresent(Bool.self, forKey: .isAvailable) ?? true + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(id, forKey: .id) + try container.encode(name, forKey: .name) + try container.encode(detail, forKey: .detail) + try container.encode(price, forKey: .price) + try container.encode(type, forKey: .type) + try container.encode(estimatedDeliveryDays.lowerBound, forKey: .estimatedDeliveryDaysMin) + try container.encode(estimatedDeliveryDays.upperBound, forKey: .estimatedDeliveryDaysMax) + try container.encodeIfPresent(freeShippingThreshold, forKey: .freeShippingThreshold) + try container.encodeIfPresent(supportedRegions, forKey: .supportedRegions) + try container.encode(isAvailable, forKey: .isAvailable) + } +} + +extension ShippingMethod.ShippingType: Codable {} + +// MARK: - CustomDebugStringConvertible + +extension ShippingMethod: CustomDebugStringConvertible { + var debugDescription: String { + """ + ShippingMethod( + id: \(id), + name: \(name), + price: \(formattedPrice), + type: \(type.displayName), + delivery: \(estimatedDeliveryDescription) + ) + """ + } +} diff --git a/practice/HIGPractice/HIGPractice/Learning/Phase-01-AppFrameworks/Observation/CartFlow/Views/ApplePayButton.swift b/practice/HIGPractice/HIGPractice/Learning/Phase-01-AppFrameworks/Observation/CartFlow/Views/ApplePayButton.swift new file mode 100644 index 00000000..bebc81a1 --- /dev/null +++ b/practice/HIGPractice/HIGPractice/Learning/Phase-01-AppFrameworks/Observation/CartFlow/Views/ApplePayButton.swift @@ -0,0 +1,506 @@ +// +// ApplePayButton.swift +// HIGPractice +// +// Created by YuSeongChoi on 3/16/26. +// + +import SwiftUI +internal import PassKit + +// MARK: - Apple Pay 버튼 +/// SwiftUI에서 사용하는 Apple Pay 버튼 래퍼 +/// +/// ## 개요 +/// `PKPaymentButton`을 SwiftUI에서 사용할 수 있도록 래핑한 컴포넌트입니다. +/// 다양한 버튼 스타일과 타입을 지원하며, 접근성이 완벽하게 구현되어 있습니다. +/// +/// ## 사용 예시 +/// ```swift +/// // 기본 사용 +/// ApplePayButton { +/// await processPayment() +/// } +/// +/// // 스타일 커스터마이징 +/// ApplePayButton(type: .checkout, style: .whiteOutline) { +/// await processPayment() +/// } +/// +/// // 비활성화 상태 +/// ApplePayButton { +/// await processPayment() +/// } +/// .disabled(cart.isEmpty) +/// ``` +/// +/// ## 버튼 타입 +/// - `.buy`: 구입 (Buy with Apple Pay) +/// - `.setUp`: 설정 (Set up Apple Pay) +/// - `.checkout`: 결제 (Check out with Apple Pay) +/// - `.donate`: 기부 (Donate with Apple Pay) +/// - `.subscribe`: 구독 (Subscribe with Apple Pay) +/// - `.plain`: 로고만 표시 + +struct ApplePayButton: View { + + // MARK: - 속성 + + /// 버튼 타입 + let type: PKPaymentButtonType + + /// 버튼 스타일 + let style: PKPaymentButtonStyle + + /// 버튼 높이 + let height: CGFloat + + /// 모서리 곡률 + let cornerRadius: CGFloat + + /// 탭 액션 + let action: () async -> Void + + /// 비활성화 상태 + @Environment(\.isEnabled) private var isEnabled + + // MARK: - 상태 + + /// 로딩 중 여부 + @State private var isLoading = false + + // MARK: - 초기화 + + /// Apple Pay 버튼 생성 + /// - Parameters: + /// - type: 버튼 타입 (기본: .checkout) + /// - style: 버튼 스타일 (기본: .black) + /// - height: 버튼 높이 (기본: 50) + /// - cornerRadius: 모서리 곡률 (기본: 8) + /// - action: 버튼 탭 시 실행할 비동기 액션 + init( + type: PKPaymentButtonType = .checkout, + style: PKPaymentButtonStyle = .black, + height: CGFloat = 50, + cornerRadius: CGFloat = 8, + action: @escaping () async -> Void + ) { + self.type = type + self.style = style + self.height = height + self.cornerRadius = cornerRadius + self.action = action + } + + // MARK: - Body + + var body: some View { + Button { + guard !isLoading else { return } + + Task { + isLoading = true + await action() + isLoading = false + } + } label: { + ZStack { + // PKPaymentButton 래퍼 + PaymentButtonRepresentable( + type: type, + style: style, + cornerRadius: cornerRadius + ) + .frame(height: height) + + // 로딩 오버레이 + if isLoading { + RoundedRectangle(cornerRadius: cornerRadius) + .fill(.black.opacity(0.5)) + + ProgressView() + .progressViewStyle(.circular) + .tint(.white) + } + } + } + .buttonStyle(.plain) + .disabled(!isEnabled || isLoading) + .opacity(isEnabled ? 1.0 : 0.5) + .accessibilityLabel(accessibilityLabelText) + .accessibilityHint(accessibilityHintText) + .accessibilityAddTraits(.isButton) + .accessibilityRemoveTraits(isEnabled ? [] : .isButton) + .accessibilityValue(isLoading ? "처리 중" : "") + } + + // MARK: - 접근성 + + private var accessibilityLabelText: String { + switch type { + case .plain: + return "Apple Pay" + case .buy: + return "Apple Pay로 구입" + case .setUp: + return "Apple Pay 설정" + case .inStore: + return "매장에서 Apple Pay 사용" + case .donate: + return "Apple Pay로 기부" + case .checkout: + return "Apple Pay로 결제" + case .book: + return "Apple Pay로 예약" + case .subscribe: + return "Apple Pay로 구독" + case .reload: + return "Apple Pay로 충전" + case .addMoney: + return "Apple Pay로 금액 추가" + case .topUp: + return "Apple Pay로 충전" + case .order: + return "Apple Pay로 주문" + case .rent: + return "Apple Pay로 대여" + case .support: + return "Apple Pay로 후원" + case .contribute: + return "Apple Pay로 기여" + case .tip: + return "Apple Pay로 팁 주기" + case .continue: + return "Apple Pay로 계속" + @unknown default: + return "Apple Pay" + } + } + + private var accessibilityHintText: String { + if !isEnabled { + return "현재 사용할 수 없습니다" + } + if isLoading { + return "결제 처리 중입니다" + } + return "두 번 탭하여 결제를 시작합니다" + } +} + +// MARK: - PKPaymentButton UIViewRepresentable + +/// UIKit의 PKPaymentButton을 SwiftUI에서 사용하기 위한 래퍼 +private struct PaymentButtonRepresentable: UIViewRepresentable { + let type: PKPaymentButtonType + let style: PKPaymentButtonStyle + let cornerRadius: CGFloat + + func makeUIView(context: Context) -> PKPaymentButton { + let button = PKPaymentButton(paymentButtonType: type, paymentButtonStyle: style) + button.cornerRadius = cornerRadius + // 버튼의 기본 터치 이벤트 비활성화 (SwiftUI Button이 처리) + button.isUserInteractionEnabled = false + return button + } + + func updateUIView(_ uiView: PKPaymentButton, context: Context) { + // 코너 라운드 업데이트 + uiView.cornerRadius = cornerRadius + } +} + +// MARK: - Apple Pay 설정 버튼 + +/// Apple Pay 설정 안내 버튼 +/// 카드가 등록되지 않은 경우 표시 +struct ApplePaySetupButton: View { + + /// 버튼 높이 + let height: CGFloat + + /// 설정 액션 + let action: () -> Void + + @Environment(\.isEnabled) private var isEnabled + + init( + height: CGFloat = 50, + action: @escaping () -> Void + ) { + self.height = height + self.action = action + } + + var body: some View { + Button(action: action) { + PaymentButtonRepresentable( + type: .setUp, + style: .black, + cornerRadius: 8 + ) + .frame(height: height) + } + .buttonStyle(.plain) + .disabled(!isEnabled) + .opacity(isEnabled ? 1.0 : 0.5) + .accessibilityLabel("Apple Pay 설정") + .accessibilityHint("탭하여 Wallet에 카드를 추가합니다") + } +} + +// MARK: - 조건부 Apple Pay 버튼 + +/// Apple Pay 가용성에 따라 자동으로 적절한 버튼 표시 +struct ConditionalApplePayButton: View { + + /// 결제 서비스 + let paymentService: ApplePayService + + /// 버튼 높이 + let height: CGFloat + + /// 결제 액션 + let payAction: () async -> Void + + /// 설정 액션 + let setupAction: () -> Void + + init( + paymentService: ApplePayService, + height: CGFloat = 50, + payAction: @escaping () async -> Void, + setupAction: @escaping () -> Void + ) { + self.paymentService = paymentService + self.height = height + self.payAction = payAction + self.setupAction = setupAction + } + + var body: some View { + switch paymentService.paymentAvailability { + case .available: + ApplePayButton( + type: .checkout, + style: .black, + height: height, + action: payAction + ) + + case .needsSetup: + ApplePaySetupButton( + height: height, + action: setupAction + ) + + case .notSupported: + UnavailablePaymentButton( + message: "이 기기에서 Apple Pay를 사용할 수 없습니다", + height: height + ) + } + } +} + +// MARK: - 사용 불가 버튼 + +/// Apple Pay를 사용할 수 없을 때 표시하는 대체 버튼 +struct UnavailablePaymentButton: View { + + let message: String + let height: CGFloat + + var body: some View { + VStack(spacing: 8) { + HStack(spacing: 8) { + Image(systemName: "creditcard.trianglebadge.exclamationmark") + .font(.title3) + + Text("Apple Pay 사용 불가") + .font(.headline) + } + .foregroundStyle(.secondary) + + Text(message) + .font(.caption) + .foregroundStyle(.tertiary) + .multilineTextAlignment(.center) + } + .frame(maxWidth: .infinity) + .frame(height: height + 20) + .background(.fill.tertiary, in: RoundedRectangle(cornerRadius: 12)) + .accessibilityElement(children: .combine) + .accessibilityLabel("Apple Pay 사용 불가: \(message)") + } +} + +// MARK: - 버튼 스타일 프리뷰 + +#Preview("Apple Pay Button Styles") { + VStack(spacing: 20) { + Text("버튼 타입") + .font(.headline) + + ApplePayButton(type: .plain, style: .black) {} + ApplePayButton(type: .buy, style: .black) {} + ApplePayButton(type: .checkout, style: .black) {} + ApplePayButton(type: .subscribe, style: .black) {} + ApplePayButton(type: .donate, style: .black) {} + + Divider() + + Text("버튼 스타일") + .font(.headline) + + ApplePayButton(type: .checkout, style: .black) {} + ApplePayButton(type: .checkout, style: .white) {} + ApplePayButton(type: .checkout, style: .whiteOutline) {} + ApplePayButton(type: .checkout, style: .automatic) {} + + Divider() + + Text("비활성화 상태") + .font(.headline) + + ApplePayButton(type: .checkout, style: .black) {} + .disabled(true) + } + .padding() +} + +#Preview("Setup & Unavailable") { + VStack(spacing: 20) { + ApplePaySetupButton {} + + UnavailablePaymentButton( + message: "이 기기에서는 Apple Pay를 지원하지 않습니다", + height: 50 + ) + } + .padding() +} + +// MARK: - 커스텀 모디파이어 + +extension View { + /// Apple Pay 버튼 스타일 적용 + /// - Parameters: + /// - height: 버튼 높이 + /// - cornerRadius: 모서리 곡률 + func applePayButtonStyle( + height: CGFloat = 50, + cornerRadius: CGFloat = 8 + ) -> some View { + self + .frame(maxWidth: .infinity) + .frame(height: height) + .clipShape(RoundedRectangle(cornerRadius: cornerRadius)) + } +} + +// MARK: - 결제 진행 상태 표시 뷰 + +/// Apple Pay 결제 진행 상태를 시각적으로 표시 +struct PaymentProgressView: View { + + /// 현재 결제 상태 + let state: PaymentState + + var body: some View { + HStack(spacing: 12) { + statusIcon + .font(.title2) + .foregroundStyle(statusColor) + + VStack(alignment: .leading, spacing: 2) { + Text(statusTitle) + .font(.headline) + + Text(statusDescription) + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer() + + if state.isInProgress { + ProgressView() + .progressViewStyle(.circular) + } + } + .padding() + .background(.fill.tertiary, in: RoundedRectangle(cornerRadius: 12)) + .accessibilityElement(children: .combine) + .accessibilityLabel("\(statusTitle): \(statusDescription)") + } + + @ViewBuilder + private var statusIcon: some View { + switch state { + case .idle: + Image(systemName: "creditcard") + case .preparing: + Image(systemName: "gear") + case .authorizing: + Image(systemName: "faceid") + case .processing: + Image(systemName: "arrow.triangle.2.circlepath") + case .completed: + Image(systemName: "checkmark.circle.fill") + case .cancelled: + Image(systemName: "xmark.circle") + case .failed: + Image(systemName: "exclamationmark.triangle.fill") + } + } + + private var statusColor: Color { + switch state { + case .idle: + return .secondary + case .preparing, .authorizing, .processing: + return .blue + case .completed: + return .green + case .cancelled: + return .orange + case .failed: + return .red + } + } + + private var statusTitle: String { + state.rawValue + } + + private var statusDescription: String { + switch state { + case .idle: + return "결제를 시작해주세요" + case .preparing: + return "결제 정보를 준비하고 있습니다" + case .authorizing: + return "Face ID 또는 Touch ID로 인증해주세요" + case .processing: + return "결제를 처리하고 있습니다" + case .completed: + return "결제가 완료되었습니다" + case .cancelled: + return "결제가 취소되었습니다" + case .failed: + return "결제에 실패했습니다" + } + } +} + +#Preview("Payment Progress") { + VStack(spacing: 16) { + PaymentProgressView(state: .idle) + PaymentProgressView(state: .authorizing) + PaymentProgressView(state: .processing) + PaymentProgressView(state: .completed) + PaymentProgressView(state: .failed) + } + .padding() +} diff --git a/practice/HIGPractice/HIGPractice/Learning/Phase-01-AppFrameworks/Observation/CartFlow/Views/CartRootView.swift b/practice/HIGPractice/HIGPractice/Learning/Phase-01-AppFrameworks/Observation/CartFlow/Views/CartRootView.swift new file mode 100644 index 00000000..b1878ae1 --- /dev/null +++ b/practice/HIGPractice/HIGPractice/Learning/Phase-01-AppFrameworks/Observation/CartFlow/Views/CartRootView.swift @@ -0,0 +1,375 @@ +// +// CartRootView.swift +// HIGPractice +// +// Created by YuSeongChoi on 3/17/26. +// + +import SwiftUI +internal import PassKit + +// MARK: - CartFlow 앱 +/// PassKit(Apple Pay) 기능을 보여주는 쇼핑 앱 샘플 +/// +/// ## 개요 +/// CartFlow는 Apple Pay를 활용한 결제 프로세스를 시연하는 +/// HIG Lab 샘플 프로젝트입니다. +/// +/// ## 주요 기능 +/// - 상품 목록 및 상세 +/// - 장바구니 관리 +/// - Apple Pay 결제 +/// - 배송 옵션 선택 +/// - 쿠폰 코드 적용 +/// +/// ## PassKit API 활용 +/// ``` +/// ┌─────────────────────────────────────────────────────────┐ +/// │ CartFlow App │ +/// ├─────────────────────────────────────────────────────────┤ +/// │ │ +/// │ ┌──────────────┐ ┌──────────────┐ ┌────────────┐ │ +/// │ │ ProductList │───▶│ Cart │───▶│ Checkout │ │ +/// │ │ View │ │ View │ │ View │ │ +/// │ └──────────────┘ └──────────────┘ └─────┬──────┘ │ +/// │ │ │ +/// │ ▼ │ +/// │ ┌───────────────────┐│ +/// │ │ Apple Pay Sheet ││ +/// │ │ ┌─────────────┐ ││ +/// │ │ │PKPayment │ ││ +/// │ │ │Authorization│ ││ +/// │ │ │Controller │ ││ +/// │ │ └─────────────┘ ││ +/// │ └───────────────────┘│ +/// │ │ +/// └─────────────────────────────────────────────────────────┘ +/// ``` +/// +/// ## 아키텍처 +/// - **Observation Framework**: iOS 17+ @Observable 매크로 활용 +/// - **Async/Await**: 비동기 결제 처리 +/// - **Sendable**: 동시성 안전한 데이터 모델 + +struct CartRootView: View { + + // MARK: - 상태 + + /// 장바구니 상태 (앱 전역) + @State private var cartStore = CartStore() + + /// 상품 서비스 + private let productService = ProductService.shared + + // MARK: - Scene + + var body: some View { + CartContentView() + .environment(cartStore) + } +} + +// MARK: - 메인 콘텐츠 뷰 + +/// 앱의 메인 콘텐츠를 담당하는 루트 뷰 +struct CartContentView: View { + + // MARK: - 환경 + + @Environment(CartStore.self) private var cartStore + + // MARK: - 상태 + + /// 선택된 탭 + @State private var selectedTab: Tab = .products + + /// 장바구니 배지 애니메이션 + @State private var cartBadgeScale: CGFloat = 1.0 + + // MARK: - 탭 열거형 + + enum Tab: String, CaseIterable { + case products = "상품" + case cart = "장바구니" + case settings = "설정" + + var icon: String { + switch self { + case .products: return "square.grid.2x2" + case .cart: return "cart" + case .settings: return "gearshape" + } + } + + var filledIcon: String { + switch self { + case .products: return "square.grid.2x2.fill" + case .cart: return "cart.fill" + case .settings: return "gearshape.fill" + } + } + } + + // MARK: - Body + + var body: some View { + TabView(selection: $selectedTab) { + // 상품 목록 + NavigationStack { + ProductListView() + } + .tabItem { + Label( + Tab.products.rawValue, + systemImage: selectedTab == .products + ? Tab.products.filledIcon + : Tab.products.icon + ) + } + .tag(Tab.products) + + // 장바구니 + NavigationStack { + CartView() + } + .tabItem { + Label( + Tab.cart.rawValue, + systemImage: selectedTab == .cart + ? Tab.cart.filledIcon + : Tab.cart.icon + ) + } + .tag(Tab.cart) + .badge(cartStore.totalItemCount > 0 ? cartStore.totalItemCount : 0) + + // 설정 + NavigationStack { + SettingsView() + } + .tabItem { + Label( + Tab.settings.rawValue, + systemImage: selectedTab == .settings + ? Tab.settings.filledIcon + : Tab.settings.icon + ) + } + .tag(Tab.settings) + } + .onChange(of: cartStore.totalItemCount) { oldValue, newValue in + // 장바구니 아이템 추가 시 배치 애니메이션 + withAnimation(.spring(response: 0.3, dampingFraction: 0.5)) { + cartBadgeScale = 1.2 + } + withAnimation(.spring(response: 0.3, dampingFraction: 0.5).delay(0.1)) { + cartBadgeScale = 1.0 + } + } + } + +} + +// MARK: - 설정 뷰 + +/// 앱 설정 및 Apple Pay 상태 확인 뷰 +struct SettingsView: View { + + // MARK: - 상태 + + /// Apple Pay 서비스 (상태 확인용) + @State private var paymentService = ApplePayService() + + // MARK: - Body + + var body: some View { + List { + // Apple Pay 섹션 + Section { + // Apple Pay 상태 + applePayStatusRow + + // Apple Pay 설정 버튼 + if paymentService.needsSetup { + Button { + paymentService.presentAddCardSheet() + } label: { + Label("카드 추가", systemImage: "plus.rectangle.on.folder") + } + } + } header: { + Label("Apple Pay", systemImage: "applelogo") + } footer: { + Text("Apple Pay를 사용하면 안전하고 빠르게 결제할 수 있습니다.") + } + + // 지원 카드 네트워크 + Section { + ForEach(PaymentConfiguration.defaultNetworks, id: \.rawValue) { network in + Label(network.rawValue.capitalized, systemImage: "creditcard") + .foregroundStyle(.secondary) + } + } header: { + Text("지원 카드") + } + + // 앱 정보 + Section { + LabeledContent("버전", value: "1.0.0") + LabeledContent("빌드", value: "1") + + Link(destination: URL(string: "https://developer.apple.com/documentation/passkit")!) { + Label("PassKit 문서", systemImage: "doc.text") + } + + Link(destination: URL(string: "https://developer.apple.com/design/human-interface-guidelines/apple-pay")!) { + Label("Apple Pay HIG", systemImage: "book") + } + } header: { + Text("정보") + } + + // 디버그 섹션 +#if DEBUG + Section { + NavigationLink { + PaymentDebugView() + } label: { + Label("결제 디버그", systemImage: "ladybug") + } + } header: { + Text("개발자 도구") + } +#endif + } + .navigationTitle("설정") + } + + // MARK: - Apple Pay 상태 행 + + @ViewBuilder + private var applePayStatusRow: some View { + HStack { + Label("Apple Pay", systemImage: "applelogo") + + Spacer() + + switch paymentService.paymentAvailability { + case .available: + Label("사용 가능", systemImage: "checkmark.circle.fill") + .foregroundStyle(.green) + .labelStyle(.iconOnly) + Text("사용 가능") + .foregroundStyle(.green) + + case .needsSetup: + Label("설정 필요", systemImage: "exclamationmark.circle.fill") + .foregroundStyle(.orange) + .labelStyle(.iconOnly) + Text("설정 필요") + .foregroundStyle(.orange) + + case .notSupported: + Label("지원 안됨", systemImage: "xmark.circle.fill") + .foregroundStyle(.red) + .labelStyle(.iconOnly) + Text("지원 안됨") + .foregroundStyle(.red) + } + } + .accessibilityElement(children: .combine) + .accessibilityLabel("Apple Pay 상태: \(statusText)") + } + + private var statusText: String { + switch paymentService.paymentAvailability { + case .available: return "사용 가능" + case .needsSetup: return "설정 필요" + case .notSupported: return "지원 안됨" + } + } +} + +// MARK: - 결제 디버그 뷰 + +#if DEBUG +struct PaymentDebugView: View { + + @State private var paymentService = ApplePayService() + @State private var testResult: String = "" + + var body: some View { + List { + Section("결제 설정") { + LabeledContent("Merchant ID", value: PaymentConfiguration.default.merchantIdentifier) + LabeledContent("국가 코드", value: PaymentConfiguration.default.countryCode) + LabeledContent("통화 코드", value: PaymentConfiguration.default.currencyCode) + } + + Section("결제 상태") { + LabeledContent("canMakePayments", value: "\(paymentService.canMakePayments)") + LabeledContent("canMakePaymentsWithCards", value: "\(paymentService.canMakePaymentsWithRegisteredCards)") + LabeledContent("needsSetup", value: "\(paymentService.needsSetup)") + LabeledContent("paymentState", value: paymentService.paymentState.rawValue) + } + + Section("테스트") { + Button("테스트 결제 (₩10,000)") { + Task { + do { + let result = try await paymentService.processPayment(amount: 10000) + testResult = "성공: \(result.transactionId)" + } catch { + testResult = "실패: \(error.localizedDescription)" + } + } + } + + if !testResult.isEmpty { + Text(testResult) + .font(.caption) + .foregroundStyle(.secondary) + } + + Button("상태 초기화") { + paymentService.resetPayment() + testResult = "" + } + .foregroundStyle(.red) + } + + Section("에러 코드") { + ForEach([ + PaymentError.applePayNotSupported, + PaymentError.noRegisteredCards, + PaymentError.userCancelled, + PaymentError.timeout + ], id: \.errorCode) { error in + VStack(alignment: .leading, spacing: 4) { + Text("[\(error.errorCode)] \(error.category.rawValue)") + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + Text(error.localizedDescription) + .font(.subheadline) + } + } + } + } + .navigationTitle("결제 디버그") + .navigationBarTitleDisplayMode(.inline) + } +} +#endif + +// MARK: - Preview + +#Preview { + CartContentView() + .environment(CartStore.preview) +} + +#Preview("Settings") { + NavigationStack { + SettingsView() + } +} diff --git a/practice/HIGPractice/HIGPractice/Learning/Phase-01-AppFrameworks/Observation/CartFlow/Views/CartView.swift b/practice/HIGPractice/HIGPractice/Learning/Phase-01-AppFrameworks/Observation/CartFlow/Views/CartView.swift new file mode 100644 index 00000000..69d69a85 --- /dev/null +++ b/practice/HIGPractice/HIGPractice/Learning/Phase-01-AppFrameworks/Observation/CartFlow/Views/CartView.swift @@ -0,0 +1,397 @@ +// +// CartView.swift +// HIGPractice +// +// Created by YuSeongChoi on 3/17/26. +// + +import SwiftUI + +// MARK: - 장바구니 뷰 +/// 장바구니 관리 및 결제 진행 화면 +/// +/// ## 주요 기능 +/// - 담긴 상품 목록 표시 +/// - 수량 조절 (증가/감소) +/// - 상품 삭제 (스와이프, 버튼) +/// - 결제 화면으로 이동 +/// - 장바구니 비우기 +/// +/// ## 접근성 +/// - 모든 액션에 VoiceOver 라벨 제공 +/// - 스와이프 액션 대신 버튼 제공 +/// - 수량 변경 시 피드백 + +struct CartView: View { + + // MARK: - 환경 + + @Environment(CartStore.self) private var cartStore + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + // MARK: - 상태 + + /// 삭제 확인 알림 표시 + @State private var showDeleteConfirmation = false + + /// 삭제 대상 아이템 + @State private var itemToDelete: CartItem? + + /// 결제 화면 표시 + @State private var showCheckout = false + + /// 전체 삭제 확인 알림 표시 + @State private var showClearConfirmation = false + + // MARK: - Body + + var body: some View { + Group { + if cartStore.isEmpty { + emptyCartView + } else { + cartContentView + } + } + .navigationTitle("장바구니") + .toolbar { + if !cartStore.isEmpty { + ToolbarItem(placement: .topBarTrailing) { + clearCartButton + } + } + } + .navigationDestination(isPresented: $showCheckout) { + CheckoutView(cartStore: cartStore) + } + .alert("상품 삭제", isPresented: $showDeleteConfirmation) { + Button("취소", role: .cancel) { + itemToDelete = nil + } + Button("삭제", role: .destructive) { + if let item = itemToDelete { + withAnimation { + cartStore.removeFromCart(item.product) + } + } + itemToDelete = nil + } + } message: { + if let item = itemToDelete { + Text("'\(item.product.name)'을(를) 장바구니에서 삭제하시겠습니까?") + } + } + .alert("장바구니 비우기", isPresented: $showClearConfirmation) { + Button("취소", role: .cancel) {} + Button("비우기", role: .destructive) { + withAnimation { + cartStore.clearCart() + } + } + } message: { + Text("장바구니의 모든 상품(\(cartStore.totalItemCount)개)을 삭제하시겠습니까?") + } + } + + // MARK: - 빈 장바구니 뷰 + + private var emptyCartView: some View { + ContentUnavailableView { + Label("장바구니가 비었습니다", systemImage: "cart") + } description: { + Text("상품을 둘러보고 마음에 드는 상품을 담아보세요.") + } actions: { + NavigationLink(value: "products") { + Text("쇼핑하기") + } + .buttonStyle(.borderedProminent) + } + } + + // MARK: - 장바구니 콘텐츠 + + private var cartContentView: some View { + VStack(spacing: 0) { + // 상품 목록 + List { + // 상품 섹션 + Section { + ForEach(cartStore.items) { item in + CartItemRow( + item: item, + onIncrement: { + withAnimation(reduceMotion ? .none : .easeInOut(duration: 0.2)) { + cartStore.incrementQuantity(for: item) + } + }, + onDecrement: { + if item.quantity == 1 { + itemToDelete = item + showDeleteConfirmation = true + } else { + withAnimation(reduceMotion ? .none : .easeInOut(duration: 0.2)) { + cartStore.decrementQuantity(for: item) + } + } + }, + onDelete: { + itemToDelete = item + showDeleteConfirmation = true + } + ) + .swipeActions(edge: .trailing, allowsFullSwipe: true) { + Button(role: .destructive) { + itemToDelete = item + showDeleteConfirmation = true + } label: { + Label("삭제", systemImage: "trash") + } + } + } + } header: { + Text("담긴 상품 (\(cartStore.totalItemCount))") + } + + // 배송 안내 섹션 + Section { + freeShippingProgress + } header: { + Text("배송") + } + } + .listStyle(.insetGrouped) + + // 하단 결제 영역 + checkoutBar + } + } + + // MARK: - 무료 배송 진행률 + + private var freeShippingProgress: some View { + VStack(alignment: .leading, spacing: 8) { + let threshold = 50000 + let progress = min(Double(cartStore.totalPrice) / Double(threshold), 1.0) + let remaining = max(0, threshold - cartStore.totalPrice) + + HStack { + Image(systemName: "shippingbox") + .foregroundStyle(.secondary) + + if remaining > 0 { + Text("₩\(remaining.formatted()) 더 담으면 무료 배송!") + .font(.subheadline) + } else { + Text("무료 배송 적용 가능!") + .font(.subheadline) + .foregroundStyle(.green) + } + } + + ProgressView(value: progress) + .tint(progress >= 1.0 ? .green : .accentColor) + .accessibilityLabel("무료 배송까지 진행률 \(Int(progress * 100))%") + } + } + + // MARK: - 결제 바 + + private var checkoutBar: some View { + VStack(spacing: 12) { + // 금액 정보 + HStack { + Text("총 결제 예정 금액") + .font(.subheadline) + .foregroundStyle(.secondary) + + Spacer() + + Text(cartStore.formattedTotalPrice) + .font(.title2.bold()) + } + + // 결제 버튼 + Button { + showCheckout = true + } label: { + HStack { + Image(systemName: "creditcard") + Text("결제하기") + } + .font(.headline) + .frame(maxWidth: .infinity) + .padding(.vertical, 16) + .background(Color.accentColor, in: RoundedRectangle(cornerRadius: 12)) + .foregroundStyle(.white) + } + .buttonStyle(.plain) + .accessibilityLabel("결제하기, 총 금액 \(cartStore.formattedTotalPrice)") + } + .padding() + .background(.bar) + } + + // MARK: - 장바구니 비우기 버튼 + + private var clearCartButton: some View { + Button { + showClearConfirmation = true + } label: { + Image(systemName: "trash") + } + .accessibilityLabel("장바구니 비우기") + .accessibilityHint("\(cartStore.totalItemCount)개의 상품을 모두 삭제합니다") + } +} + +// MARK: - 장바구니 아이템 행 + +struct CartItemRow: View { + let item: CartItem + let onIncrement: () -> Void + let onDecrement: () -> Void + let onDelete: () -> Void + + var body: some View { + HStack(spacing: 12) { + // 상품 이미지 + RoundedRectangle(cornerRadius: 8) + .fill(.fill.tertiary) + .frame(width: 60, height: 60) + .overlay { + Image(systemName: item.product.category.symbol) + .foregroundStyle(.secondary) + } + + // 상품 정보 + VStack(alignment: .leading, spacing: 4) { + Text(item.product.name) + .font(.subheadline.weight(.medium)) + .lineLimit(1) + + Text(item.product.formattedPrice) + .font(.caption) + .foregroundStyle(.secondary) + + Text(item.formattedTotalPrice) + .font(.subheadline.weight(.semibold)) + } + + Spacer() + + // 수량 조절 + QuantityStepper( + quantity: item.quantity, + onIncrement: onIncrement, + onDecrement: onDecrement + ) + } + .accessibilityElement(children: .combine) + .accessibilityLabel("\(item.product.name), 수량 \(item.quantity), \(item.formattedTotalPrice)") + .accessibilityActions { + Button("수량 추가", action: onIncrement) + Button("수량 감소", action: onDecrement) + Button("삭제", action: onDelete) + } + } +} + +// MARK: - 수량 스테퍼 + +struct QuantityStepper: View { + let quantity: Int + let onIncrement: () -> Void + let onDecrement: () -> Void + + /// 최대 수량 + private let maxQuantity = 99 + + var body: some View { + HStack(spacing: 0) { + // 감소 버튼 + Button(action: onDecrement) { + Image(systemName: quantity == 1 ? "trash" : "minus") + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(quantity == 1 ? .red : .primary) + .frame(width: 32, height: 32) + } + .buttonStyle(.plain) + .accessibilityLabel(quantity == 1 ? "삭제" : "수량 감소") + + // 수량 표시 + Text("\(quantity)") + .font(.subheadline.weight(.medium)) + .frame(width: 32) + .accessibilityHidden(true) + + // 증가 버튼 + Button(action: onIncrement) { + Image(systemName: "plus") + .font(.system(size: 14, weight: .medium)) + .frame(width: 32, height: 32) + } + .buttonStyle(.plain) + .disabled(quantity >= maxQuantity) + .accessibilityLabel("수량 추가") + } + .background(.fill.secondary, in: RoundedRectangle(cornerRadius: 8)) + .accessibilityElement(children: .contain) + .accessibilityLabel("수량 \(quantity)") + } +} + +// MARK: - 장바구니 요약 뷰 (다른 화면에서 사용) + +/// 장바구니 요약 정보를 보여주는 컴팩트 뷰 +struct CartSummaryView: View { + @Environment(CartStore.self) private var cartStore + + var body: some View { + HStack { + Image(systemName: "cart.fill") + .foregroundStyle(Color.accentColor) + + VStack(alignment: .leading, spacing: 2) { + Text("장바구니") + .font(.caption) + .foregroundStyle(.secondary) + + Text("\(cartStore.totalItemCount)개 · \(cartStore.formattedTotalPrice)") + .font(.subheadline.weight(.medium)) + } + } + .accessibilityElement(children: .combine) + .accessibilityLabel("장바구니: \(cartStore.totalItemCount)개, \(cartStore.formattedTotalPrice)") + } +} + +// MARK: - Preview + +#Preview("With Items") { + NavigationStack { + CartView() + } + .environment(CartStore.preview) +} + +#Preview("Empty") { + NavigationStack { + CartView() + } + .environment(CartStore()) +} + +#Preview("Cart Summary") { + CartSummaryView() + .padding() + .environment(CartStore.preview) +} + +#Preview("Quantity Stepper") { + VStack(spacing: 20) { + QuantityStepper(quantity: 1, onIncrement: {}, onDecrement: {}) + QuantityStepper(quantity: 5, onIncrement: {}, onDecrement: {}) + QuantityStepper(quantity: 99, onIncrement: {}, onDecrement: {}) + } + .padding() +} diff --git a/practice/HIGPractice/HIGPractice/Learning/Phase-01-AppFrameworks/Observation/CartFlow/Views/CheckoutView.swift b/practice/HIGPractice/HIGPractice/Learning/Phase-01-AppFrameworks/Observation/CartFlow/Views/CheckoutView.swift new file mode 100644 index 00000000..d3f79f29 --- /dev/null +++ b/practice/HIGPractice/HIGPractice/Learning/Phase-01-AppFrameworks/Observation/CartFlow/Views/CheckoutView.swift @@ -0,0 +1,588 @@ +// +// CheckoutView.swift +// HIGPractice +// +// Created by YuSeongChoi on 3/17/26. +// + +import SwiftUI +internal import PassKit + +// MARK: - 결제 화면 +/// 장바구니에서 결제로 진행하는 체크아웃 화면 +/// +/// ## 주요 기능 +/// - 주문 요약 표시 +/// - 배송 방법 선택 +/// - 쿠폰 코드 입력 +/// - Apple Pay 결제 버튼 +/// - 결제 결과 표시 +/// +/// ## 사용 예시 +/// ```swift +/// CheckoutView(cartStore: cartStore) +/// ``` + +struct CheckoutView: View { + + // MARK: - 의존성 + + /// 장바구니 상태 + @Bindable var cartStore: CartStore + + /// Apple Pay 서비스 + @State private var paymentService = ApplePayService() + + // MARK: - 상태 + + /// 선택된 배송 방법 + @State private var selectedShipping: ShippingMethod = .standardPaid + + /// 쿠폰 코드 입력 + @State private var couponCode = "" + + /// 적용된 쿠폰 할인 금액 + @State private var couponDiscount: Int = 0 + + /// 쿠폰 적용 중 여부 + @State private var isApplyingCoupon = false + + /// 쿠폰 에러 메시지 + @State private var couponError: String? + + /// 결제 결과 표시 여부 + @State private var showPaymentResult = false + + /// 결제 결과 + @State private var paymentResult: PaymentResult? + + /// 에러 알림 표시 여부 + @State private var showError = false + + /// 에러 메시지 + @State private var errorMessage = "" + + /// 주문 요약 섹션 접힘 상태 + @State private var isOrderSummaryExpanded = true + + // MARK: - 계산 속성 + + /// 상품 소계 + private var subtotal: Int { + cartStore.totalPrice + } + + /// 배송비 + private var shippingCost: Int { + selectedShipping.calculatePrice(for: subtotal) + } + + /// 최종 결제 금액 + private var totalAmount: Int { + max(0, subtotal + shippingCost - couponDiscount) + } + + /// 포맷팅된 총액 + private var formattedTotal: String { + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + let formatted = formatter.string(from: NSNumber(value: totalAmount)) ?? "\(totalAmount)" + return "₩\(formatted)" + } + + // MARK: - Body + + var body: some View { + ScrollView { + VStack(spacing: 24) { + // 주문 요약 + orderSummarySection + + // 배송 방법 선택 + shippingMethodSection + + // 쿠폰 코드 + couponSection + + // 금액 상세 + priceBreakdownSection + + Divider() + + // Apple Pay 버튼 + paymentSection + + // 결제 안내 + paymentInfoSection + } + .padding() + } + .navigationTitle("결제") + .navigationBarTitleDisplayMode(.inline) + .alert("결제 오류", isPresented: $showError) { + Button("확인", role: .cancel) {} + + if paymentService.lastError?.isRetryable == true { + Button("다시 시도") { + Task { await processPayment() } + } + } + } message: { + Text(errorMessage) + } + .sheet(isPresented: $showPaymentResult) { + if let result = paymentResult { + PaymentResultView(result: result) { + cartStore.clearCart() + showPaymentResult = false + } + } + } + } + + // MARK: - 주문 요약 섹션 + + private var orderSummarySection: some View { + VStack(alignment: .leading, spacing: 12) { + Button { + withAnimation(.easeInOut(duration: 0.2)) { + isOrderSummaryExpanded.toggle() + } + } label: { + HStack { + Label("주문 상품", systemImage: "bag") + .font(.headline) + + Spacer() + + Text("\(cartStore.totalItemCount)개") + .foregroundStyle(.secondary) + + Image(systemName: "chevron.right") + .font(.caption.weight(.semibold)) + .foregroundStyle(.tertiary) + .rotationEffect(.degrees(isOrderSummaryExpanded ? 90 : 0)) + } + } + .buttonStyle(.plain) + .accessibilityLabel("주문 상품 \(cartStore.totalItemCount)개") + .accessibilityHint("탭하여 \(isOrderSummaryExpanded ? "접기" : "펼치기")") + + if isOrderSummaryExpanded { + ForEach(cartStore.items) { item in + OrderItemRow(item: item) + } + .transition(.opacity.combined(with: .move(edge: .top))) + } + } + .padding() + .background(.fill.tertiary, in: RoundedRectangle(cornerRadius: 16)) + } + + // MARK: - 배송 방법 섹션 + + private var shippingMethodSection: some View { + VStack(alignment: .leading, spacing: 12) { + Label("배송 방법", systemImage: "shippingbox") + .font(.headline) + + ForEach(ShippingMethod.defaultMethods, id: \.id) { method in + ShippingMethodRow( + method: method, + isSelected: selectedShipping.id == method.id, + orderAmount: subtotal + ) { + withAnimation(.easeInOut(duration: 0.2)) { + selectedShipping = method + } + } + } + } + .padding() + .background(.fill.tertiary, in: RoundedRectangle(cornerRadius: 16)) + } + + // MARK: - 쿠폰 섹션 + + private var couponSection: some View { + VStack(alignment: .leading, spacing: 12) { + Label("쿠폰 코드", systemImage: "ticket") + .font(.headline) + + HStack(spacing: 12) { + TextField("쿠폰 코드 입력", text: $couponCode) + .textFieldStyle(.roundedBorder) + .textInputAutocapitalization(.characters) + .autocorrectionDisabled() + .submitLabel(.done) + .onSubmit { + Task { await applyCoupon() } + } + .accessibilityLabel("쿠폰 코드 입력") + .accessibilityHint("쿠폰 코드를 입력하고 적용 버튼을 눌러주세요") + + Button { + Task { await applyCoupon() } + } label: { + if isApplyingCoupon { + ProgressView() + .controlSize(.small) + } else { + Text("적용") + } + } + .buttonStyle(.borderedProminent) + .disabled(couponCode.isEmpty || isApplyingCoupon) + } + + // 쿠폰 적용 결과 + if couponDiscount > 0 { + HStack { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.green) + + Text("쿠폰 적용됨: -₩\(couponDiscount.formatted())") + .font(.subheadline) + .foregroundStyle(.green) + + Spacer() + + Button { + couponCode = "" + couponDiscount = 0 + } label: { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + .accessibilityLabel("쿠폰 제거") + } + } + + // 쿠폰 에러 + if let error = couponError { + HStack { + Image(systemName: "exclamationmark.circle.fill") + .foregroundStyle(.red) + + Text(error) + .font(.subheadline) + .foregroundStyle(.red) + } + } + + // 쿠폰 힌트 + Text("테스트 쿠폰: SAVE10, SAVE20, WELCOME, FREESHIP") + .font(.caption) + .foregroundStyle(.secondary) + } + .padding() + .background(.fill.tertiary, in: RoundedRectangle(cornerRadius: 16)) + } + + // MARK: - 금액 상세 섹션 + + private var priceBreakdownSection: some View { + VStack(spacing: 12) { + // 상품 소계 + PriceRow(label: "상품 금액", amount: subtotal) + + // 배송비 + PriceRow( + label: selectedShipping.name, + amount: shippingCost, + detail: shippingCost == 0 ? "무료" : nil + ) + + // 쿠폰 할인 + if couponDiscount > 0 { + PriceRow( + label: "쿠폰 할인", + amount: -couponDiscount, + isDiscount: true + ) + } + + Divider() + + // 총액 + HStack { + Text("총 결제 금액") + .font(.headline) + + Spacer() + + Text(formattedTotal) + .font(.title2.bold()) + .foregroundStyle(.primary) + } + .accessibilityElement(children: .combine) + .accessibilityLabel("총 결제 금액 \(formattedTotal)") + } + .padding() + .background(.fill.tertiary, in: RoundedRectangle(cornerRadius: 16)) + } + + // MARK: - 결제 섹션 + + private var paymentSection: some View { + VStack(spacing: 16) { + // 결제 진행 상태 (처리 중일 때만) + if paymentService.paymentState.isInProgress { + PaymentProgressView(state: paymentService.paymentState) + } + + // Apple Pay 버튼 + ConditionalApplePayButton( + paymentService: paymentService, + height: 50, + payAction: { + await processPayment() + }, + setupAction: { + paymentService.presentAddCardSheet() + } + ) + .disabled(cartStore.isEmpty || paymentService.paymentState.isInProgress) + } + } + + // MARK: - 결제 안내 섹션 + + private var paymentInfoSection: some View { + VStack(alignment: .leading, spacing: 8) { + Label("결제 안내", systemImage: "info.circle") + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.secondary) + + Text("• Apple Pay로 안전하게 결제할 수 있습니다.") + Text("• Face ID 또는 Touch ID로 인증합니다.") + Text("• 실제 카드 정보는 판매자에게 공유되지 않습니다.") + } + .font(.caption) + .foregroundStyle(.tertiary) + .frame(maxWidth: .infinity, alignment: .leading) + } + + // MARK: - 액션 + + /// 쿠폰 적용 + private func applyCoupon() async { + guard !couponCode.isEmpty else { return } + + isApplyingCoupon = true + couponError = nil + + // 쿠폰 검증 (Mock) + try? await Task.sleep(for: .milliseconds(500)) + + let normalizedCode = couponCode.uppercased().trimmingCharacters(in: .whitespaces) + + let validCoupons: [String: Int] = [ + "SAVE10": 10000, + "SAVE20": 20000, + "WELCOME": 5000, + "FREESHIP": 3000 + ] + + if let discount = validCoupons[normalizedCode] { + couponDiscount = discount + couponError = nil + } else if normalizedCode == "EXPIRED" { + couponError = "만료된 쿠폰입니다" + couponDiscount = 0 + } else { + couponError = "유효하지 않은 쿠폰 코드입니다" + couponDiscount = 0 + } + + isApplyingCoupon = false + } + + /// 결제 처리 + private func processPayment() async { + do { + let result = try await paymentService.processPayment( + for: cartStore.items, + shippingMethod: selectedShipping + ) + + paymentResult = result + showPaymentResult = true + + } catch let error as PaymentError { + switch error { + case .userCancelled: + // 사용자 취소는 에러로 처리하지 않음 + break + default: + errorMessage = error.localizedDescription + showError = true + } + + paymentService.resetPayment() + + } catch { + errorMessage = "알 수 없는 오류가 발생했습니다." + showError = true + paymentService.resetPayment() + } + } +} + +// MARK: - 주문 상품 행 + +struct OrderItemRow: View { + let item: CartItem + + var body: some View { + HStack(spacing: 12) { + // 상품 이미지 플레이스홀더 + RoundedRectangle(cornerRadius: 8) + .fill(.fill.quaternary) + .frame(width: 50, height: 50) + .overlay { + Image(systemName: item.product.category.symbol) + .foregroundStyle(.secondary) + } + + VStack(alignment: .leading, spacing: 4) { + Text(item.product.name) + .font(.subheadline.weight(.medium)) + .lineLimit(1) + + Text("수량: \(item.quantity)") + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer() + + Text(item.formattedTotalPrice) + .font(.subheadline.weight(.medium)) + } + .accessibilityElement(children: .combine) + .accessibilityLabel("\(item.product.name), 수량 \(item.quantity), \(item.formattedTotalPrice)") + } +} + +// MARK: - 배송 방법 행 + +struct ShippingMethodRow: View { + let method: ShippingMethod + let isSelected: Bool + let orderAmount: Int + let action: () -> Void + + private var price: Int { + method.calculatePrice(for: orderAmount) + } + + var body: some View { + Button(action: action) { + HStack(spacing: 12) { + // 선택 표시 + Image(systemName: isSelected ? "checkmark.circle.fill" : "circle") + .font(.title3) + .foregroundStyle(isSelected ? .blue : .secondary) + + // 아이콘 + Image(systemName: method.type.symbol) + .font(.title3) + .foregroundStyle(.secondary) + .frame(width: 24) + + // 정보 + VStack(alignment: .leading, spacing: 2) { + Text(method.name) + .font(.subheadline.weight(.medium)) + .foregroundStyle(.primary) + + Text(method.detail) + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer() + + // 가격 + VStack(alignment: .trailing, spacing: 2) { + Text(method.formattedPrice(for: orderAmount)) + .font(.subheadline.weight(.medium)) + .foregroundStyle(price == 0 ? .green : .primary) + + Text(method.estimatedDeliveryDescription) + .font(.caption) + .foregroundStyle(.secondary) + } + } + .padding(12) + .background( + isSelected ? Color.blue.opacity(0.1) : Color.clear, + in: RoundedRectangle(cornerRadius: 12) + ) + .overlay( + RoundedRectangle(cornerRadius: 12) + .strokeBorder( + isSelected ? Color.blue : Color.clear, + lineWidth: 2 + ) + ) + } + .buttonStyle(.plain) + .accessibilityLabel("\(method.name), \(method.formattedPrice(for: orderAmount)), \(method.estimatedDeliveryDescription)") + .accessibilityAddTraits(isSelected ? .isSelected : []) + } +} + +// MARK: - 가격 행 + +struct PriceRow: View { + let label: String + let amount: Int + var detail: String? = nil + var isDiscount: Bool = false + + private var formattedAmount: String { + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + let formatted = formatter.string(from: NSNumber(value: abs(amount))) ?? "\(abs(amount))" + let prefix = isDiscount || amount < 0 ? "-" : "" + return "\(prefix)₩\(formatted)" + } + + var body: some View { + HStack { + Text(label) + .foregroundStyle(.secondary) + + Spacer() + + if let detail = detail { + Text(detail) + .foregroundStyle(.green) + .font(.subheadline.weight(.medium)) + } else { + Text(formattedAmount) + .foregroundStyle(isDiscount ? .green : .primary) + } + } + .font(.subheadline) + .accessibilityElement(children: .combine) + .accessibilityLabel("\(label): \(detail ?? formattedAmount)") + } +} + +// MARK: - Preview + +#Preview { + NavigationStack { + CheckoutView(cartStore: .preview) + } +} + +#Preview("Empty Cart") { + NavigationStack { + CheckoutView(cartStore: .empty) + } +} diff --git a/practice/HIGPractice/HIGPractice/Learning/Phase-01-AppFrameworks/Observation/CartFlow/Views/PaymentResultView.swift b/practice/HIGPractice/HIGPractice/Learning/Phase-01-AppFrameworks/Observation/CartFlow/Views/PaymentResultView.swift new file mode 100644 index 00000000..b44f6843 --- /dev/null +++ b/practice/HIGPractice/HIGPractice/Learning/Phase-01-AppFrameworks/Observation/CartFlow/Views/PaymentResultView.swift @@ -0,0 +1,496 @@ +// +// PaymentResultView.swift +// HIGPractice +// +// Created by YuSeongChoi on 3/17/26. +// + +import SwiftUI +internal import PassKit + +// MARK: - 결제 결과 화면 +/// Apple Pay 결제 완료 후 결과를 표시하는 화면 +/// +/// ## 주요 기능 +/// - 결제 성공/실패 상태 표시 +/// - 주문 정보 요약 +/// - 배송 정보 표시 +/// - 영수증 공유 기능 +/// +/// ## 사용 예시 +/// ```swift +/// PaymentResultView(result: paymentResult) { +/// // 완료 후 처리 +/// cartStore.clearCart() +/// } +/// ``` + +struct PaymentResultView: View { + + // MARK: - 속성 + + /// 결제 결과 + let result: PaymentResult + + /// 완료 액션 + let onComplete: () -> Void + + // MARK: - 환경 + + @Environment(\.dismiss) private var dismiss + + // MARK: - 상태 + + /// 애니메이션 상태 + @State private var showContent = false + + /// 공유 시트 표시 + @State private var showShareSheet = false + + /// 복사 완료 토스트 + @State private var showCopiedToast = false + + // MARK: - Body + + var body: some View { + NavigationStack { + ScrollView { + VStack(spacing: 32) { + // 결과 헤더 + resultHeader + .scaleEffect(showContent ? 1 : 0.5) + .opacity(showContent ? 1 : 0) + + // 주문 정보 + orderInfoSection + .offset(y: showContent ? 0 : 20) + .opacity(showContent ? 1 : 0) + + // 배송 정보 + if result.shippingContact != nil || result.shippingMethod != nil { + shippingInfoSection + .offset(y: showContent ? 0 : 20) + .opacity(showContent ? 1 : 0) + } + + // 결제 정보 + paymentInfoSection + .offset(y: showContent ? 0 : 20) + .opacity(showContent ? 1 : 0) + + // 주문 항목 + orderItemsSection + .offset(y: showContent ? 0 : 20) + .opacity(showContent ? 1 : 0) + } + .padding() + } + .navigationTitle("결제 완료") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button("완료") { + onComplete() + dismiss() + } + .fontWeight(.semibold) + } + + ToolbarItem(placement: .topBarLeading) { + Button { + showShareSheet = true + } label: { + Image(systemName: "square.and.arrow.up") + } + } + } + .overlay(alignment: .bottom) { + if showCopiedToast { + copiedToast + .transition(.move(edge: .bottom).combined(with: .opacity)) + } + } + } + .onAppear { + withAnimation(.spring(response: 0.6, dampingFraction: 0.7)) { + showContent = true + } + } + .sheet(isPresented: $showShareSheet) { + ShareSheet(items: [receiptText]) + } + } + + // MARK: - 결과 헤더 + + private var resultHeader: some View { + VStack(spacing: 16) { + // 성공/실패 아이콘 + ZStack { + Circle() + .fill(statusColor.opacity(0.1)) + .frame(width: 100, height: 100) + + Image(systemName: statusIcon) + .font(.system(size: 50)) + .foregroundStyle(statusColor) + } + .accessibilityHidden(true) + + // 상태 텍스트 + Text(statusTitle) + .font(.title.bold()) + + Text(statusDescription) + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + .accessibilityElement(children: .combine) + .accessibilityLabel("\(statusTitle). \(statusDescription)") + } + + private var statusColor: Color { + switch result.status { + case .success: + return .green + case .pending: + return .orange + case .failed: + return .red + case .refunded: + return .blue + } + } + + private var statusIcon: String { + switch result.status { + case .success: + return "checkmark.circle.fill" + case .pending: + return "clock.fill" + case .failed: + return "xmark.circle.fill" + case .refunded: + return "arrow.uturn.left.circle.fill" + } + } + + private var statusTitle: String { + switch result.status { + case .success: + return "결제 완료" + case .pending: + return "처리 중" + case .failed: + return "결제 실패" + case .refunded: + return "환불 완료" + } + } + + private var statusDescription: String { + switch result.status { + case .success: + return "주문이 성공적으로 완료되었습니다." + case .pending: + return "결제가 처리 중입니다. 잠시만 기다려주세요." + case .failed: + return "결제에 실패했습니다. 다시 시도해주세요." + case .refunded: + return "결제가 환불되었습니다." + } + } + + // MARK: - 주문 정보 섹션 + + private var orderInfoSection: some View { + VStack(alignment: .leading, spacing: 12) { + SectionHeader(title: "주문 정보", icon: "doc.text") + + InfoRow(label: "주문 번호", value: String(result.transactionId.prefix(16))) + InfoRow(label: "결제 일시", value: result.formattedDate) + InfoRow(label: "결제 금액", value: result.formattedAmount, isHighlighted: true) + } + .infoSectionStyle() + } + + // MARK: - 배송 정보 섹션 + + private var shippingInfoSection: some View { + VStack(alignment: .leading, spacing: 12) { + SectionHeader(title: "배송 정보", icon: "shippingbox") + + if let method = result.shippingMethod { + InfoRow(label: "배송 방법", value: method.name) + InfoRow(label: "예상 배송일", value: method.formattedDeliveryDate()) + } + + if let contact = result.shippingContact { + if let name = contact.name { + let fullName = [name.familyName, name.givenName] + .compactMap { $0 } + .joined(separator: "") + InfoRow(label: "받는 분", value: fullName) + } + + if let address = contact.postalAddress { + let fullAddress = [ + address.state, + address.city, + address.street, + address.subLocality + ] + .compactMap { $0.isEmpty ? nil : $0 } + .joined(separator: " ") + + InfoRow(label: "배송 주소", value: fullAddress) + } + + if let phone = contact.phoneNumber?.stringValue { + InfoRow(label: "연락처", value: phone) + } + } + } + .infoSectionStyle() + } + + // MARK: - 결제 정보 섹션 + + private var paymentInfoSection: some View { + VStack(alignment: .leading, spacing: 12) { + SectionHeader(title: "결제 정보", icon: "creditcard") + + HStack(spacing: 12) { + // 카드 네트워크 아이콘 + Image(systemName: cardNetworkIcon) + .font(.title2) + .foregroundStyle(.secondary) + .frame(width: 32) + + VStack(alignment: .leading, spacing: 2) { + Text(result.paymentNetwork) + .font(.subheadline.weight(.medium)) + + Text(result.cardType) + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer() + + Image(systemName: "applelogo") + .font(.title3) + Text("Pay") + .font(.headline) + } + .padding(12) + .background(.fill.quaternary, in: RoundedRectangle(cornerRadius: 8)) + } + .infoSectionStyle() + } + + private var cardNetworkIcon: String { + switch result.paymentNetwork.lowercased() { + case "visa": + return "creditcard" + case "mastercard": + return "creditcard.fill" + case "amex", "american express": + return "creditcard.trianglebadge.exclamationmark" + default: + return "creditcard" + } + } + + // MARK: - 주문 항목 섹션 + + private var orderItemsSection: some View { + VStack(alignment: .leading, spacing: 12) { + SectionHeader(title: "주문 항목", icon: "bag") + + ForEach(result.orderItems, id: \.productId) { item in + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(item.productName) + .font(.subheadline) + + Text("수량: \(item.quantity)") + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer() + + Text("₩\(item.totalPrice.formatted())") + .font(.subheadline) + } + + if item.productId != result.orderItems.last?.productId { + Divider() + } + } + } + .infoSectionStyle() + } + + // MARK: - 복사 완료 토스트 + + private var copiedToast: some View { + Text("주문 번호가 복사되었습니다") + .font(.subheadline) + .padding(.horizontal, 16) + .padding(.vertical, 12) + .background(.ultraThinMaterial, in: Capsule()) + .shadow(color: .black.opacity(0.1), radius: 10) + .padding(.bottom, 16) + } + + // MARK: - 영수증 텍스트 + + private var receiptText: String { + var text = """ + [CartFlow 주문 영수증] + + 주문 번호: \(result.transactionId) + 결제 일시: \(result.formattedDate) + 결제 금액: \(result.formattedAmount) + 결제 방법: Apple Pay (\(result.paymentNetwork)) + + [주문 항목] + """ + + for item in result.orderItems { + text += "\n• \(item.productName) x \(item.quantity): ₩\(item.totalPrice.formatted())" + } + + if let method = result.shippingMethod { + text += "\n\n[배송 정보]\n배송 방법: \(method.name)" + text += "\n예상 배송일: \(method.formattedDeliveryDate())" + } + + text += "\n\n감사합니다!" + + return text + } + + // MARK: - 복사 기능 + + private func copyOrderNumber() { + UIPasteboard.general.string = result.transactionId + + withAnimation { + showCopiedToast = true + } + + Task { + try? await Task.sleep(for: .seconds(2)) + withAnimation { + showCopiedToast = false + } + } + } +} + +// MARK: - 보조 뷰 + +struct SectionHeader: View { + let title: String + let icon: String + + var body: some View { + Label(title, systemImage: icon) + .font(.headline) + } +} + +struct InfoRow: View { + let label: String + let value: String + var isHighlighted: Bool = false + + var body: some View { + HStack { + Text(label) + .foregroundStyle(.secondary) + + Spacer() + + Text(value) + .foregroundStyle(isHighlighted ? .primary : .secondary) + .fontWeight(isHighlighted ? .semibold : .regular) + .multilineTextAlignment(.trailing) + } + .font(.subheadline) + .accessibilityElement(children: .combine) + .accessibilityLabel("\(label): \(value)") + } +} + +// MARK: - 섹션 스타일 Modifier + +extension View { + func infoSectionStyle() -> some View { + self + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + .background(.fill.tertiary, in: RoundedRectangle(cornerRadius: 16)) + } +} + +// MARK: - 공유 시트 + +struct ShareSheet: UIViewControllerRepresentable { + let items: [Any] + + func makeUIViewController(context: Context) -> UIActivityViewController { + UIActivityViewController(activityItems: items, applicationActivities: nil) + } + + func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {} +} + +// MARK: - Preview + +#Preview("Success") { + PaymentResultView( + result: PaymentResult( + transactionId: "TXN-2024-0217-ABC123DEF456", + status: .success, + amount: 425000, + currency: "KRW", + paymentNetwork: "Visa", + cardType: "Credit", + shippingContact: nil, + billingContact: nil, + shippingMethod: .express, + timestamp: Date(), + orderItems: [ + OrderItem(from: CartItem(product: Product.samples[0], quantity: 1)), + OrderItem(from: CartItem(product: Product.samples[3], quantity: 2)) + ] + ), + onComplete: {} + ) +} + +#Preview("Pending") { + PaymentResultView( + result: PaymentResult( + transactionId: "TXN-2024-0217-PENDING", + status: .pending, + amount: 99000, + currency: "KRW", + paymentNetwork: "Mastercard", + cardType: "Debit", + shippingContact: nil, + billingContact: nil, + shippingMethod: .standardPaid, + timestamp: Date(), + orderItems: [ + OrderItem(from: CartItem(product: Product.samples[1], quantity: 1)) + ] + ), + onComplete: {} + ) +} diff --git a/practice/HIGPractice/HIGPractice/Learning/Phase-01-AppFrameworks/Observation/CartFlow/Views/ProductListView.swift b/practice/HIGPractice/HIGPractice/Learning/Phase-01-AppFrameworks/Observation/CartFlow/Views/ProductListView.swift new file mode 100644 index 00000000..58c73e21 --- /dev/null +++ b/practice/HIGPractice/HIGPractice/Learning/Phase-01-AppFrameworks/Observation/CartFlow/Views/ProductListView.swift @@ -0,0 +1,554 @@ +// +// ProductListView.swift +// HIGPractice +// +// Created by YuSeongChoi on 3/17/26. +// + +import SwiftUI + +// MARK: - 상품 목록 뷰 +/// 쇼핑 앱의 메인 상품 목록 화면 +/// +/// ## 주요 기능 +/// - 카테고리별 필터링 +/// - 상품 검색 +/// - 그리드/리스트 레이아웃 전환 +/// - 장바구니 추가 +/// +/// ## 접근성 +/// - VoiceOver 완벽 지원 +/// - Dynamic Type 대응 +/// - 애니메이션 감소 지원 + +struct ProductListView: View { + + // MARK: - 환경 + + @Environment(CartStore.self) private var cartStore + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + // MARK: - 상태 + + /// 상품 목록 + @State private var products: [Product] = [] + + /// 로딩 중 여부 + @State private var isLoading = true + + /// 선택된 카테고리 + @State private var selectedCategory: ProductCategory? + + /// 검색어 + @State private var searchText = "" + + /// 레이아웃 모드 + @State private var layoutMode: LayoutMode = .grid + + /// 에러 메시지 + @State private var errorMessage: String? + + /// 정렬 기준 + @State private var sortOrder: SortOrder = .default + + /// 추가됨 토스트 표시 + @State private var showAddedToast = false + @State private var addedProductName = "" + + // MARK: - 레이아웃 모드 + + enum LayoutMode: String, CaseIterable { + case grid = "그리드" + case list = "리스트" + + var icon: String { + switch self { + case .grid: return "square.grid.2x2" + case .list: return "list.bullet" + } + } + } + + // MARK: - 정렬 기준 + + enum SortOrder: String, CaseIterable { + case `default` = "기본" + case priceAsc = "가격 낮은순" + case priceDesc = "가격 높은순" + case name = "이름순" + + var icon: String { + switch self { + case .default: return "arrow.up.arrow.down" + case .priceAsc: return "arrow.up" + case .priceDesc: return "arrow.down" + case .name: return "textformat.abc" + } + } + } + + // MARK: - 필터링된 상품 + + private var filteredProducts: [Product] { + var result = products + + // 카테고리 필터 + if let category = selectedCategory { + result = result.filter { $0.category == category } + } + + // 검색 필터 + if !searchText.isEmpty { + let query = searchText.lowercased() + result = result.filter { product in + product.name.lowercased().contains(query) || + product.description.lowercased().contains(query) + } + } + + // 정렬 + switch sortOrder { + case .default: + break + case .priceAsc: + result.sort { $0.price < $1.price } + case .priceDesc: + result.sort { $0.price > $1.price } + case .name: + result.sort { $0.name < $1.name } + } + + return result + } + + // MARK: - 그리드 레이아웃 + + private var gridColumns: [GridItem] { + [ + GridItem(.flexible(), spacing: 16), + GridItem(.flexible(), spacing: 16) + ] + } + + // MARK: - Body + + var body: some View { + Group { + if isLoading { + loadingView + } else if let error = errorMessage { + errorView(message: error) + } else if filteredProducts.isEmpty { + emptyView + } else { + productScrollView + } + } + .navigationTitle("CartFlow") + .searchable(text: $searchText, prompt: "상품 검색") + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + layoutToggleButton + } + + ToolbarItem(placement: .topBarTrailing) { + sortMenu + } + } + .overlay(alignment: .bottom) { + if showAddedToast { + addedToastView + .transition(.move(edge: .bottom).combined(with: .opacity)) + } + } + .task { + await loadProducts() + } + .refreshable { + await loadProducts() + } + } + + // MARK: - 로딩 뷰 + + private var loadingView: some View { + VStack(spacing: 16) { + ProgressView() + .controlSize(.large) + + Text("상품을 불러오는 중...") + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + + // MARK: - 에러 뷰 + + private func errorView(message: String) -> some View { + ContentUnavailableView { + Label("오류 발생", systemImage: "exclamationmark.triangle") + } description: { + Text(message) + } actions: { + Button("다시 시도") { + Task { await loadProducts() } + } + .buttonStyle(.borderedProminent) + } + } + + // MARK: - 빈 상태 뷰 + + private var emptyView: some View { + ContentUnavailableView { + Label("검색 결과 없음", systemImage: "magnifyingglass") + } description: { + Text("'\(searchText)'에 대한 검색 결과가 없습니다.") + } actions: { + Button("검색어 지우기") { + searchText = "" + } + .buttonStyle(.bordered) + } + } + + // MARK: - 상품 스크롤 뷰 + + private var productScrollView: some View { + ScrollView { + VStack(spacing: 16) { + // 카테고리 필터 + categoryFilterView + + // 상품 목록 + switch layoutMode { + case .grid: + gridView + case .list: + listView + } + } + .padding() + } + } + + // MARK: - 카테고리 필터 + + private var categoryFilterView: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + // 전체 버튼 + ProductCategoryChip( + title: "전체", + icon: "square.grid.2x2", + isSelected: selectedCategory == nil, + action: { selectedCategory = nil } + ) + + // 카테고리 버튼들 + ForEach(ProductCategory.allCases) { category in + ProductCategoryChip( + title: category.rawValue, + icon: category.symbol, + isSelected: selectedCategory == category, + action: { selectedCategory = category } + ) + } + } + } + .accessibilityElement(children: .contain) + .accessibilityLabel("카테고리 필터") + } + + // MARK: - 그리드 뷰 + + private var gridView: some View { + LazyVGrid(columns: gridColumns, spacing: 16) { + ForEach(filteredProducts) { product in + ProductGridCell(product: product) { + addToCart(product) + } + } + } + } + + // MARK: - 리스트 뷰 + + private var listView: some View { + LazyVStack(spacing: 12) { + ForEach(filteredProducts) { product in + ProductListRow(product: product) { + addToCart(product) + } + } + } + } + + // MARK: - 레이아웃 토글 버튼 + + private var layoutToggleButton: some View { + Button { + withAnimation(reduceMotion ? .none : .easeInOut(duration: 0.2)) { + layoutMode = layoutMode == .grid ? .list : .grid + } + } label: { + Image(systemName: layoutMode == .grid ? "list.bullet" : "square.grid.2x2") + } + .accessibilityLabel("레이아웃 변경") + .accessibilityHint("현재 \(layoutMode.rawValue), 탭하여 변경") + } + + // MARK: - 정렬 메뉴 + + private var sortMenu: some View { + Menu { + ForEach(SortOrder.allCases, id: \.self) { order in + Button { + sortOrder = order + } label: { + Label(order.rawValue, systemImage: order.icon) + if sortOrder == order { + Image(systemName: "checkmark") + } + } + } + } label: { + Image(systemName: "arrow.up.arrow.down.circle") + } + .accessibilityLabel("정렬 기준") + .accessibilityHint("현재 \(sortOrder.rawValue)") + } + + // MARK: - 추가됨 토스트 + + private var addedToastView: some View { + HStack(spacing: 8) { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.green) + + Text("\(addedProductName) 장바구니에 추가됨") + .font(.subheadline) + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + .background(.ultraThinMaterial, in: Capsule()) + .shadow(color: .black.opacity(0.1), radius: 10) + .padding(.bottom, 16) + } + + // MARK: - 액션 + + /// 상품 로드 + private func loadProducts() async { + isLoading = true + errorMessage = nil + + do { + products = try await ProductService.shared.fetchAllProducts() + } catch { + errorMessage = error.localizedDescription + } + + isLoading = false + } + + /// 장바구니에 추가 + private func addToCart(_ product: Product) { + cartStore.addToCart(product) + + // 토스트 표시 + addedProductName = product.name + withAnimation { + showAddedToast = true + } + + // 토스트 숨기기 + Task { + try? await Task.sleep(for: .seconds(2)) + withAnimation { + showAddedToast = false + } + } + } +} + +// MARK: - 카테고리 칩 + +struct ProductCategoryChip: View { + let title: String + let icon: String + let isSelected: Bool + let action: () -> Void + + var body: some View { + Button(action: action) { + Label(title, systemImage: icon) + .font(.subheadline) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background( + isSelected ? Color.accentColor : Color(.systemGray5), + in: Capsule() + ) + .foregroundStyle(isSelected ? .white : .primary) + } + .buttonStyle(.plain) + .accessibilityLabel(title) + .accessibilityAddTraits(isSelected ? .isSelected : []) + } +} + +// MARK: - 상품 그리드 셀 + +struct ProductGridCell: View { + let product: Product + let addAction: () -> Void + + @Environment(CartStore.self) private var cartStore + + private var isInCart: Bool { + cartStore.contains(product) + } + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + // 이미지 영역 + ZStack(alignment: .topTrailing) { + RoundedRectangle(cornerRadius: 12) + .fill(.fill.tertiary) + .aspectRatio(1, contentMode: .fit) + .overlay { + Image(systemName: product.category.symbol) + .font(.largeTitle) + .foregroundStyle(.secondary) + } + + // 카테고리 배지 + Text(product.category.rawValue) + .font(.caption2.weight(.medium)) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(.ultraThinMaterial, in: Capsule()) + .padding(8) + } + + // 상품 정보 + VStack(alignment: .leading, spacing: 4) { + Text(product.name) + .font(.subheadline.weight(.medium)) + .lineLimit(2) + + Text(product.formattedPrice) + .font(.headline) + .foregroundStyle(.primary) + } + + // 장바구니 버튼 + Button { + addAction() + } label: { + HStack { + if isInCart { + Image(systemName: "checkmark") + Text("담김 (\(cartStore.quantity(of: product)))") + } else { + Image(systemName: "cart.badge.plus") + Text("담기") + } + } + .font(.subheadline.weight(.medium)) + .frame(maxWidth: .infinity) + .padding(.vertical, 10) + .background( + isInCart ? Color.green.opacity(0.1) : Color.accentColor.opacity(0.1), + in: RoundedRectangle(cornerRadius: 8) + ) + .foregroundStyle(isInCart ? .green : .accentColor) + } + .buttonStyle(.plain) + .accessibilityLabel(isInCart ? "장바구니에 추가됨, 탭하여 더 추가" : "장바구니에 추가") + } + .accessibilityElement(children: .combine) + .accessibilityLabel("\(product.name), \(product.formattedPrice)") + .accessibilityHint(product.description) + } +} + +// MARK: - 상품 리스트 행 + +struct ProductListRow: View { + let product: Product + let addAction: () -> Void + + @Environment(CartStore.self) private var cartStore + + private var isInCart: Bool { + cartStore.contains(product) + } + + var body: some View { + HStack(spacing: 12) { + // 이미지 + RoundedRectangle(cornerRadius: 12) + .fill(.fill.tertiary) + .frame(width: 80, height: 80) + .overlay { + Image(systemName: product.category.symbol) + .font(.title2) + .foregroundStyle(.secondary) + } + + // 상품 정보 + VStack(alignment: .leading, spacing: 4) { + Text(product.category.rawValue) + .font(.caption) + .foregroundStyle(.secondary) + + Text(product.name) + .font(.subheadline.weight(.medium)) + .lineLimit(1) + + Text(product.description) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(2) + + Text(product.formattedPrice) + .font(.headline) + } + + Spacer() + + // 추가 버튼 + Button { + addAction() + } label: { + Image(systemName: isInCart ? "checkmark.circle.fill" : "plus.circle.fill") + .font(.title2) + .foregroundStyle(isInCart ? .green : .accentColor) + } + .buttonStyle(.plain) + .accessibilityLabel(isInCart ? "장바구니에 추가됨" : "장바구니에 추가") + } + .padding() + .background(.fill.tertiary, in: RoundedRectangle(cornerRadius: 16)) + .accessibilityElement(children: .combine) + .accessibilityLabel("\(product.name), \(product.formattedPrice)") + } +} + +// MARK: - Preview + +#Preview("Grid") { + NavigationStack { + ProductListView() + } + .environment(CartStore.preview) +} + +#Preview("List") { + NavigationStack { + ProductListView() + } + .environment(CartStore()) +} diff --git a/practice/HIGPractice/HIGPractice/Learning/Phase-01-AppFrameworks/Observation/ISSUE_DRAFT.md b/practice/HIGPractice/HIGPractice/Learning/Phase-01-AppFrameworks/Observation/ISSUE_DRAFT.md new file mode 100644 index 00000000..10346fd7 --- /dev/null +++ b/practice/HIGPractice/HIGPractice/Learning/Phase-01-AppFrameworks/Observation/ISSUE_DRAFT.md @@ -0,0 +1,44 @@ +# [학습] Phase 1 - Observation + +- label: `learning` +- branch: `learning/observation-cartflow` +- issue: `#26` + +## 학습 Phase +- Phase 1: App Frameworks + +## Framework 이름 +- Observation + +## 이번 학습 목표 +- `CartFlow`를 기준으로 Observation 상태 소유권과 뷰 갱신 흐름을 설명할 수 있다. +- `@Observable`, `@State`, `@Bindable`, `@Environment`의 역할을 구분할 수 있다. +- 뷰가 "읽은 프로퍼티만 추적된다"는 Observation의 핵심 원리를 정리한다. +- `ObservableObject` 기반 사고방식과 Observation 기반 사고방식의 차이를 비교 설명할 수 있다. +- 이후 Foundation Models나 더 큰 앱 구조 학습으로 넘어갈 때 상태 관리 기준점으로 삼을 메모를 남긴다. + +## 작업 체크리스트 +- [x] site 개념 확인 +- [x] tutorials 실습 +- [x] samples 구조 비교 +- [x] `CartStore.swift` 읽고 `@Observable` / 상태 소유 구조 정리 +- [x] `Product.swift` 읽고 plain model과 observable store 경계 정리 +- [x] `ProductListView.swift` 읽고 읽기 추적과 뷰 갱신 범위 정리 +- [x] `CartView.swift` 읽고 파생 상태와 액션 위임 구조 정리 +- [x] `CheckoutView.swift` 읽고 async 상태 전이 정리 +- [x] `CartFlowApp.swift` 읽고 앱 루트 상태 주입 방식 정리 +- [x] Observation vs ObservableObject 비교 메모 작성 +- [ ] PR 생성 및 CI 확인 +- [ ] 머지 후 `LEARNING_LOG` / 회고 기록 + +## 완료 조건 (Definition of Done) +- `CartFlow` 기준 Observation 핵심 파일의 역할을 스스로 설명할 수 있다. +- `@Observable`, `@State`, `@Bindable`, `@Environment`의 사용 기준을 말할 수 있다. +- 왜 Observation이 `ObservableObject`보다 읽기 추적 관점에서 더 정교한지 설명할 수 있다. +- `Observation.md`에 파일별 메모와 비교 정리가 남아 있다. + +## 2026-03-17 진행 메모 +- `CartFlow/Views` 폴더 구현을 완료했다. +- 상품 목록, 장바구니, 체크아웃, 결제 결과, 루트 탭 구조, Apple Pay 버튼 래퍼를 연결했다. +- `CartStore`를 루트에서 소유하고 하위 화면에서는 `@Environment`와 `@Bindable`로 읽거나 전달하는 구조를 실제 UI에 반영했다. +- Apple Pay 결제 흐름을 위해 PassKit 관련 타입 import와 보조 UI도 함께 정리했다. diff --git a/practice/HIGPractice/HIGPractice/Learning/Phase-01-AppFrameworks/Observation/Observation.md b/practice/HIGPractice/HIGPractice/Learning/Phase-01-AppFrameworks/Observation/Observation.md new file mode 100644 index 00000000..c6160402 --- /dev/null +++ b/practice/HIGPractice/HIGPractice/Learning/Phase-01-AppFrameworks/Observation/Observation.md @@ -0,0 +1,278 @@ +# Observation + +## 학습 소스 +- site: `site/observation/01-tutorial.html` +- tutorials: `tutorials/observation` +- sample: `samples/CartFlow` +- ai-reference: `ai-reference/swiftui-observation.md` +- issue draft: `practice/HIGPractice/HIGPractice/Learning/Phase-01-AppFrameworks/Observation/ISSUE_DRAFT.md` +- issue: `#26` +- branch: `learning/observation-cartflow` + +## 이번 학습 구조 +- 이번 Observation 학습은 `CartFlow`를 기준으로 "상태가 어떻게 추적되고, 어떤 읽기가 어떤 뷰 갱신을 유발하는가"를 보는 방식으로 진행한다. +- SwiftUI 때는 화면 구조, SwiftData 때는 저장 흐름을 봤다면, 이번에는 상태 관리 계층 자체를 읽는다. +- 핵심 질문은 아래 4가지다. + - 상태는 어느 객체가 소유하는가 + - 뷰는 어떤 값을 읽고 있는가 + - 어떤 수정이 어떤 뷰 갱신으로 이어지는가 + - `@Observable`, `@State`, `@Bindable`, `@Environment`는 각각 어디에 쓰는가 + +## 이번 학습 방식 +- 읽는 순서는 고정한다. + 1. `CartStore.swift` + 2. `Product.swift` + 3. `ProductListView.swift` + 4. `CartView.swift` + 5. `CheckoutView.swift` + 6. `CartFlowApp.swift` +- 각 파일에서 "Observation 관점 메모"를 남긴다. +- 문법만 외우지 말고, `CartFlow`에서 상태 소유권과 뷰 갱신 경계를 설명할 수 있는지까지 본다. + +## 읽는 순서와 체크 포인트 + +### 1. `CartStore.swift` +- 파일: `samples/CartFlow/Shared/CartStore.swift` +- 여기서는 "상태를 가진 중심 객체"를 본다. +- 완료 여부: [x] +- 체크 포인트 + - `@Observable` + - stored property와 computed property + - 상태 변경 메서드 + - 왜 `ObservableObject` 대신 이 구조를 쓰는가 + +### 내가 적을 메모 +- `CartStore`는 Observation 상태의 중심 객체다. +- `@Observable`이 붙으면 stored property 접근과 변경이 자동 추적된다. +- `items`, `isLoading`, `isCheckoutComplete`, `errorMessage`는 추적 대상 상태다. +- `totalItemCount`, `totalPrice`, `formattedTotalPrice`, `isEmpty`는 저장값이 아니라 읽기 파생값이다. +- 예전 `ObservableObject`처럼 각 프로퍼티마다 `@Published`를 붙이지 않아도 된다는 점이 가장 큰 문법 차이다. +- 하지만 더 중요한 차이는 "모든 변경 통지"가 아니라 "실제로 읽은 프로퍼티 기준 추적"이라는 점이다. +- `addToCart`, `updateQuantity`, `checkout` 같은 메서드를 store 안에 두면, 뷰는 상태 변경 규칙을 직접 들지 않고 store의 도메인 동작만 호출하면 된다. +- 따라서 Observation에서는 상태 객체를 단순 데이터 상자가 아니라 "상태 + 상태 변경 규칙을 가진 객체"로 보는 편이 이해에 도움이 된다. + +## `CartStore.swift`를 보고 답할 질문 +- `@Observable`은 무엇을 자동화하는가 +- computed property는 어떻게 뷰 갱신과 연결되는가 +- 도메인 메서드를 store 안에 두는 이유는 무엇인가 + +### 2. `Product.swift` +- 파일: `samples/CartFlow/Shared/Product.swift` +- 여기서는 "관찰 대상 상태"와 "단순 모델 값"의 차이를 본다. +- 체크 포인트 + - value type 모델 + - store가 아닌 데이터 모델의 역할 + - 상태 소유권 경계 + +### 내가 적을 메모 +- 모든 모델이 `@Observable`일 필요는 없다. +- `Product`처럼 주로 값 전달에 쓰이는 모델은 단순 struct로 두는 편이 자연스럽다. +- Observation에서 중요한 건 "무엇을 관찰할지"보다 "누가 상태를 소유하고 바꾸는지"를 분리하는 것이다. + +## `Product.swift`를 보고 답할 질문 +- 왜 `Product`는 `@Observable`이 아닐까 +- store와 plain model은 역할이 어떻게 다른가 + +### 3. `ProductListView.swift` +- 파일: `samples/CartFlow/CartFlowApp/Views/ProductListView.swift` +- 여기서는 "뷰가 상태를 읽는 방식"을 본다. +- 체크 포인트 + - 뷰가 store를 어떻게 받는가 + - 어떤 프로퍼티 읽기가 뷰 갱신 대상이 되는가 + - 세부 뷰 분리와 읽기 범위 + +### 내가 적을 메모 +- Observation에서는 뷰가 실제로 읽은 프로퍼티 기준으로 갱신 경계가 잡힌다. +- 따라서 큰 store 하나를 넘기더라도, 뷰가 무엇을 읽는지에 따라 갱신 범위가 달라진다. +- 이 단계에서는 "store를 넘기는 것"보다 "어떤 값을 읽는지"가 더 중요하다. + +## `ProductListView.swift`를 보고 답할 질문 +- 이 뷰는 store의 어떤 값을 읽고 있는가 +- 읽기 범위를 줄이면 어떤 이점이 있는가 + +### 4. `CartView.swift` +- 파일: `samples/CartFlow/CartFlowApp/Views/CartView.swift` +- 여기서는 "파생 상태와 사용자 액션"을 본다. +- 체크 포인트 + - 계산 프로퍼티 사용 + - 수량 변경 액션 + - 비어 있음/합계/목록 상태 연결 + +### 내가 적을 메모 +- `CartView`는 store의 raw state와 파생 state를 함께 사용한다. +- 아이템 수량 변경 같은 액션은 뷰가 직접 배열을 수정하지 않고 store 메서드에 위임한다. +- Observation 학습에서는 "뷰가 상태를 어떻게 직접 만지지 않게 하는가"도 중요하다. + +## `CartView.swift`를 보고 답할 질문 +- 뷰가 배열을 직접 수정하지 않고 store 메서드를 호출하는 이유는 무엇인가 +- 빈 상태, 합계, 목록은 각각 어떤 읽기에 의존하는가 + +### 5. `CheckoutView.swift` +- 파일: `samples/CartFlow/CartFlowApp/Views/CheckoutView.swift` +- 여기서는 "비동기 상태 변화"를 본다. +- 체크 포인트 + - `isLoading` + - `errorMessage` + - async 액션 후 상태 전이 + +### 내가 적을 메모 +- Observation은 비동기 작업 자체를 처리하는 프레임워크가 아니라, 비동기 작업이 바꾼 상태를 추적하는 프레임워크다. +- 따라서 `checkout()` 같은 async 메서드에서는 작업보다 "어떤 상태를 언제 바꾸는가"를 봐야 한다. + +## `CheckoutView.swift`를 보고 답할 질문 +- async 작업과 Observation의 관계는 무엇인가 +- 로딩/성공/실패는 어떤 상태로 표현되는가 + +### 6. `CartFlowApp.swift` +- 파일: `samples/CartFlow/CartFlowApp/CartFlowApp.swift` +- 여기서는 "상태 소유권 주입"을 본다. +- 체크 포인트 + - 앱 루트에서 store를 어디에 두는가 + - `@State`와 `@Environment` 사용 위치 + - 하위 뷰 전달 방식 + +### 내가 적을 메모 +- Observation에서는 `@Observable` 타입을 앱 루트에서 `@State`로 소유하는 패턴이 중요하다. +- 환경 주입을 쓰면 하위 뷰는 타입 기반으로 상태를 읽을 수 있다. +- 이 단계는 SwiftUI 학습에서 봤던 상태 소유권 개념과 직접 연결된다. + +## `CartFlowApp.swift`를 보고 답할 질문 +- 왜 앱 루트에서 store를 소유하는가 +- `@State`와 `@Environment`는 각각 어느 역할인가 + +## Observation 핵심 개념 요약 + +### `@Observable` +- 상태 객체를 자동 추적 가능하게 만드는 매크로 +- `ObservableObject` + `@Published` 조합의 많은 보일러플레이트를 줄인다 + +### `@Bindable` +- `@Observable` 객체의 프로퍼티를 폼 입력과 양방향 바인딩할 때 사용한다 +- 읽기 전용 참조가 아니라 binding을 열어 주는 역할이다 + +### `@State` +- 뷰가 상태 객체의 소유권을 가질 때 쓴다 +- Observation 객체도 앱/뷰 루트에서는 `@State`로 들고 가는 경우가 많다 + +### `@Environment` +- Observation 상태 객체를 하위 뷰로 공유할 때 타입 기반으로 주입할 수 있다 +- 기존 `@EnvironmentObject`와 구분해서 이해해야 한다 + +## Observation 전에 같이 알아둘 이론 + +### `Sendable`은 무엇인가 +- `Sendable`은 "이 값을 동시성 경계를 넘어 안전하게 전달할 수 있는가"를 나타내는 프로토콜이다. +- 여기서 동시성 경계란 다른 `Task`, 다른 actor, 다른 실행 문맥으로 값이 이동하는 상황을 말한다. +- Swift Concurrency에서 데이터 레이스를 줄이기 위해 컴파일러가 확인하는 기준 중 하나다. + +### `Sendable`과 `@Sendable`의 차이 +- `Sendable`은 타입에 붙는다. + - 예: 어떤 struct, enum, class가 안전하게 전달 가능한가 +- `@Sendable`은 클로저 타입에 붙는다. + - 예: 어떤 클로저를 task에 넘겨도 안전한가 +- 즉 `Sendable`은 값의 안전성, `@Sendable`은 클로저의 안전성이라고 보면 된다. + +### 왜 Observation 학습 전에 이것을 알아야 하나 +- Observation은 상태를 추적하는 프레임워크지만, 실제 앱에서는 async 작업과 함께 쓰이는 경우가 많다. +- 이때 상태 객체 자체는 Observation으로 이해하고, + task/actor 사이에 넘기는 값은 `Sendable` 관점으로 이해해야 경계가 헷갈리지 않는다. +- 즉 Observation은 "누가 상태를 읽고 갱신하는가"를 설명하고, + `Sendable`은 "그 값을 다른 실행 문맥으로 보내도 안전한가"를 설명한다. + +### struct는 언제 `Sendable`에 유리한가 +- struct는 값 타입이라 기본적으로 참조 공유 문제가 적다. +- 내부 프로퍼티가 모두 안전한 값 타입이면 자동으로 `Sendable` 취급이 가능한 경우가 많다. +- 예를 들어 `String`, `Int`, `Bool`, 그리고 그 값들로 이루어진 단순 설정 struct는 `Sendable`과 잘 맞는다. + +### class는 왜 `Sendable`이 까다로운가 +- class는 참조 타입이라 여러 task가 같은 객체 하나를 동시에 볼 수 있다. +- 그 객체가 mutable state를 가지면 데이터 레이스 가능성이 생긴다. +- 그래서 상태가 계속 바뀌는 store나 manager는 무조건 `Sendable`로 보기보다, + `@MainActor`, actor 격리, 혹은 명확한 소유권 구조로 먼저 이해하는 편이 맞다. + +### Observation과 `Sendable`을 헷갈리지 않기 +- `@Observable` = 상태 추적 +- `@Bindable` = 양방향 바인딩 +- `@State` = 상태 소유 +- `@Environment` = 상태 공유 +- `Sendable` = 동시성 경계를 넘는 값 안전성 +- `@Sendable` = 동시 실행에 넘기는 클로저 안전성 + +### 한 줄 정리 +- Observation은 "이 상태를 누가 읽고 바꾸는가"를 보는 도구다. +- `Sendable`은 "이 값을 다른 task/actor로 보내도 안전한가"를 보는 기준이다. + +## Observation vs ObservableObject + +### Observation이 강한 점 +- 더 적은 보일러플레이트 +- 프로퍼티 단위 읽기 추적 +- modern SwiftUI 패턴과 자연스럽게 연결 + +### ObservableObject가 남는 맥락 +- 구버전 iOS 호환 +- 기존 코드베이스 유지보수 +- Combine 의존이 이미 깊은 경우 + +## 이번 학습 체크리스트 +- [x] `@Observable`이 무엇을 자동화하는지 설명할 수 있다 +- [x] `@State`로 Observation 객체를 소유하는 이유를 말할 수 있다 +- [x] `@Bindable`이 필요한 순간을 설명할 수 있다 +- [x] 뷰가 "읽은 프로퍼티 기준으로 갱신된다"는 말을 설명할 수 있다 +- [x] plain model과 observable store의 역할 차이를 말할 수 있다 +- [x] async 작업과 상태 추적의 관계를 말할 수 있다 +- [x] `Observation`과 `ObservableObject`의 차이를 비교할 수 있다 + +## 이번 학습에서 내가 남길 정리 +- Observation의 핵심은 "상태 객체를 만든다"가 아니라 "어떤 읽기가 어떤 갱신을 만들었는지"를 이해하는 데 있다. +- SwiftUI와 SwiftData 학습에서 봤던 상태/저장 흐름이 Observation에서 하나로 이어진다. + +## 2026-03-17 구현 완료 메모 + +### 이번에 구현한 범위 +- `CartFlow/Views` 폴더의 주요 화면 구현을 완료했다. +- 구현 파일 + - `ProductListView.swift` + - `CartView.swift` + - `CheckoutView.swift` + - `PaymentResultView.swift` + - `CartRootView.swift` + - `ApplePayButton.swift` +- Apple Pay 결제 흐름을 보조하기 위해 `PassKit` import 범위와 결제 설정 타입도 함께 정리했다. + +### 화면별 Observation 관점 정리 +- `ProductListView` + - `@Environment(CartStore.self)`로 공유 store를 읽고, 화면 고유 상태는 `@State`로 분리했다. + - 상품 로딩, 검색어, 레이아웃 모드, 정렬 기준은 뷰 로컬 상태이며, 장바구니 담기 액션만 store에 위임한다. +- `CartView` + - 장바구니 목록, 합계, 빈 상태는 store의 읽기 결과에 따라 자동 갱신된다. + - 삭제 확인, 결제 이동 여부 같은 일회성 UI 상태는 로컬 `@State`로 유지했다. +- `CheckoutView` + - `@Bindable var cartStore`로 결제 직전 단계에서 store를 직접 참조한다. + - 배송 방법, 쿠폰, 에러 표시, 결과 sheet 노출은 checkout 화면 책임으로 두고, 실제 결제 후 장바구니 정리는 store 액션으로 연결했다. +- `CartRootView` + - 앱 루트에서 `@State private var cartStore = CartStore()`로 상태 소유권을 가진 뒤 `.environment(cartStore)`로 하위에 주입했다. + - Observation에서 루트 소유와 하위 공유가 어떻게 분리되는지 확인할 수 있다. +- `ApplePayButton` / `PaymentResultView` + - UIKit/PassKit 브리징과 결과 표현 UI를 SwiftUI로 감싸면서도, 결제 진행 상태는 별도 상태값으로 분리했다. + - 외부 서비스 상태와 화면 표현 상태를 분리하는 구조가 Observation 학습 포인트로 남는다. + +### 이번 구현에서 확인한 기준 +- 공유 도메인 상태는 `CartStore`가 소유한다. +- 뷰 전용 상태는 각 화면의 `@State`로 둔다. +- 수정 규칙은 store 메서드나 서비스 메서드로 위임한다. +- Observation 학습에서는 "store를 어디서 읽는가"와 "뷰가 어떤 값을 직접 가지는가"를 분리해서 보는 것이 중요하다. + +### 다음에 PR/회고에 옮길 핵심 문장 +- 이번 작업은 `CartFlow`의 Views 레이어를 완성하면서 Observation의 상태 소유권, 읽기 추적, 화면 로컬 상태 분리를 실제 UI 흐름으로 연결한 단계다. +- 특히 `@Environment(CartStore.self)`, `@Bindable`, `@State`가 각각 어디에서 필요한지 실제 결제 플로우 안에서 비교할 수 있게 됐다. + +## 수동 검증 메모 +- `ProductListView`에서 상품을 추가하면 장바구니 수량과 담김 상태가 즉시 반영되는 흐름을 기준으로 확인했다. +- `CartView`에서 수량을 증감하면 합계와 무료 배송 진행률이 같은 store 읽기 위에서 함께 갱신되는 흐름을 기준으로 확인했다. +- `CheckoutView`에서 결제 결과 sheet가 열리고 완료 액션 후 장바구니를 비우는 흐름을 최종 확인 포인트로 정리했다. + +## 학습 종료 회고 +- 공유 상태를 여러 화면에 전달하는 경우에는 `@Environment(CartStore.self)`가 가장 읽기 쉬웠고, 결제 직전처럼 store를 직접 넘겨 받아 다루는 구간에서는 `@Bindable`이 더 명확했다. +- 이번 구현에서는 검색어, 토스트, sheet, 탭 선택처럼 화면 수명주기에 묶인 값은 `@State`로 두고, 장바구니/결제 관련 도메인 상태만 `CartStore`에 남기는 기준을 세웠다. +- 같은 구조를 `ObservableObject`로 구현했다면 `@Published`, `@StateObject`, `@EnvironmentObject` 조합과 갱신 설명 비용이 더 늘어났을 것이고, 이번 학습에서는 그 차이를 실제 화면 흐름으로 체감할 수 있었다. diff --git a/practice/HIGPractice/HIGPractice/Learning/Phase-01-AppFrameworks/Observation/PR_DRAFT.md b/practice/HIGPractice/HIGPractice/Learning/Phase-01-AppFrameworks/Observation/PR_DRAFT.md new file mode 100644 index 00000000..89268874 --- /dev/null +++ b/practice/HIGPractice/HIGPractice/Learning/Phase-01-AppFrameworks/Observation/PR_DRAFT.md @@ -0,0 +1,44 @@ +# [학습] Observation - CartFlow Views 구현 + +## 요약 +- `CartFlow`의 `Views` 폴더 구현을 마무리했습니다. +- 상품 목록부터 장바구니, 체크아웃, 결제 결과까지 이어지는 UI 흐름을 연결했습니다. +- Observation 관점에서 `CartStore`의 상태 소유권과 화면별 로컬 상태 분리를 실제 코드로 정리했습니다. + +## 주요 변경 사항 +- `ProductListView` 구현 + - 상품 로딩, 검색, 카테고리 필터, 정렬, 그리드/리스트 전환 + - 장바구니 추가 토스트와 접근성 라벨 반영 +- `CartView` 구현 + - 장바구니 목록, 수량 변경, 삭제 확인, 무료 배송 진행률, 체크아웃 진입 +- `CheckoutView` 구현 + - 주문 요약, 배송 방법 선택, 쿠폰 적용, Apple Pay 결제 시작, 오류/결과 처리 +- `PaymentResultView` 구현 + - 결제 상태별 결과 표시, 주문/배송/결제 정보 요약, 영수증 공유 +- `CartRootView`, `ApplePayButton` 구현 + - 루트 상태 주입, 탭 구조, PassKit 버튼 브리징, 결제 진행 상태 UI 추가 +- 보조 수정 + - PassKit 관련 shared/service 타입 import 정리 + - Xcode project 파일에 신규 뷰 파일 반영 + +## Observation 학습 포인트 +- 루트에서 `@State`로 `CartStore`를 소유하고 `.environment(cartStore)`로 하위에 주입했습니다. +- 목록/장바구니 화면은 `@Environment(CartStore.self)`로 공유 상태를 읽도록 구성했습니다. +- 체크아웃 화면은 `@Bindable`로 store를 직접 참조해 결제 직전 상태를 다루도록 구성했습니다. +- 검색어, 탭 선택, 시트 표시, 토스트, 쿠폰 입력 같은 화면 전용 상태는 각 뷰의 `@State`로 분리했습니다. + +## 테스트 +- 별도 자동 테스트는 실행하지 못했습니다. +- 수동 확인 기준 + - 상품 추가 시 목록 화면과 장바구니 화면의 상태가 함께 갱신되는지 + - 수량 변경 시 합계와 무료 배송 진행률이 함께 반영되는지 + - 체크아웃 후 결과 화면 표시와 장바구니 비우기 흐름이 정상인지 + +## 이슈에 남길 내용 +- Observation 학습 범위 중 `CartFlow`의 Views 구현을 완료했습니다. +- 상태 소유권은 루트 `CartStore`, 화면 전용 상태는 각 뷰 `@State`로 나누는 기준을 실제 코드에 반영했습니다. +- `@Environment`, `@Bindable`, `@State`의 역할 차이를 상품 목록-장바구니-체크아웃 흐름에서 확인할 수 있게 정리했습니다. + +## 후속 정리 +- PR `#27` 생성 완료. `LEARNING_LOG.md`의 Observation 행에 PR 번호를 반영합니다. +- issue 체크리스트의 `PR 생성 및 CI 확인`, `머지 후 LEARNING_LOG / 회고 기록` 항목은 실제 PR 진행 시점에 마무리합니다. diff --git a/practice/HIGPractice/HIGPractice/Learning/Phase-01-AppFrameworks/SwiftData/ISSUE_DRAFT.md b/practice/HIGPractice/HIGPractice/Learning/Phase-01-AppFrameworks/SwiftData/ISSUE_DRAFT.md index 7ec28e0b..7830167e 100644 --- a/practice/HIGPractice/HIGPractice/Learning/Phase-01-AppFrameworks/SwiftData/ISSUE_DRAFT.md +++ b/practice/HIGPractice/HIGPractice/Learning/Phase-01-AppFrameworks/SwiftData/ISSUE_DRAFT.md @@ -36,3 +36,34 @@ - `@Query`와 `FetchDescriptor`의 사용 기준을 말할 수 있다. - 왜 SwiftData가 `UserDefaults`나 `Keychain`의 대체재가 아닌지 설명할 수 있다. - `SwiftData.md`에 파일별 메모와 비교 정리가 남아 있다. + +## 학습 목표에 대한 답 + +### `TaskMaster`를 기준으로 SwiftData의 앱 연결 지점을 설명할 수 있다 +- 앱 연결 시작점은 `TaskMasterApp.swift`의 `ModelContainer` 주입이다. +- 앱 루트에서 `.modelContainer(sharedModelContainer)`를 붙여야 하위 View가 같은 저장소를 기준으로 `@Environment(\.modelContext)`와 `@Query`를 사용할 수 있다. +- 즉 모델 정의는 `TaskItem`, `Category`에 있고, 저장소 연결은 앱 루트에 있고, 실제 CRUD는 각 View와 `DataService`에 흩어져 있다. + +### `@Model`, `ModelContainer`, `ModelContext`, `@Query`, `#Predicate`의 역할을 구분할 수 있다 +- `@Model`: SwiftData가 저장 가능한 모델로 인식하는 선언이다. 예: `TaskItem`, `Category` +- `ModelContainer`: 앱의 저장소 루트다. 어떤 모델들을 관리할지와 persistence 설정을 가진다. +- `ModelContext`: 생성, 수정, 삭제, fetch 같은 실제 작업 문맥이다. +- `@Query`: SwiftUI View 안에서 선언형으로 데이터를 읽고, 변경 시 UI 갱신까지 연결하는 방식이다. +- `#Predicate`: `FetchDescriptor`와 함께 쓰는 타입 안전한 조건식이다. 서비스/로직 코드에서 직접 쿼리 조건을 만들 때 유용하다. + +### `Create / Read / Update / Delete` 흐름이 `TaskMaster` 안에서 어디서 일어나는지 정리한다 +- Create: `AddTaskView.swift`에서 입력을 `@State`로 모은 뒤 `TaskItem(...)` 생성 후 `modelContext.insert(...)` +- Read: `ContentView.swift`에서 `@Query`로 `allTasks`, `categories`를 읽고 화면 상태로 메모리 필터링 +- Update: `TaskDetailView.swift`에서 `@Bindable var task`를 통해 제목, 메모, 우선순위, 카테고리 등을 직접 수정 +- Delete: `TaskDetailView.swift`와 `ContentView.swift`에서 `modelContext.delete(...)`, 대량 삭제는 `DataService.swift` + +### SwiftData와 `UserDefaults`, `Keychain`의 저장 목적과 적용 범위를 비교 설명할 수 있다 +- SwiftData: 구조화된 앱 데이터 저장소다. 목록, 관계, 정렬, 필터가 필요한 모델 데이터에 적합하다. +- UserDefaults: 가벼운 설정 저장소다. 온보딩 여부, 마지막 선택 탭 같은 작은 값 저장에 적합하다. +- Keychain: 보안 정보 저장소다. 토큰, 비밀번호, 키 같은 민감 정보 저장에 적합하다. +- 따라서 SwiftData는 일반 앱 데이터용이고, UserDefaults는 설정용이고, Keychain은 보안용이라 서로 대체재가 아니다. + +### 이후 `Observation` 학습으로 자연스럽게 넘어갈 수 있도록 데이터 흐름 메모를 남긴다 +- 이번 SwiftData 학습으로 "저장된 데이터가 어떻게 View 갱신과 연결되는가"를 정리했다. +- 다음 Observation 학습에서는 이 흐름을 "상태 소유권"과 "읽기 추적" 관점에서 다시 볼 수 있다. +- 즉 SwiftData가 저장 계층이라면, Observation은 그 상태가 화면에서 읽히고 갱신되는 계층을 더 선명하게 보여준다. diff --git a/samples/CartFlow/Shared/CartStore.swift b/samples/CartFlow/Shared/CartStore.swift index e1b23bff..b2432ea8 100644 --- a/samples/CartFlow/Shared/CartStore.swift +++ b/samples/CartFlow/Shared/CartStore.swift @@ -31,6 +31,7 @@ import Observation @Observable class CartStore { // MARK: - 상태 (자동 추적됨) + // stored property를 읽거나 바꾸는 순간 Observation이 접근을 추적한다. /// 장바구니 아이템 목록 var items: [CartItem] = [] @@ -47,6 +48,8 @@ class CartStore { // MARK: - 계산 속성 /// 카트 내 총 아이템 수량 + /// - 저장값이 아니라 items를 읽어 계산하는 파생 상태다. + /// - 이 값을 읽는 뷰는 결국 items 변화와 연결된다. var totalItemCount: Int { items.reduce(0) { $0 + $1.quantity } } @@ -70,6 +73,8 @@ class CartStore { } // MARK: - 카트 조작 메서드 + // 상태 변경 규칙을 View 밖이 아니라 Store 안에 둔다. + // 이렇게 해야 View는 "어떻게 바꿀지"보다 "무슨 행동을 요청할지"에 집중할 수 있다. /// 상품을 카트에 추가 /// - Parameters: @@ -140,6 +145,8 @@ class CartStore { func checkout() async { guard !isEmpty else { return } + // Observation은 async 자체를 처리하지 않는다. + // 대신 async 작업 전후에 바뀐 상태(isLoading, errorMessage, items)를 추적한다. isLoading = true errorMessage = nil