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:
parent
0509285a04
commit
26410006a6
22
myApp/HaruDanim/Haru_Danim.entitlements
Normal file
22
myApp/HaruDanim/Haru_Danim.entitlements
Normal 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>
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
171
myApp/HaruDanim/Shared/Premium.swift
Normal file
171
myApp/HaruDanim/Shared/Premium.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user