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
This commit is contained in:
parent
c6959950c2
commit
14fc5cb5bd
@ -13,6 +13,7 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
import StoreKit
|
||||
import WidgetKit
|
||||
|
||||
// MARK: - 상품 정의
|
||||
|
||||
@ -68,6 +69,26 @@ nonisolated struct StoreKitEntitlementProvider: EntitlementProvider {
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
@ -78,6 +99,8 @@ final class 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?
|
||||
|
||||
@ -109,7 +132,7 @@ final class PremiumStore {
|
||||
if case .verified(let transaction) = verification {
|
||||
await transaction.finish()
|
||||
}
|
||||
await PremiumManager.shared.refresh()
|
||||
await didChangeEntitlement()
|
||||
lastErrorMessage = nil
|
||||
case .userCancelled, .pending:
|
||||
break
|
||||
@ -124,6 +147,39 @@ final class PremiumStore {
|
||||
/// 구매 복원 (기기 변경·재설치 시)
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
@ -483,6 +483,22 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"%lld%% 절약": {
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Save %lld%%"
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "%lld%%お得"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"%lld/%lld": {
|
||||
"localizations": {
|
||||
"en": {
|
||||
@ -1244,7 +1260,40 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"구독 관리 (플랜 변경·해지)": {
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Manage Subscription (change or cancel)"
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "サブスクリプション管理(変更・解約)"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"구독은 결제 확인 시 Apple 계정으로 청구되고, 기간이 끝나기 24시간 전까지 해지하지 않으면 자동 갱신돼요. 해지는 언제든 App Store 구독 관리에서 할 수 있어요. 평생 이용권은 한 번 결제로 모든 기능을 계속 사용해요. 기기를 바꿨다면 '구매 복원'을 눌러 주세요.": {
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Subscriptions are charged to your Apple account at purchase confirmation and renew automatically unless cancelled at least 24 hours before the period ends. You can cancel anytime in App Store subscription settings. The lifetime plan is a one-time purchase that keeps every feature forever. Changed devices? Tap 'Restore Purchases'."
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "サブスクリプションは購入確定時にAppleアカウントに請求され、期間終了の24時間前までに解約しない限り自動更新されます。解約はいつでもApp Storeのサブスクリプション管理から行えます。買い切りは1回の購入ですべての機能をずっと利用できます。機種変更した場合は「購入を復元」をタップしてください。"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"구독은 언제든 해지할 수 있고, 평생 이용권은 한 번 결제로 모든 기능을 계속 사용해요.": {
|
||||
"extractionState": "stale",
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
@ -1260,6 +1309,22 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"구독을 해지해도 이미 결제한 기간이 끝날 때까지는 계속 사용할 수 있어요.": {
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Even after cancelling, you can keep using premium until the paid period ends."
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "解約しても、支払い済みの期間が終わるまでは引き続き利用できます。"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"구매 복원": {
|
||||
"localizations": {
|
||||
"en": {
|
||||
@ -2067,6 +2132,22 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"년 구독": {
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Yearly"
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "年額プラン"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"누적 기록": {
|
||||
"localizations": {
|
||||
"en": {
|
||||
@ -2131,6 +2212,22 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"다음 갱신: %@": {
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Renews on %@"
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "次回更新: %@"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"다이나믹 아일랜드 · 잠금화면": {
|
||||
"localizations": {
|
||||
"en": {
|
||||
@ -3580,6 +3677,7 @@
|
||||
}
|
||||
},
|
||||
"무료 버전에서는 목표당 다짐을 최대 %lld개까지 만들 수 있어요.": {
|
||||
"extractionState": "stale",
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
@ -3595,6 +3693,22 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"무료 버전에서는 목표당 다짐을 최대 %lld개까지 만들 수 있어요. 프리미엄으로 업그레이드하면 무제한으로 사용할 수 있습니다.": {
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "The free version allows up to %lld quests per goal. Upgrade to premium for unlimited quests."
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "無料版では目標ごとに最大%lld個のクエストを作成できます。プレミアムにアップグレードすると無制限になります。"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"무료 버전에서는 목표를 최대 %lld개까지 만들 수 있어요. 프리미엄으로 업그레이드하면 무제한으로 사용할 수 있습니다.": {
|
||||
"localizations": {
|
||||
"en": {
|
||||
@ -5380,6 +5494,22 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"월 구독": {
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Monthly"
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "月額プラン"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"월간": {
|
||||
"localizations": {
|
||||
"en": {
|
||||
@ -5845,6 +5975,22 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"이용 중인 플랜": {
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Current Plan"
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "ご利用中のプラン"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"이하 유지": {
|
||||
"localizations": {
|
||||
"en": {
|
||||
@ -7335,6 +7481,54 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"평생 이용권": {
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Lifetime"
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "買い切り"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"평생 이용권으로 전환": {
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Switch to Lifetime"
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "買い切りに切り替え"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"평생 이용권을 구매한 뒤에는 구독 관리에서 기존 구독을 해지해 주세요. 결제가 겹치지 않게 남은 구독 기간이 끝날 즈음 구매하는 것도 좋은 방법이에요.": {
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "After buying the lifetime plan, cancel your subscription in subscription settings. To avoid overlapping charges, you can also buy it near the end of your current period."
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "買い切りを購入した後は、サブスクリプション管理で既存のサブスクリプションを解約してください。支払いが重ならないよう、残りの期間が終わる頃に購入するのもおすすめです。"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"표시 방식": {
|
||||
"localizations": {
|
||||
"en": {
|
||||
@ -7479,6 +7673,38 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"프리미엄 알아보기": {
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Explore Premium"
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "プレミアムを見る"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"프리미엄 이용 중": {
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Premium active"
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "プレミアム利用中"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"프리미엄으로 할 수 있는 것": {
|
||||
"localizations": {
|
||||
"en": {
|
||||
@ -7704,7 +7930,24 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"하루의 기록·통계와 함께 애플 펜슬로 쓰는\n나만의 다이어리를 만들 수 있어요.": {
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Create your own diary with Apple Pencil,\nalongside your daily records and stats."
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "1日の記録・統計とともに、Apple Pencilで書く\n自分だけのダイアリーが作れます。"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"하루의 기록·통계와 함께 애플 펜슬로 쓰는\n나만의 다이어리를 만들 수 있어요.\n설정 → 프리미엄에서 잠금 해제해 주세요.": {
|
||||
"extractionState": "stale",
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
@ -7816,6 +8059,38 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"한 번 결제": {
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "One-time"
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "1回払い"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"한 번 결제로 모든 기능을 계속 사용할 수 있어요": {
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "One purchase keeps every feature forever"
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "1回の購入ですべての機能をずっと利用できます"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"한 줄에 표시할 개수": {
|
||||
"localizations": {
|
||||
"en": {
|
||||
|
||||
@ -18,6 +18,7 @@ struct ActionListView: View {
|
||||
|
||||
@State private var showingAdd = false
|
||||
@State private var showLimitAlert = false
|
||||
@State private var showingPremiumSheet = false
|
||||
@State private var showingTagOrder = false
|
||||
|
||||
// 행동 표시 순서는 모음 탭 배치와 같은 기기별 로컬 설정을 따른다 (LocalPrefs 참고)
|
||||
@ -111,10 +112,14 @@ struct ActionListView: View {
|
||||
TagOrderSheet()
|
||||
}
|
||||
.alert("무료 사용 한도", isPresented: $showLimitAlert) {
|
||||
Button("프리미엄 알아보기") { showingPremiumSheet = true }
|
||||
Button("확인", role: .cancel) {}
|
||||
} message: {
|
||||
Text("무료 버전에서는 행동을 최대 \(FreeLimits.actions)개까지 만들 수 있어요. 프리미엄으로 업그레이드하면 무제한으로 사용할 수 있습니다.")
|
||||
}
|
||||
.sheet(isPresented: $showingPremiumSheet) {
|
||||
PremiumSheetView()
|
||||
}
|
||||
}
|
||||
|
||||
private func actionLink(_ action: Action) -> some View {
|
||||
|
||||
@ -192,6 +192,8 @@ struct DiaryRootView: View {
|
||||
// MARK: - 프리미엄 잠금 안내
|
||||
|
||||
struct DiaryLockedView: View {
|
||||
@State private var showingPremiumSheet = false
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 12) {
|
||||
Image(systemName: "crown.fill")
|
||||
@ -199,12 +201,25 @@ struct DiaryLockedView: View {
|
||||
.foregroundStyle(AppTheme.yellow)
|
||||
Text("일기는 프리미엄 기능이에요")
|
||||
.font(.title3.weight(.semibold))
|
||||
Text("하루의 기록·통계와 함께 애플 펜슬로 쓰는\n나만의 다이어리를 만들 수 있어요.\n설정 → 프리미엄에서 잠금 해제해 주세요.")
|
||||
Text("하루의 기록·통계와 함께 애플 펜슬로 쓰는\n나만의 다이어리를 만들 수 있어요.")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
Button {
|
||||
showingPremiumSheet = true
|
||||
} label: {
|
||||
Label("프리미엄 알아보기", systemImage: "crown")
|
||||
.padding(.horizontal, 6)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.buttonBorderShape(.capsule)
|
||||
.tint(AppTheme.green)
|
||||
.padding(.top, 6)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.sheet(isPresented: $showingPremiumSheet) {
|
||||
PremiumSheetView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -17,6 +17,7 @@ struct GoalListView: View {
|
||||
|
||||
@State private var showingAdd = false
|
||||
@State private var showLimitAlert = false
|
||||
@State private var showingPremiumSheet = false
|
||||
@State private var isReordering = false
|
||||
@State private var editMode: EditMode = .inactive
|
||||
|
||||
@ -138,10 +139,14 @@ struct GoalListView: View {
|
||||
GoalEditorView(goal: nil)
|
||||
}
|
||||
.alert("무료 사용 한도", isPresented: $showLimitAlert) {
|
||||
Button("프리미엄 알아보기") { showingPremiumSheet = true }
|
||||
Button("확인", role: .cancel) {}
|
||||
} message: {
|
||||
Text("무료 버전에서는 목표를 최대 \(FreeLimits.goals)개까지 만들 수 있어요. 프리미엄으로 업그레이드하면 무제한으로 사용할 수 있습니다.")
|
||||
}
|
||||
.sheet(isPresented: $showingPremiumSheet) {
|
||||
PremiumSheetView()
|
||||
}
|
||||
.onAppear {
|
||||
let math = DayMath()
|
||||
var statusChanged = false
|
||||
@ -392,6 +397,7 @@ struct GoalDetailView: View {
|
||||
@State private var showingDelete = false
|
||||
@State private var showingAddQuest = false
|
||||
@State private var showQuestLimitAlert = false
|
||||
@State private var showingPremiumSheet = false
|
||||
@State private var showingManualFinish = false
|
||||
@State private var askManualResult = false
|
||||
@State private var editingQuest: Quest?
|
||||
@ -492,9 +498,13 @@ struct GoalDetailView: View {
|
||||
QuestEditorView(goal: goal, quest: quest)
|
||||
}
|
||||
.alert("무료 사용 한도", isPresented: $showQuestLimitAlert) {
|
||||
Button("프리미엄 알아보기") { showingPremiumSheet = true }
|
||||
Button("확인", role: .cancel) {}
|
||||
} message: {
|
||||
Text("무료 버전에서는 목표당 다짐을 최대 \(FreeLimits.questsPerGoal)개까지 만들 수 있어요.")
|
||||
Text("무료 버전에서는 목표당 다짐을 최대 \(FreeLimits.questsPerGoal)개까지 만들 수 있어요. 프리미엄으로 업그레이드하면 무제한으로 사용할 수 있습니다.")
|
||||
}
|
||||
.sheet(isPresented: $showingPremiumSheet) {
|
||||
PremiumSheetView()
|
||||
}
|
||||
.alert("목표 수동 종료", isPresented: $showingManualFinish) {
|
||||
Button("종료") {
|
||||
|
||||
@ -329,6 +329,7 @@ struct PremiumView: View {
|
||||
private let premium = PremiumManager.shared
|
||||
private let store = PremiumStore.shared
|
||||
@AppStorage(DataStore.cloudSyncKey, store: AppGroup.defaults) private var cloudSync = false
|
||||
@State private var showingManageSubscriptions = false
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
@ -337,12 +338,14 @@ struct PremiumView: View {
|
||||
featureRow("square.grid.2x2.fill", "홈·잠금화면 위젯", "위젯에서 바로 행동을 실행하고 달성률을 확인해요.")
|
||||
featureRow("icloud.fill", "아이패드 동기화", "iCloud로 기기 간 데이터가 동기화돼요.")
|
||||
featureRow("applewatch", "애플워치 앱", "워치에서 추적하고 컴플리케이션으로 확인해요.")
|
||||
featureRow("book.closed.fill", "아이패드 일기", "하루 정리와 애플 펜슬 다이어리를 사용해요.")
|
||||
} header: {
|
||||
Text("프리미엄으로 할 수 있는 것")
|
||||
} footer: {
|
||||
Text("무료: 행동 \(FreeLimits.actions)개 · 목표 \(FreeLimits.goals)개 · 목표당 다짐 \(FreeLimits.questsPerGoal)개까지")
|
||||
}
|
||||
if premium.isPremium {
|
||||
currentPlanSection
|
||||
Section {
|
||||
Toggle("기기 간 동기화", isOn: $cloudSync)
|
||||
} header: {
|
||||
@ -350,6 +353,17 @@ struct PremiumView: View {
|
||||
} footer: {
|
||||
Text("iCloud로 아이패드 등 다른 기기와 데이터가 동기화돼요. 변경 사항은 앱을 완전히 종료했다가 다시 실행하면 적용돼요.")
|
||||
}
|
||||
// 구독 이용 중이면 평생 이용권 전환 안내
|
||||
if let plan = store.currentPlan, !plan.isLifetime,
|
||||
let lifetime = store.products.first(where: { $0.id == PremiumProducts.lifetime }) {
|
||||
Section {
|
||||
productRow(lifetime)
|
||||
} header: {
|
||||
Text("평생 이용권으로 전환")
|
||||
} footer: {
|
||||
Text("평생 이용권을 구매한 뒤에는 구독 관리에서 기존 구독을 해지해 주세요. 결제가 겹치지 않게 남은 구독 기간이 끝날 즈음 구매하는 것도 좋은 방법이에요.")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
purchaseSection
|
||||
}
|
||||
@ -367,8 +381,56 @@ struct PremiumView: View {
|
||||
.navigationTitle("프리미엄")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar(.hidden, for: .tabBar)
|
||||
.manageSubscriptionsSheet(isPresented: $showingManageSubscriptions)
|
||||
.task {
|
||||
await store.loadProducts()
|
||||
await store.refreshEntitlement()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: 이용 중인 플랜
|
||||
|
||||
@ViewBuilder
|
||||
private var currentPlanSection: some View {
|
||||
Section {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: "crown.fill")
|
||||
.font(.system(size: 20))
|
||||
.foregroundStyle(AppTheme.yellow)
|
||||
.frame(width: 30)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
if let plan = store.currentPlan {
|
||||
Text(store.planDisplayName(plan))
|
||||
.font(.body.weight(.semibold))
|
||||
if plan.isLifetime {
|
||||
Text("한 번 결제로 모든 기능을 계속 사용할 수 있어요")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
} else if let expiration = plan.expirationDate {
|
||||
Text("다음 갱신: \(expiration.formatted(.dateTime.year().month().day()))")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
} else {
|
||||
Text("프리미엄 이용 중")
|
||||
.font(.body.weight(.semibold))
|
||||
}
|
||||
}
|
||||
}
|
||||
if let plan = store.currentPlan, !plan.isLifetime {
|
||||
Button {
|
||||
showingManageSubscriptions = true
|
||||
} label: {
|
||||
Label("구독 관리 (플랜 변경·해지)", systemImage: "creditcard")
|
||||
.font(.callout)
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("이용 중인 플랜")
|
||||
} footer: {
|
||||
if let plan = store.currentPlan, !plan.isLifetime {
|
||||
Text("구독을 해지해도 이미 결제한 기간이 끝날 때까지는 계속 사용할 수 있어요.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -412,7 +474,7 @@ struct PremiumView: View {
|
||||
if let message = store.lastErrorMessage {
|
||||
Text(message).foregroundStyle(.red)
|
||||
} else {
|
||||
Text("구독은 언제든 해지할 수 있고, 평생 이용권은 한 번 결제로 모든 기능을 계속 사용해요.")
|
||||
Text("구독은 결제 확인 시 Apple 계정으로 청구되고, 기간이 끝나기 24시간 전까지 해지하지 않으면 자동 갱신돼요. 해지는 언제든 App Store 구독 관리에서 할 수 있어요. 평생 이용권은 한 번 결제로 모든 기능을 계속 사용해요. 기기를 바꿨다면 '구매 복원'을 눌러 주세요.")
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -423,9 +485,19 @@ struct PremiumView: View {
|
||||
} label: {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(product.displayName)
|
||||
.font(.body.weight(.semibold))
|
||||
.foregroundStyle(.primary)
|
||||
HStack(spacing: 6) {
|
||||
Text(product.displayName)
|
||||
.font(.body.weight(.semibold))
|
||||
.foregroundStyle(.primary)
|
||||
if let badge = savingsBadge(product) {
|
||||
Text(badge)
|
||||
.font(.system(size: 10, weight: .bold))
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 2)
|
||||
.background(AppTheme.yellow, in: Capsule())
|
||||
}
|
||||
}
|
||||
if !product.description.isEmpty {
|
||||
Text(product.description)
|
||||
.font(.caption)
|
||||
@ -443,6 +515,25 @@ struct PremiumView: View {
|
||||
.disabled(store.isPurchasing)
|
||||
}
|
||||
|
||||
/// 년 구독: 월 구독 대비 절약률 배지 / 평생: 한 번 결제 배지
|
||||
private func savingsBadge(_ product: Product) -> String? {
|
||||
switch product.id {
|
||||
case PremiumProducts.lifetime:
|
||||
return String(localized: "한 번 결제")
|
||||
case PremiumProducts.yearly:
|
||||
guard let monthly = store.products.first(where: { $0.id == PremiumProducts.monthly }) else {
|
||||
return nil
|
||||
}
|
||||
let monthlyYearTotal = NSDecimalNumber(decimal: monthly.price).doubleValue * 12
|
||||
let yearly = NSDecimalNumber(decimal: product.price).doubleValue
|
||||
guard monthlyYearTotal > yearly, monthlyYearTotal > 0 else { return nil }
|
||||
let percent = Int(((monthlyYearTotal - yearly) / monthlyYearTotal * 100).rounded())
|
||||
return String(localized: "\(percent)% 절약")
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private func featureRow(_ symbol: String, _ title: String, _ detail: String) -> some View {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
Image(systemName: symbol)
|
||||
@ -457,3 +548,21 @@ struct PremiumView: View {
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 프리미엄 모달 (잠금 안내에서 어디서든 띄우는 시트)
|
||||
|
||||
/// 무료 한도 알림·잠긴 기능 화면에서 바로 결제 화면을 보여주기 위한 래퍼
|
||||
struct PremiumSheetView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
PremiumView()
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button("닫기") { dismiss() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user