diff --git a/myApp/HaruDanim/Haru_Danim.entitlements b/myApp/HaruDanim/Haru_Danim.entitlements
new file mode 100644
index 0000000..3f29113
--- /dev/null
+++ b/myApp/HaruDanim/Haru_Danim.entitlements
@@ -0,0 +1,22 @@
+
+
+
+
+ aps-environment
+ development
+ com.apple.developer.icloud-container-identifiers
+
+ iCloud.com.yechan.HaruDanim
+
+ com.apple.developer.icloud-services
+
+ CloudKit
+
+ com.apple.developer.siri
+
+ com.apple.security.application-groups
+
+ group.com.yechan.HaruDanim
+
+
+
diff --git a/myApp/HaruDanim/IOS/Views/ActionViews.swift b/myApp/HaruDanim/IOS/Views/ActionViews.swift
index 72c3988..c6c20a3 100644
--- a/myApp/HaruDanim/IOS/Views/ActionViews.swift
+++ b/myApp/HaruDanim/IOS/Views/ActionViews.swift
@@ -14,7 +14,7 @@ struct ActionListView: View {
@Environment(\.modelContext) private var context
@Query(sort: [SortDescriptor(\Tag.sortOrder), SortDescriptor(\Tag.createdAt)]) private var tags: [Tag]
@Query(sort: \Action.sortOrder) private var actions: [Action]
- @AppStorage(SettingsKeys.isPremium) private var isPremium = false
+ private let premium = PremiumManager.shared
@State private var showingAdd = false
@State private var showLimitAlert = false
@@ -87,7 +87,7 @@ struct ActionListView: View {
}
ToolbarItem(placement: .topBarTrailing) {
Button {
- if !isPremium && actions.count >= FreeLimits.actions {
+ if !premium.canAddAction(currentCount: actions.count) {
showLimitAlert = true
} else {
showingAdd = true
diff --git a/myApp/HaruDanim/IOS/Views/GoalViews.swift b/myApp/HaruDanim/IOS/Views/GoalViews.swift
index cc7a143..4e09324 100644
--- a/myApp/HaruDanim/IOS/Views/GoalViews.swift
+++ b/myApp/HaruDanim/IOS/Views/GoalViews.swift
@@ -13,7 +13,7 @@ import SwiftData
struct GoalListView: View {
@Environment(\.modelContext) private var context
@Query(sort: [SortDescriptor(\Goal.sortOrder), SortDescriptor(\Goal.createdAt)]) private var goals: [Goal]
- @AppStorage(SettingsKeys.isPremium) private var isPremium = false
+ private let premium = PremiumManager.shared
@State private var showingAdd = false
@State private var showLimitAlert = false
@@ -119,7 +119,7 @@ struct GoalListView: View {
ToolbarItem(placement: .topBarTrailing) {
if !isReordering {
Button {
- if !isPremium && goals.count >= FreeLimits.goals {
+ if !premium.canAddGoal(currentCount: goals.count) {
showLimitAlert = true
} else {
showingAdd = true
@@ -370,7 +370,7 @@ struct FinishedGoalListView: View {
struct GoalDetailView: View {
@Environment(\.modelContext) private var context
@Environment(\.dismiss) private var dismiss
- @AppStorage(SettingsKeys.isPremium) private var isPremium = false
+ private let premium = PremiumManager.shared
let goal: Goal
@State private var showingEdit = false
@@ -411,7 +411,7 @@ struct GoalDetailView: View {
}
}
Button {
- if !isPremium && goal.quests.count >= FreeLimits.questsPerGoal {
+ if !premium.canAddQuest(currentCount: goal.quests.count) {
showQuestLimitAlert = true
} else {
showingAddQuest = true
diff --git a/myApp/HaruDanim/Shared/Premium.swift b/myApp/HaruDanim/Shared/Premium.swift
new file mode 100644
index 0000000..a1ab9de
--- /dev/null
+++ b/myApp/HaruDanim/Shared/Premium.swift
@@ -0,0 +1,171 @@
+//
+// Premium.swift
+// Haru_Danim
+//
+// 프리미엄 권한 레이어 (CLAUDE.md §7)
+// - EntitlementProvider: 권한 조회를 추상화. 지금은 Mock(설정 토글), StoreKit 2 도입 시
+// Transaction.currentEntitlements 기반 구현으로 교체해 PremiumManager에 주입한다.
+// - PremiumManager: 앱 UI가 관찰하는 단일 진입점 (@Observable)
+// - PremiumGate: 위젯·워치·인텐트 등 확장에서 동기적으로 읽는 캐시 (App Group UserDefaults)
+//
+
+import Foundation
+import Observation
+
+// MARK: - App Group
+
+/// 앱·위젯·워치가 공유하는 App Group 컨테이너
+nonisolated enum AppGroup {
+ static let identifier = "group.com.yechan.HaruDanim"
+
+ static var defaults: UserDefaults {
+ UserDefaults(suiteName: identifier) ?? .standard
+ }
+}
+
+// MARK: - 무료 한도 / 프리미엄 기능
+
+/// 무료 사용 한도 (CLAUDE.md §7.1)
+nonisolated enum FreeLimits {
+ static let actions = 10
+ static let goals = 2
+ static let questsPerGoal = 3
+}
+
+/// 프리미엄으로 잠금 해제되는 기능 (CLAUDE.md §7.2)
+nonisolated enum PremiumFeature {
+ /// 행동·목표·다짐 개수 무제한
+ case unlimitedItems
+ /// 홈 화면 위젯
+ case homeWidgets
+ /// 잠금화면 위젯
+ case lockScreenWidgets
+ /// 애플워치 앱 + 컴플리케이션
+ case watchApp
+ /// 시리 단축어 (App Intents)
+ case siriShortcuts
+ /// iCloud 기기 간 동기화
+ case cloudSync
+}
+
+// MARK: - 권한 제공자 (StoreKit 주입 지점)
+
+/// 프리미엄 권한 조회 추상화.
+/// StoreKit 2 도입 시 `Transaction.currentEntitlements`를 조회하고
+/// `Transaction.updates`를 entitlementUpdates()로 흘려보내는 구현으로 교체한다.
+nonisolated protocol EntitlementProvider: Sendable {
+ /// 현재 프리미엄 권한 보유 여부 조회
+ func fetchIsEntitled() async -> Bool
+ /// 구매·환불 등 권한 변경 이벤트 스트림
+ func entitlementUpdates() -> AsyncStream
+}
+
+/// 결제 연동 전 임시 제공자: 설정 탭의 테스트 토글이 저장한 값을 그대로 사용
+nonisolated struct MockEntitlementProvider: EntitlementProvider {
+ func fetchIsEntitled() async -> Bool {
+ PremiumGate.isPremium
+ }
+
+ func entitlementUpdates() -> AsyncStream {
+ // Mock은 외부 변경 이벤트가 없음 (토글은 PremiumManager를 직접 거침)
+ AsyncStream { $0.finish() }
+ }
+}
+
+// MARK: - 확장용 동기 게이트
+
+/// 위젯·워치·App Intents처럼 PremiumManager를 띄우기 어려운 곳에서
+/// App Group에 캐시된 권한을 동기적으로 읽는 게이트
+nonisolated enum PremiumGate {
+ /// 기존 UserDefaults.standard 시절과 같은 키 (마이그레이션 호환)
+ static let cacheKey = "settings.isPremium"
+
+ static var isPremium: Bool {
+ AppGroup.defaults.bool(forKey: cacheKey)
+ }
+
+ static func isUnlocked(_ feature: PremiumFeature) -> Bool {
+ // 현재는 모든 프리미엄 기능이 단일 권한. 기능별 차등은 여기서 분기
+ isPremium
+ }
+
+ static func cache(_ value: Bool) {
+ AppGroup.defaults.set(value, forKey: cacheKey)
+ }
+}
+
+// MARK: - 프리미엄 매니저
+
+/// 앱에서 프리미엄 권한을 확인하는 단일 진입점.
+/// 뷰는 `PremiumManager.shared.isPremium` 또는 canAdd~ / isUnlocked를 사용한다.
+@MainActor
+@Observable
+final class PremiumManager {
+ static let shared = PremiumManager()
+
+ private(set) var isPremium: Bool
+ @ObservationIgnored private let provider: any EntitlementProvider
+
+ init(provider: any EntitlementProvider = MockEntitlementProvider()) {
+ self.provider = provider
+ Self.migrateLegacyFlagIfNeeded()
+ #if DEBUG
+ // 검증용 런치 인자: -premium YES/NO → 권한 강제 설정
+ if UserDefaults.standard.object(forKey: "premium") != nil {
+ PremiumGate.cache(UserDefaults.standard.bool(forKey: "premium"))
+ }
+ #endif
+ self.isPremium = PremiumGate.isPremium
+ Task { [weak self] in
+ guard let self else { return }
+ self.apply(await self.provider.fetchIsEntitled())
+ for await value in self.provider.entitlementUpdates() {
+ self.apply(value)
+ }
+ }
+ }
+
+ // MARK: 기능 게이트
+
+ func isUnlocked(_ feature: PremiumFeature) -> Bool {
+ // 현재는 모든 프리미엄 기능이 단일 권한. 기능별 차등은 여기서 분기
+ isPremium
+ }
+
+ func canAddAction(currentCount: Int) -> Bool {
+ isUnlocked(.unlimitedItems) || currentCount < FreeLimits.actions
+ }
+
+ func canAddGoal(currentCount: Int) -> Bool {
+ isUnlocked(.unlimitedItems) || currentCount < FreeLimits.goals
+ }
+
+ func canAddQuest(currentCount: Int) -> Bool {
+ isUnlocked(.unlimitedItems) || currentCount < FreeLimits.questsPerGoal
+ }
+
+ // MARK: 상태 갱신
+
+ /// 결제/복원 후 권한 재조회 (StoreKit 연동 시 구매 완료 지점에서 호출)
+ func refresh() async {
+ apply(await provider.fetchIsEntitled())
+ }
+
+ /// 설정 탭 테스트 토글용. StoreKit 도입 후에는 DEBUG 전용으로 남긴다
+ func setMockPremium(_ value: Bool) {
+ apply(value)
+ }
+
+ private func apply(_ value: Bool) {
+ isPremium = value
+ PremiumGate.cache(value)
+ }
+
+ /// 기존 UserDefaults.standard의 settings.isPremium 값을 App Group으로 1회 이관
+ private static func migrateLegacyFlagIfNeeded() {
+ let standard = UserDefaults.standard
+ guard AppGroup.defaults.object(forKey: PremiumGate.cacheKey) == nil,
+ let legacy = standard.object(forKey: PremiumGate.cacheKey) as? Bool else { return }
+ PremiumGate.cache(legacy)
+ }
+}