mycode/myApp/HaruDanim/Shared/Premium.swift
songyc macbook 40d917d4e2 feat(diary): add iPad-only premium diary tab with calendar, pencil notes, and export
- 달력(작성일 마커: 기분 이모지/연필 점) → 날짜별 일기, 이번 달 작성 수 표시
- 첫 페이지: 날짜·오늘 기분(이모지/사진, 끄기 옵션)·할 일 체크리스트(스크리블 입력)·
  타임테이블·통계(내보내기 스냅숏 재사용으로 화면·내보내기 일치)
- 노트 페이지: PencilKit 캔버스(도구 팔레트) + 줄 노트 배경 + 사진/도형(사각형·원·화살표·선)
  배치 모드(드래그·핀치), 페이지 무한 추가 — 논리 크기 768×1024 고정으로 WYSIWYG 내보내기
- 내보내기: 구간/복수 날짜(MultiDatePicker) → PDF 1개 또는 날짜별 세로 스티치 PNG
- SwiftData 모델 4종(CloudKit 호환 패턴) 스키마 추가, PremiumFeature.diary,
  iPhone 탭 컨텍스트(탭바·더보기·라디얼)에서 일기 제외, en/ja 번역 39건
- 검증 인자: -diarySeed/-diaryOpenToday/-diaryPage N/-diaryShowExport/-diaryExportRun pdf|image

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013yKDMhuF39GVYy3FMcGHh7
2026-07-13 02:51:30 +09:00

202 lines
7.2 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"
/// : @AppStorage(store:)
/// UserDefaults
/// ( )
static let 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
/// iPad ()
case diary
}
// 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 private(set) var shared = PremiumManager()
/// (StoreKitEntitlementProvider) .
/// (·) bootstrap Mock + PremiumGate .
static func bootstrap(provider: any EntitlementProvider) {
shared = PremiumManager(provider: provider)
}
private(set) var isPremium: Bool
@ObservationIgnored private let provider: any EntitlementProvider
#if DEBUG
/// (-premium · )
/// StoreKit
static let mockActiveKey = "debug.premiumMockActive"
private var isMockActive: Bool {
UserDefaults.standard.object(forKey: "premium") != nil
|| AppGroup.defaults.bool(forKey: Self.mockActiveKey)
}
#endif
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.applyFromProvider(await self.provider.fetchIsEntitled())
for await value in self.provider.entitlementUpdates() {
self.applyFromProvider(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:
/// / ( )
func refresh() async {
applyFromProvider(await provider.fetchIsEntitled())
}
#if DEBUG
/// (DEBUG ).
func setMockPremium(_ value: Bool) {
AppGroup.defaults.set(true, forKey: Self.mockActiveKey)
apply(value)
}
#endif
/// (StoreKit) . DEBUG
private func applyFromProvider(_ value: Bool) {
#if DEBUG
guard !isMockActive else { return }
#endif
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)
}
}