Refactor StoreKit 2 subscription system and add interactive vote widget
## StoreKit 2 Refactor - Rewrote IAPManager with clean enum-based state model (SubscriptionState) - Added native SubscriptionStoreView for iOS 17+ purchase UI - Subscription status now checked on every app launch - Synced subscription status to UserDefaults for widget access - Simplified PurchaseButtonView and IAPWarningView - Removed unused StatusInfoView ## Interactive Vote Widget - New FeelsVoteWidget with App Intents for mood voting - Subscribers can vote directly from widget, shows stats after voting - Non-subscribers see "Tap to subscribe" which opens subscription store - Added feels:// URL scheme for deep linking ## Firebase Removal - Commented out Firebase imports and initialization - EventLogger now prints to console in DEBUG mode only ## Other Changes - Added fallback for Core Data when App Group unavailable - Added new localization strings for subscription UI - Updated entitlements and Info.plist 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
// PurchaseButtonView.swift
|
||||
// Feels
|
||||
//
|
||||
// Created by Trey Tartt on 7/7/22.
|
||||
// Subscription status and purchase view for settings.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
@@ -11,238 +11,190 @@ import StoreKit
|
||||
struct PurchaseButtonView: View {
|
||||
@AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system
|
||||
@AppStorage(UserDefaultsStore.Keys.textColor.rawValue, store: GroupUserDefaults.groupDefaults) private var textColor: Color = DefaultTextColor.textColor
|
||||
@AppStorage(UserDefaultsStore.Keys.firstLaunchDate.rawValue, store: GroupUserDefaults.groupDefaults) private var firstLaunchDate = Date()
|
||||
|
||||
var iapManager: IAPManager
|
||||
|
||||
private let showCountdownTimer: Bool
|
||||
private let showManageSubClosure: (() -> Void)?
|
||||
private let height: CGFloat?
|
||||
|
||||
public init(height: CGFloat? = nil,
|
||||
iapManager: IAPManager,
|
||||
showManageSubClosure: (() -> Void)? = nil,
|
||||
showCountdownTimer: Bool = false) {
|
||||
self.height = height
|
||||
self.showManageSubClosure = showManageSubClosure
|
||||
self.iapManager = iapManager
|
||||
self.showCountdownTimer = showCountdownTimer
|
||||
}
|
||||
|
||||
|
||||
@ObservedObject var iapManager: IAPManager
|
||||
|
||||
@State private var showSubscriptionStore = false
|
||||
@State private var showManageSubscriptions = false
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
// if we should show the iap warning that means no purchase which means
|
||||
// we should show buy options
|
||||
switch iapManager.showIAPWarning {
|
||||
case true:
|
||||
VStack {
|
||||
if let height = self.height {
|
||||
buyOptionsView
|
||||
.background(theme.currentTheme.secondaryBGColor)
|
||||
.frame(height: height)
|
||||
} else {
|
||||
buyOptionsView
|
||||
.background(theme.currentTheme.secondaryBGColor)
|
||||
}
|
||||
}
|
||||
case false:
|
||||
VStack(spacing: 16) {
|
||||
if iapManager.isLoading {
|
||||
loadingView
|
||||
} else if iapManager.isSubscribed {
|
||||
subscribedView
|
||||
.background(theme.currentTheme.secondaryBGColor)
|
||||
} else {
|
||||
notSubscribedView
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var buyOptionsSetingsView: some View {
|
||||
GeometryReader { metrics in
|
||||
VStack(spacing: 20) {
|
||||
Text(String(localized: "purchase_view_title"))
|
||||
.foregroundColor(textColor)
|
||||
.frame(minWidth: 0, maxWidth: .infinity, alignment: .leading)
|
||||
.padding([.leading, .trailing])
|
||||
VStack(alignment: .leading) {
|
||||
ForEach(iapManager.sortedSubscriptionKeysByPriceOptions, id: \.self) { product in
|
||||
HStack {
|
||||
Button(action: {
|
||||
purchase(product: product)
|
||||
}, label: {
|
||||
Text("\(product.displayPrice)\n\(product.displayName)")
|
||||
.foregroundColor(.white)
|
||||
.bold()
|
||||
.frame(maxWidth: .infinity)
|
||||
.contentShape(Rectangle())
|
||||
})
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 50)
|
||||
.padding()
|
||||
.background(RoundedRectangle(cornerRadius: 10).fill(iapManager.colorForIAPButton(iapIdentifier: product.id)))
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding([.leading, .leading])
|
||||
}
|
||||
.padding()
|
||||
.background(theme.currentTheme.secondaryBGColor)
|
||||
.cornerRadius(10)
|
||||
.sheet(isPresented: $showSubscriptionStore) {
|
||||
FeelsSubscriptionStoreView()
|
||||
}
|
||||
.manageSubscriptionsSheet(isPresented: $showManageSubscriptions)
|
||||
}
|
||||
|
||||
private var buyOptionsView: some View {
|
||||
VStack {
|
||||
ZStack {
|
||||
theme.currentTheme.secondaryBGColor
|
||||
|
||||
if iapManager.isLoadingSubscriptions {
|
||||
VStack(spacing: 20) {
|
||||
Text(String(localized: "purchase_view_loading"))
|
||||
.font(.body)
|
||||
.bold()
|
||||
.frame(minWidth: 0, maxWidth: .infinity, alignment: .center)
|
||||
ProgressView()
|
||||
}
|
||||
} else {
|
||||
|
||||
VStack(spacing: 20) {
|
||||
Text(String(localized: "purchase_view_title"))
|
||||
.font(.body)
|
||||
.bold()
|
||||
.foregroundColor(textColor)
|
||||
.frame(minWidth: 0, maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.top)
|
||||
|
||||
if showCountdownTimer {
|
||||
if let date = Calendar.current.date(byAdding: .day, value: 30, to: firstLaunchDate) {
|
||||
HStack {
|
||||
if iapManager.daysLeftBeforeIAP > 0 {
|
||||
Text(String(localized: "purchase_view_current_subscription_expires_in"))
|
||||
.font(.body)
|
||||
.bold()
|
||||
.foregroundColor(textColor)
|
||||
|
||||
Text(date, style: .relative)
|
||||
.font(.body)
|
||||
.bold()
|
||||
.foregroundColor(textColor)
|
||||
} else {
|
||||
Text(String(localized: "purchase_view_current_subscription_expired_on"))
|
||||
.font(.body)
|
||||
.bold()
|
||||
.foregroundColor(textColor)
|
||||
|
||||
Text(date, style: .date)
|
||||
.font(.body)
|
||||
.bold()
|
||||
.foregroundColor(textColor)
|
||||
}
|
||||
}
|
||||
.frame(minWidth: 0, maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
|
||||
Text(String(localized: "purchase_view_current_why_subscribe"))
|
||||
.font(.body)
|
||||
.bold()
|
||||
.foregroundColor(textColor)
|
||||
|
||||
HStack {
|
||||
ForEach(iapManager.sortedSubscriptionKeysByPriceOptions) { product in
|
||||
Button(action: {
|
||||
purchase(product: product)
|
||||
}, label: {
|
||||
Text("\(product.displayPrice)\n\(product.displayName)")
|
||||
.foregroundColor(.white)
|
||||
.bold()
|
||||
.frame(maxWidth: .infinity)
|
||||
.contentShape(Rectangle())
|
||||
.frame(height: 65)
|
||||
})
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(RoundedRectangle(cornerRadius: 10).fill(iapManager.colorForIAPButton(iapIdentifier: product.id)))
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding([.leading, .trailing])
|
||||
.frame(minWidth: 0, maxWidth: .infinity)
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Loading View
|
||||
|
||||
private var loadingView: some View {
|
||||
VStack(spacing: 12) {
|
||||
ProgressView()
|
||||
Text(String(localized: "purchase_view_loading"))
|
||||
.font(.body)
|
||||
.foregroundColor(textColor)
|
||||
}
|
||||
.background(.ultraThinMaterial)
|
||||
.frame(minWidth: 0, maxWidth: .infinity)
|
||||
.background(.clear)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
}
|
||||
|
||||
// MARK: - Subscribed View
|
||||
|
||||
private var subscribedView: some View {
|
||||
VStack(alignment: .leading) {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text(String(localized: "purchase_view_current_subscription"))
|
||||
.font(.title3)
|
||||
.padding([.leading, .top])
|
||||
|
||||
Divider()
|
||||
|
||||
if let currentProduct = iapManager.currentSubscription,
|
||||
let value = iapManager.subscriptions[currentProduct] {
|
||||
.bold()
|
||||
.foregroundColor(textColor)
|
||||
|
||||
if let product = iapManager.currentProduct {
|
||||
HStack {
|
||||
VStack (alignment: .leading, spacing: 10) {
|
||||
Text(currentProduct.displayName)
|
||||
.font(.title3)
|
||||
Text(currentProduct.displayPrice)
|
||||
.font(.title3)
|
||||
}.padding([.leading, .trailing])
|
||||
|
||||
ForEach(value!.status, id: \.self) { singleStatus in
|
||||
StatusInfoView(product: currentProduct, status: singleStatus)
|
||||
.padding([.leading])
|
||||
.font(.body)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(product.displayName)
|
||||
.font(.headline)
|
||||
.foregroundColor(textColor)
|
||||
Text(product.displayPrice)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
subscriptionStatusBadge
|
||||
}
|
||||
}
|
||||
|
||||
Button(action: {
|
||||
showManageSubClosure?()
|
||||
}, label: {
|
||||
Text(String(localized: "purchase_view_cancel"))
|
||||
.foregroundColor(.red)
|
||||
})
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding([.bottom])
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
|
||||
Divider()
|
||||
|
||||
showOtherSubOptions
|
||||
}
|
||||
}
|
||||
|
||||
private var showOtherSubOptions: some View {
|
||||
VStack (spacing: 10) {
|
||||
HStack {
|
||||
ForEach(iapManager.sortedSubscriptionKeysByPriceOptions, id: \.self) { product in
|
||||
if product.id != iapManager.nextRenewllOption?.id {
|
||||
Button(action: {
|
||||
purchase(product: product)
|
||||
}, label: {
|
||||
Text("\(product.displayPrice)\n\(product.displayName)")
|
||||
.foregroundColor(.white)
|
||||
.font(.headline)
|
||||
})
|
||||
.contentShape(Rectangle())
|
||||
.padding()
|
||||
|
||||
// Manage subscription button
|
||||
Button {
|
||||
showManageSubscriptions = true
|
||||
} label: {
|
||||
Text(String(localized: "purchase_view_manage_subscription"))
|
||||
.font(.body)
|
||||
.foregroundColor(.blue)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
|
||||
// Show other subscription options
|
||||
if iapManager.sortedProducts.count > 1 {
|
||||
Button {
|
||||
showSubscriptionStore = true
|
||||
} label: {
|
||||
Text(String(localized: "purchase_view_change_plan"))
|
||||
.font(.body)
|
||||
.foregroundColor(.secondary)
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(RoundedRectangle(cornerRadius: 10).fill(iapManager.colorForIAPButton(iapIdentifier: product.id)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding([.leading, .trailing])
|
||||
}
|
||||
|
||||
private func purchase(product: Product) {
|
||||
Task {
|
||||
try await iapManager.purchase(product)
|
||||
|
||||
private var subscriptionStatusBadge: some View {
|
||||
Group {
|
||||
if case .subscribed(_, let willAutoRenew) = iapManager.state {
|
||||
if willAutoRenew {
|
||||
Text(String(localized: "subscription_status_active"))
|
||||
.font(.caption)
|
||||
.foregroundColor(.white)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(Color.green)
|
||||
.cornerRadius(4)
|
||||
} else {
|
||||
Text(String(localized: "subscription_status_expires"))
|
||||
.font(.caption)
|
||||
.foregroundColor(.white)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(Color.orange)
|
||||
.cornerRadius(4)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Not Subscribed View
|
||||
|
||||
private var notSubscribedView: some View {
|
||||
VStack(spacing: 16) {
|
||||
Text(String(localized: "purchase_view_title"))
|
||||
.font(.title3)
|
||||
.bold()
|
||||
.foregroundColor(textColor)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
// Trial status
|
||||
if iapManager.shouldShowTrialWarning {
|
||||
trialStatusView
|
||||
} else if iapManager.shouldShowPaywall {
|
||||
Text(String(localized: "purchase_view_trial_expired"))
|
||||
.font(.body)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
Text(String(localized: "purchase_view_current_why_subscribe"))
|
||||
.font(.body)
|
||||
.foregroundColor(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
// Subscribe button
|
||||
Button {
|
||||
showSubscriptionStore = true
|
||||
} label: {
|
||||
Text(String(localized: "purchase_view_subscribe_button"))
|
||||
.font(.headline)
|
||||
.foregroundColor(.white)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
.background(Color.pink)
|
||||
.cornerRadius(10)
|
||||
}
|
||||
|
||||
// Restore purchases
|
||||
Button {
|
||||
Task {
|
||||
await iapManager.restore()
|
||||
}
|
||||
} label: {
|
||||
Text(String(localized: "purchase_view_restore"))
|
||||
.font(.body)
|
||||
.foregroundColor(.blue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var trialStatusView: some View {
|
||||
HStack {
|
||||
Image(systemName: "clock")
|
||||
.foregroundColor(.orange)
|
||||
|
||||
if let expirationDate = iapManager.trialExpirationDate {
|
||||
Text(String(localized: "purchase_view_trial_expires_in"))
|
||||
.foregroundColor(textColor)
|
||||
+
|
||||
Text(" ")
|
||||
+
|
||||
Text(expirationDate, style: .relative)
|
||||
.foregroundColor(.orange)
|
||||
.bold()
|
||||
}
|
||||
}
|
||||
.font(.body)
|
||||
}
|
||||
}
|
||||
|
||||
struct PurchaseButtonView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
PurchaseButtonView(iapManager: IAPManager())
|
||||
}
|
||||
#Preview {
|
||||
PurchaseButtonView(iapManager: IAPManager())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user