mycode/myApp/HaruDanim/IOS/Core/Store.swift
songyc macbook 14fc5cb5bd feat(premium): complete StoreKit 2 purchase experience
기존 StoreKit 2 골격(상품 3종·구매·복원·권한 주입) 위에 실사용 경험 완성:
- PremiumStore.currentPlan: 현재 이용 중인 대표 플랜(평생 > 만료 먼 구독)
  추적, 구매·복원 후 위젯 타임라인 즉시 갱신
- 프리미엄 화면: '이용 중인 플랜' 섹션(플랜 이름·다음 갱신일·평생 안내),
  구독 관리 시트(manageSubscriptionsSheet), 구독 중 평생 이용권 전환 안내,
  년 구독 절약률·평생 '한 번 결제' 배지, 자동 갱신·해지·복원 안내 문구
- 잠금 안내 개선: 행동/목표/다짐 무료 한도 알림에 '프리미엄 알아보기'
  버튼 + PremiumSheetView 모달, 아이패드 일기 잠금 화면에도 버튼 추가
- 기능 소개에 '아이패드 일기' 추가, 새 문자열 en/ja 번역

가격(월 5,000/년 50,000/일회성 80,000)은 HaruDanim.storekit(로컬 테스트)과
App Store Connect에서 각각 수정 가능. DEBUG 강제 토글은 그대로 유지.

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

186 lines
6.8 KiB
Swift

//
// Store.swift
// Haru_Danim
//
// StoreKit 2 (CLAUDE.md §1 , §7.2)
// - StoreKitEntitlementProvider: Transaction.currentEntitlements .
// PremiumManager.bootstrap .
// - PremiumStore: / / . PremiumView .
// - App Store Connect productID 3 .
// : HaruDanim.storekit .
//
import Foundation
import Observation
import StoreKit
import WidgetKit
// MARK: -
nonisolated enum PremiumProducts {
/// ( 5,000)
static let monthly = "com.yechan.HaruDanim.premium.monthly"
/// ( 50,000)
static let yearly = "com.yechan.HaruDanim.premium.yearly"
/// ( 80,000)
static let lifetime = "com.yechan.HaruDanim.premium.lifetime"
static let all: Set<String> = [monthly, yearly, lifetime]
///
static func sortOrder(_ id: String) -> Int {
switch id {
case monthly: return 0
case yearly: return 1
default: return 2
}
}
}
// MARK: - (PremiumManager )
/// StoreKit 2 EntitlementProvider
nonisolated struct StoreKitEntitlementProvider: EntitlementProvider {
func fetchIsEntitled() async -> Bool {
for await result in Transaction.currentEntitlements {
if case .verified(let transaction) = result,
PremiumProducts.all.contains(transaction.productID),
transaction.revocationDate == nil {
return true
}
}
return false
}
func entitlementUpdates() -> AsyncStream<Bool> {
AsyncStream { continuation in
let task = Task {
// ··
for await update in Transaction.updates {
if case .verified(let transaction) = update {
await transaction.finish()
}
continuation.yield(await fetchIsEntitled())
}
continuation.finish()
}
continuation.onTermination = { _ in task.cancel() }
}
}
}
// MARK: -
/// " "
struct PremiumPlan: Equatable {
let productID: String
/// () . nil
let expirationDate: Date?
var isLifetime: Bool { productID == PremiumProducts.lifetime }
///
var fallbackName: String {
switch productID {
case PremiumProducts.monthly: return String(localized: "월 구독")
case PremiumProducts.yearly: return String(localized: "년 구독")
default: return String(localized: "평생 이용권")
}
}
}
// MARK: - ( )
@MainActor
@Observable
final class PremiumStore {
static let shared = PremiumStore()
private(set) var products: [Product] = []
private(set) var isLoading = false
private(set) var isPurchasing = false
/// ( > ). · nil
private(set) var currentPlan: PremiumPlan?
/// (nil )
private(set) var lastErrorMessage: String?
/// App Store ( )
func loadProducts() async {
guard products.isEmpty, !isLoading else { return }
isLoading = true
defer { isLoading = false }
do {
let fetched = try await Product.products(for: PremiumProducts.all)
products = fetched.sorted {
PremiumProducts.sortOrder($0.id) < PremiumProducts.sortOrder($1.id)
}
lastErrorMessage = nil
} catch {
lastErrorMessage = String(localized: "상품 정보를 불러오지 못했어요. 네트워크를 확인해 주세요.")
}
}
/// . PremiumManager
func purchase(_ product: Product) async {
guard !isPurchasing else { return }
isPurchasing = true
defer { isPurchasing = false }
do {
let result = try await product.purchase()
switch result {
case .success(let verification):
if case .verified(let transaction) = verification {
await transaction.finish()
}
await didChangeEntitlement()
lastErrorMessage = nil
case .userCancelled, .pending:
break
@unknown default:
break
}
} catch {
lastErrorMessage = String(localized: "구매를 완료하지 못했어요. 잠시 후 다시 시도해 주세요.")
}
}
/// ( · )
func restore() async {
try? await AppStore.sync()
await didChangeEntitlement()
}
/// ( ·· )
func refreshEntitlement() async {
var best: PremiumPlan?
for await result in Transaction.currentEntitlements {
guard case .verified(let transaction) = result,
PremiumProducts.all.contains(transaction.productID),
transaction.revocationDate == nil else { continue }
let plan = PremiumPlan(productID: transaction.productID,
expirationDate: transaction.expirationDate)
if plan.isLifetime {
best = plan
break
}
if best == nil
|| (plan.expirationDate ?? .distantFuture) > (best?.expirationDate ?? .distantPast) {
best = plan
}
}
currentPlan = best
}
/// ( )
func planDisplayName(_ plan: PremiumPlan) -> String {
products.first { $0.id == plan.productID }?.displayName ?? plan.fallbackName
}
/// · : ·
private func didChangeEntitlement() async {
await PremiumManager.shared.refresh()
await refreshEntitlement()
WidgetCenter.shared.reloadAllTimelines()
}
}