|
@@ -1,14 +1,22 @@
|
|
|
|
|
+import StoreKit
|
|
|
import SwiftUI
|
|
import SwiftUI
|
|
|
|
|
|
|
|
struct PremiumFeaturesView: View {
|
|
struct PremiumFeaturesView: View {
|
|
|
enum PlanOption: String {
|
|
enum PlanOption: String {
|
|
|
case perpetual
|
|
case perpetual
|
|
|
case yearly
|
|
case yearly
|
|
|
|
|
+
|
|
|
|
|
+ fileprivate var productID: PremiumProductID {
|
|
|
|
|
+ switch self {
|
|
|
|
|
+ case .perpetual: return .perpetual
|
|
|
|
|
+ case .yearly: return .yearly
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ @EnvironmentObject private var premiumStore: PremiumStore
|
|
|
@Environment(\.dismiss) private var dismiss
|
|
@Environment(\.dismiss) private var dismiss
|
|
|
@State private var selectedPlan: PlanOption = .perpetual
|
|
@State private var selectedPlan: PlanOption = .perpetual
|
|
|
- @State private var showingCheckout = false
|
|
|
|
|
@State private var showingSuccessAlert = false
|
|
@State private var showingSuccessAlert = false
|
|
|
|
|
|
|
|
private let featureRows: [(String, String)] = [
|
|
private let featureRows: [(String, String)] = [
|
|
@@ -29,6 +37,20 @@ struct PremiumFeaturesView: View {
|
|
|
.indigo, .pink, .cyan, .gray, .mint,
|
|
.indigo, .pink, .cyan, .gray, .mint,
|
|
|
]
|
|
]
|
|
|
|
|
|
|
|
|
|
+ private var selectedProduct: Product? {
|
|
|
|
|
+ premiumStore.product(for: selectedPlan.productID)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func priceLine(for plan: PlanOption) -> String {
|
|
|
|
|
+ if let p = premiumStore.displayPrice(for: plan.productID) {
|
|
|
|
|
+ return p
|
|
|
|
|
+ }
|
|
|
|
|
+ if premiumStore.isLoadingProducts {
|
|
|
|
|
+ return "Loading…"
|
|
|
|
|
+ }
|
|
|
|
|
+ return "—"
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
var body: some View {
|
|
var body: some View {
|
|
|
ZStack {
|
|
ZStack {
|
|
|
LinearGradient(
|
|
LinearGradient(
|
|
@@ -61,15 +83,21 @@ struct PremiumFeaturesView: View {
|
|
|
|
|
|
|
|
Spacer()
|
|
Spacer()
|
|
|
|
|
|
|
|
- Text("Restore")
|
|
|
|
|
- .font(.system(size: 11, weight: .semibold))
|
|
|
|
|
- .foregroundStyle(.white.opacity(0.9))
|
|
|
|
|
- .padding(.horizontal, 8)
|
|
|
|
|
- .padding(.vertical, 3)
|
|
|
|
|
- .background(
|
|
|
|
|
- RoundedRectangle(cornerRadius: 7, style: .continuous)
|
|
|
|
|
- .fill(Color.white.opacity(0.16))
|
|
|
|
|
- )
|
|
|
|
|
|
|
+ Button {
|
|
|
|
|
+ Task { await premiumStore.restorePurchases() }
|
|
|
|
|
+ } label: {
|
|
|
|
|
+ Text("Restore")
|
|
|
|
|
+ .font(.system(size: 11, weight: .semibold))
|
|
|
|
|
+ .foregroundStyle(.white.opacity(0.9))
|
|
|
|
|
+ .padding(.horizontal, 8)
|
|
|
|
|
+ .padding(.vertical, 3)
|
|
|
|
|
+ .background(
|
|
|
|
|
+ RoundedRectangle(cornerRadius: 7, style: .continuous)
|
|
|
|
|
+ .fill(Color.white.opacity(0.16))
|
|
|
|
|
+ )
|
|
|
|
|
+ }
|
|
|
|
|
+ .buttonStyle(.plain)
|
|
|
|
|
+ .disabled(premiumStore.purchaseInProgress)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
Text("Upgrade to unlock all\nPremium Features")
|
|
Text("Upgrade to unlock all\nPremium Features")
|
|
@@ -77,6 +105,18 @@ struct PremiumFeaturesView: View {
|
|
|
.foregroundStyle(.white)
|
|
.foregroundStyle(.white)
|
|
|
.lineSpacing(1)
|
|
.lineSpacing(1)
|
|
|
|
|
|
|
|
|
|
+ if let err = premiumStore.loadError {
|
|
|
|
|
+ Text(err)
|
|
|
|
|
+ .font(.system(size: 11, weight: .medium))
|
|
|
|
|
+ .foregroundStyle(.orange.opacity(0.95))
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if let err = premiumStore.purchaseError {
|
|
|
|
|
+ Text(err)
|
|
|
|
|
+ .font(.system(size: 11, weight: .medium))
|
|
|
|
|
+ .foregroundStyle(.orange.opacity(0.95))
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
LazyVGrid(
|
|
LazyVGrid(
|
|
|
columns: [GridItem(.flexible(), spacing: 10), GridItem(.flexible(), spacing: 10)],
|
|
columns: [GridItem(.flexible(), spacing: 10), GridItem(.flexible(), spacing: 10)],
|
|
|
spacing: 10
|
|
spacing: 10
|
|
@@ -86,9 +126,44 @@ struct PremiumFeaturesView: View {
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ if productsUnavailable {
|
|
|
|
|
+ VStack(alignment: .leading, spacing: 10) {
|
|
|
|
|
+ Text("Store products not loaded")
|
|
|
|
|
+ .font(.system(size: 14, weight: .bold))
|
|
|
|
|
+ .foregroundStyle(.white)
|
|
|
|
|
+ Text(
|
|
|
|
|
+ "Prices and checkout need product metadata from App Store Connect, or a StoreKit Configuration file when running from Xcode. Product IDs must match exactly."
|
|
|
|
|
+ )
|
|
|
|
|
+ .font(.system(size: 11, weight: .medium))
|
|
|
|
|
+ .foregroundStyle(.white.opacity(0.85))
|
|
|
|
|
+ .fixedSize(horizontal: false, vertical: true)
|
|
|
|
|
+ Button {
|
|
|
|
|
+ Task { await premiumStore.loadProducts() }
|
|
|
|
|
+ } label: {
|
|
|
|
|
+ Text("Retry")
|
|
|
|
|
+ .font(.system(size: 13, weight: .semibold))
|
|
|
|
|
+ .foregroundStyle(.white)
|
|
|
|
|
+ .frame(maxWidth: .infinity)
|
|
|
|
|
+ .padding(.vertical, 10)
|
|
|
|
|
+ .background(RoundedRectangle(cornerRadius: 10, style: .continuous).fill(Color.orange.opacity(0.85)))
|
|
|
|
|
+ }
|
|
|
|
|
+ .buttonStyle(.plain)
|
|
|
|
|
+ }
|
|
|
|
|
+ .padding(14)
|
|
|
|
|
+ .frame(maxWidth: .infinity, alignment: .leading)
|
|
|
|
|
+ .background(
|
|
|
|
|
+ RoundedRectangle(cornerRadius: 14, style: .continuous)
|
|
|
|
|
+ .fill(Color.orange.opacity(0.18))
|
|
|
|
|
+ )
|
|
|
|
|
+ .overlay(
|
|
|
|
|
+ RoundedRectangle(cornerRadius: 14, style: .continuous)
|
|
|
|
|
+ .stroke(Color.orange.opacity(0.45), lineWidth: 1)
|
|
|
|
|
+ )
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
priceCard(
|
|
priceCard(
|
|
|
title: "Perpetual",
|
|
title: "Perpetual",
|
|
|
- price: "Rs 9,900.00",
|
|
|
|
|
|
|
+ price: priceLine(for: .perpetual),
|
|
|
subtitle: "Own the software for lifetime",
|
|
subtitle: "Own the software for lifetime",
|
|
|
badgeText: "One-time payment",
|
|
badgeText: "One-time payment",
|
|
|
isSelected: selectedPlan == .perpetual
|
|
isSelected: selectedPlan == .perpetual
|
|
@@ -99,8 +174,8 @@ struct PremiumFeaturesView: View {
|
|
|
|
|
|
|
|
priceCard(
|
|
priceCard(
|
|
|
title: "Yearly",
|
|
title: "Yearly",
|
|
|
- price: "Rs 7,900.00/year",
|
|
|
|
|
- subtitle: "Rs 658.33/mo",
|
|
|
|
|
|
|
+ price: priceLine(for: .yearly),
|
|
|
|
|
+ subtitle: "Billed annually",
|
|
|
badgeText: nil,
|
|
badgeText: nil,
|
|
|
isSelected: selectedPlan == .yearly
|
|
isSelected: selectedPlan == .yearly
|
|
|
)
|
|
)
|
|
@@ -108,18 +183,34 @@ struct PremiumFeaturesView: View {
|
|
|
selectedPlan = .yearly
|
|
selectedPlan = .yearly
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- Button(action: { showingCheckout = true }) {
|
|
|
|
|
- Text("Continue")
|
|
|
|
|
- .font(.system(size: 16, weight: .bold))
|
|
|
|
|
- .foregroundStyle(.white)
|
|
|
|
|
- .frame(maxWidth: .infinity)
|
|
|
|
|
- .padding(.vertical, 12)
|
|
|
|
|
- .background(
|
|
|
|
|
- RoundedRectangle(cornerRadius: 16, style: .continuous)
|
|
|
|
|
- .fill(Color.blue.opacity(0.9))
|
|
|
|
|
- )
|
|
|
|
|
|
|
+ Button(action: {
|
|
|
|
|
+ guard let product = selectedProduct else { return }
|
|
|
|
|
+ Task {
|
|
|
|
|
+ let ok = await premiumStore.purchase(product)
|
|
|
|
|
+ if ok {
|
|
|
|
|
+ showingSuccessAlert = true
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }) {
|
|
|
|
|
+ HStack {
|
|
|
|
|
+ if premiumStore.purchaseInProgress {
|
|
|
|
|
+ ProgressView()
|
|
|
|
|
+ .controlSize(.small)
|
|
|
|
|
+ .tint(.white)
|
|
|
|
|
+ }
|
|
|
|
|
+ Text("Continue")
|
|
|
|
|
+ .font(.system(size: 16, weight: .bold))
|
|
|
|
|
+ }
|
|
|
|
|
+ .foregroundStyle(.white)
|
|
|
|
|
+ .frame(maxWidth: .infinity)
|
|
|
|
|
+ .padding(.vertical, 12)
|
|
|
|
|
+ .background(
|
|
|
|
|
+ RoundedRectangle(cornerRadius: 16, style: .continuous)
|
|
|
|
|
+ .fill(continueEnabled ? Color.blue.opacity(0.9) : Color.gray.opacity(0.55))
|
|
|
|
|
+ )
|
|
|
}
|
|
}
|
|
|
.buttonStyle(.plain)
|
|
.buttonStyle(.plain)
|
|
|
|
|
+ .disabled(!continueEnabled)
|
|
|
.padding(.top, 6)
|
|
.padding(.top, 6)
|
|
|
|
|
|
|
|
HStack {
|
|
HStack {
|
|
@@ -138,16 +229,18 @@ struct PremiumFeaturesView: View {
|
|
|
.padding(16)
|
|
.padding(16)
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
- .sheet(isPresented: $showingCheckout) {
|
|
|
|
|
- PaymentCheckoutView(
|
|
|
|
|
- selectedPlan: selectedPlan,
|
|
|
|
|
- onCancel: { showingCheckout = false },
|
|
|
|
|
- onConfirm: {
|
|
|
|
|
- showingCheckout = false
|
|
|
|
|
- showingSuccessAlert = true
|
|
|
|
|
- }
|
|
|
|
|
- )
|
|
|
|
|
- .frame(minWidth: 500, minHeight: 520)
|
|
|
|
|
|
|
+ .task {
|
|
|
|
|
+ await premiumStore.loadProducts()
|
|
|
|
|
+ }
|
|
|
|
|
+ .onAppear {
|
|
|
|
|
+ if premiumStore.isPremiumUnlocked {
|
|
|
|
|
+ dismiss()
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ .onChange(of: premiumStore.isPremiumUnlocked) { unlocked in
|
|
|
|
|
+ if unlocked {
|
|
|
|
|
+ dismiss()
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
.alert("Payment Successful", isPresented: $showingSuccessAlert) {
|
|
.alert("Payment Successful", isPresented: $showingSuccessAlert) {
|
|
|
Button("Done") { dismiss() }
|
|
Button("Done") { dismiss() }
|
|
@@ -156,6 +249,14 @@ struct PremiumFeaturesView: View {
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ private var productsUnavailable: Bool {
|
|
|
|
|
+ premiumStore.products.isEmpty && !premiumStore.isLoadingProducts
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private var continueEnabled: Bool {
|
|
|
|
|
+ selectedProduct != nil && !premiumStore.purchaseInProgress && !premiumStore.isLoadingProducts
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
private func featureCard(title: String, subtitle: String, color: Color) -> some View {
|
|
private func featureCard(title: String, subtitle: String, color: Color) -> some View {
|
|
|
HStack(alignment: .top, spacing: 10) {
|
|
HStack(alignment: .top, spacing: 10) {
|
|
|
Circle()
|
|
Circle()
|
|
@@ -232,128 +333,8 @@ struct PremiumFeaturesView: View {
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-private struct PaymentCheckoutView: View {
|
|
|
|
|
- let selectedPlan: PremiumFeaturesView.PlanOption
|
|
|
|
|
- let onCancel: () -> Void
|
|
|
|
|
- let onConfirm: () -> Void
|
|
|
|
|
-
|
|
|
|
|
- @State private var cardholderName = ""
|
|
|
|
|
- @State private var cardNumber = ""
|
|
|
|
|
- @State private var expiry = ""
|
|
|
|
|
- @State private var cvv = ""
|
|
|
|
|
- @State private var saveCard = true
|
|
|
|
|
-
|
|
|
|
|
- private var amountText: String {
|
|
|
|
|
- switch selectedPlan {
|
|
|
|
|
- case .perpetual:
|
|
|
|
|
- return "Rs 9,900.00"
|
|
|
|
|
- case .yearly:
|
|
|
|
|
- return "Rs 7,900.00/year"
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- private var canSubmit: Bool {
|
|
|
|
|
- !cardholderName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty &&
|
|
|
|
|
- cardNumber.filter(\.isNumber).count >= 12 &&
|
|
|
|
|
- expiry.count >= 4 &&
|
|
|
|
|
- cvv.filter(\.isNumber).count >= 3
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- var body: some View {
|
|
|
|
|
- ZStack {
|
|
|
|
|
- LinearGradient(
|
|
|
|
|
- colors: [Color.black.opacity(0.95), Color(red: 0.09, green: 0.11, blue: 0.16)],
|
|
|
|
|
- startPoint: .topLeading,
|
|
|
|
|
- endPoint: .bottomTrailing
|
|
|
|
|
- )
|
|
|
|
|
- .ignoresSafeArea()
|
|
|
|
|
-
|
|
|
|
|
- VStack(alignment: .leading, spacing: 16) {
|
|
|
|
|
- HStack {
|
|
|
|
|
- Text("Add Credit Card")
|
|
|
|
|
- .font(.system(size: 22, weight: .bold))
|
|
|
|
|
- .foregroundStyle(.white)
|
|
|
|
|
- Spacer()
|
|
|
|
|
- Button("Cancel", action: onCancel)
|
|
|
|
|
- .buttonStyle(.plain)
|
|
|
|
|
- .foregroundStyle(.white.opacity(0.8))
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- Text("Selected plan: \(selectedPlan == .perpetual ? "Perpetual" : "Yearly")")
|
|
|
|
|
- .font(.system(size: 13, weight: .medium))
|
|
|
|
|
- .foregroundStyle(.white.opacity(0.8))
|
|
|
|
|
-
|
|
|
|
|
- VStack(spacing: 12) {
|
|
|
|
|
- checkoutField(title: "Cardholder Name", text: $cardholderName, placeholder: "Name on card")
|
|
|
|
|
- checkoutField(title: "Card Number", text: $cardNumber, placeholder: "1234 5678 9012 3456")
|
|
|
|
|
-
|
|
|
|
|
- HStack(spacing: 12) {
|
|
|
|
|
- checkoutField(title: "Expiry (MM/YY)", text: $expiry, placeholder: "08/28")
|
|
|
|
|
- checkoutField(title: "CVV", text: $cvv, placeholder: "123")
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- Toggle("Save card for future purchases", isOn: $saveCard)
|
|
|
|
|
- .toggleStyle(.checkbox)
|
|
|
|
|
- .foregroundStyle(.white.opacity(0.88))
|
|
|
|
|
-
|
|
|
|
|
- HStack {
|
|
|
|
|
- Text("Total")
|
|
|
|
|
- .foregroundStyle(.white.opacity(0.85))
|
|
|
|
|
- Spacer()
|
|
|
|
|
- Text(amountText)
|
|
|
|
|
- .font(.system(size: 15, weight: .bold))
|
|
|
|
|
- .foregroundStyle(.white)
|
|
|
|
|
- }
|
|
|
|
|
- .padding(12)
|
|
|
|
|
- .background(
|
|
|
|
|
- RoundedRectangle(cornerRadius: 10, style: .continuous)
|
|
|
|
|
- .fill(Color.white.opacity(0.08))
|
|
|
|
|
- )
|
|
|
|
|
-
|
|
|
|
|
- Button(action: onConfirm) {
|
|
|
|
|
- Text("Pay \(amountText)")
|
|
|
|
|
- .font(.system(size: 15, weight: .bold))
|
|
|
|
|
- .foregroundStyle(.white)
|
|
|
|
|
- .frame(maxWidth: .infinity)
|
|
|
|
|
- .padding(.vertical, 12)
|
|
|
|
|
- .background(
|
|
|
|
|
- RoundedRectangle(cornerRadius: 12, style: .continuous)
|
|
|
|
|
- .fill(canSubmit ? Color.blue.opacity(0.95) : Color.gray.opacity(0.6))
|
|
|
|
|
- )
|
|
|
|
|
- }
|
|
|
|
|
- .buttonStyle(.plain)
|
|
|
|
|
- .disabled(!canSubmit)
|
|
|
|
|
-
|
|
|
|
|
- Spacer(minLength: 0)
|
|
|
|
|
- }
|
|
|
|
|
- .padding(20)
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- private func checkoutField(title: String, text: Binding<String>, placeholder: String) -> some View {
|
|
|
|
|
- VStack(alignment: .leading, spacing: 6) {
|
|
|
|
|
- Text(title)
|
|
|
|
|
- .font(.system(size: 12, weight: .semibold))
|
|
|
|
|
- .foregroundStyle(.white.opacity(0.85))
|
|
|
|
|
- TextField(placeholder, text: text)
|
|
|
|
|
- .textFieldStyle(.plain)
|
|
|
|
|
- .foregroundStyle(.white)
|
|
|
|
|
- .padding(.horizontal, 10)
|
|
|
|
|
- .padding(.vertical, 9)
|
|
|
|
|
- .background(
|
|
|
|
|
- RoundedRectangle(cornerRadius: 10, style: .continuous)
|
|
|
|
|
- .fill(Color.white.opacity(0.1))
|
|
|
|
|
- )
|
|
|
|
|
- .overlay(
|
|
|
|
|
- RoundedRectangle(cornerRadius: 10, style: .continuous)
|
|
|
|
|
- .stroke(Color.white.opacity(0.13), lineWidth: 1)
|
|
|
|
|
- )
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
#Preview {
|
|
#Preview {
|
|
|
PremiumFeaturesView()
|
|
PremiumFeaturesView()
|
|
|
|
|
+ .environmentObject(PremiumStore.shared)
|
|
|
.frame(width: 560, height: 690)
|
|
.frame(width: 560, height: 690)
|
|
|
}
|
|
}
|