mycode/myApp/HaruDanim/Shared/Premium.swift
songyc macbook 26410006a6 feat(premium): 프리미엄 아키텍처 도입 (EntitlementProvider/PremiumManager/PremiumGate)
- 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013yKDMhuF39GVYy3FMcGHh7
2026-07-10 08:58:58 +09:00

172 lines
5.7 KiB
Swift

//
// 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<Bool>
}
/// :
nonisolated struct MockEntitlementProvider: EntitlementProvider {
func fetchIsEntitled() async -> Bool {
PremiumGate.isPremium
}
func entitlementUpdates() -> AsyncStream<Bool> {
// 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)
}
}