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
This commit is contained in:
songyc macbook 2026-07-10 08:58:58 +09:00
parent 0509285a04
commit 26410006a6
4 changed files with 199 additions and 6 deletions

View File

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>aps-environment</key>
<string>development</string>
<key>com.apple.developer.icloud-container-identifiers</key>
<array>
<string>iCloud.com.yechan.HaruDanim</string>
</array>
<key>com.apple.developer.icloud-services</key>
<array>
<string>CloudKit</string>
</array>
<key>com.apple.developer.siri</key>
<true/>
<key>com.apple.security.application-groups</key>
<array>
<string>group.com.yechan.HaruDanim</string>
</array>
</dict>
</plist>

View File

@ -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

View File

@ -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

View File

@ -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<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)
}
}