From 26410006a67ad206f3c0f61e856293a4f286af9f Mon Sep 17 00:00:00 2001 From: songyc macbook Date: Fri, 10 Jul 2026 08:58:58 +0900 Subject: [PATCH] =?UTF-8?q?feat(premium):=20=ED=94=84=EB=A6=AC=EB=AF=B8?= =?UTF-8?q?=EC=97=84=20=EC=95=84=ED=82=A4=ED=85=8D=EC=B2=98=20=EB=8F=84?= =?UTF-8?q?=EC=9E=85=20(EntitlementProvider/PremiumManager/PremiumGate)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - EntitlementProvider 프로토콜로 권한 조회 추상화 — StoreKit 2 도입 시 구현체만 교체 - PremiumManager(@Observable): 앱 UI의 단일 진입점, canAddAction/Goal/Quest 게이트 - PremiumGate: 위젯·워치·인텐트용 App Group defaults 동기 캐시 (기존 값 1회 이관) - 행동/목표/다짐 개수 제한 체크를 @AppStorage 직접 참조에서 매니저 경유로 교체 - 앱 entitlements: App Group / iCloud(CloudKit) / Siri - DEBUG 검증 인자: -premium YES/NO Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013yKDMhuF39GVYy3FMcGHh7 --- myApp/HaruDanim/Haru_Danim.entitlements | 22 +++ myApp/HaruDanim/IOS/Views/ActionViews.swift | 4 +- myApp/HaruDanim/IOS/Views/GoalViews.swift | 8 +- myApp/HaruDanim/Shared/Premium.swift | 171 ++++++++++++++++++++ 4 files changed, 199 insertions(+), 6 deletions(-) create mode 100644 myApp/HaruDanim/Haru_Danim.entitlements create mode 100644 myApp/HaruDanim/Shared/Premium.swift 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) + } +}