Skip to content
This repository was archived by the owner on Oct 16, 2025. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
289 changes: 218 additions & 71 deletions Example/OpenIapExample/Screens/AvailablePurchasesScreen.swift

Large diffs are not rendered by default.

31 changes: 18 additions & 13 deletions Example/OpenIapExample/Screens/PurchaseFlowScreen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ struct ProductCard: View {
let onPurchase: () -> Void

private var productIcon: String {
switch product.productType {
switch product.typeIOS {
case .consumable, .nonConsumable, .nonRenewingSubscription:
return "bag.fill"
case .autoRenewableSubscription:
Expand All @@ -76,7 +76,7 @@ struct ProductCard: View {
}

private var productTypeText: String {
switch product.productType {
switch product.typeIOS {
case .consumable, .nonConsumable:
return "In-App Purchase"
case .autoRenewableSubscription:
Expand All @@ -96,7 +96,7 @@ struct ProductCard: View {
.frame(width: 32, height: 32)

VStack(alignment: .leading, spacing: 4) {
Text(product.localizedTitle)
Text(product.title)
.font(.headline)
.fontWeight(.semibold)

Expand All @@ -111,7 +111,7 @@ struct ProductCard: View {

Spacer()

Text(product.localizedPrice)
Text(product.displayPrice)
.font(.title2)
.fontWeight(.bold)
.foregroundColor(AppColors.primary)
Expand All @@ -120,7 +120,7 @@ struct ProductCard: View {
}

// Product description
Text(product.localizedDescription)
Text(product.description)
.font(.subheadline)
.foregroundColor(AppColors.secondaryText)
.lineLimit(nil)
Expand Down Expand Up @@ -149,7 +149,7 @@ struct ProductCard: View {
Spacer()

if !isLoading {
Text(product.localizedPrice)
Text(product.displayPrice)
.fontWeight(.semibold)
}
}
Expand Down Expand Up @@ -191,7 +191,7 @@ struct RecentPurchasesSection: View {
}

VStack(spacing: 12) {
ForEach(purchases.prefix(3), id: \.transactionId) { purchase in
ForEach(purchases.prefix(3), id: \.id) { purchase in
RecentPurchaseRow(purchase: purchase)
}

Expand Down Expand Up @@ -237,7 +237,7 @@ struct RecentPurchaseRow: View {
.font(.subheadline)
.fontWeight(.medium)

Text(purchase.purchaseTime, style: .relative)
Text(Date(timeIntervalSince1970: purchase.transactionDate / 1000), style: .relative)
.font(.caption)
.foregroundColor(AppColors.secondaryText)
}
Expand Down Expand Up @@ -271,17 +271,22 @@ struct InstructionsCard: View {

PurchaseInstructionRow(
number: "2",
text: "Tap 'Purchase' on any product above to test the flow"
text: "Tap 'Purchase' to buy → Receipt validation → Finish transaction"
)

PurchaseInstructionRow(
number: "3",
text: "Use test card 4242 4242 4242 4242 in sandbox"
text: "Receipt is validated with server (see StoreViewModel example)"
)

PurchaseInstructionRow(
number: "4",
text: "Check Available Purchases screen for purchase history"
text: "After validation, transaction is finished automatically"
)

PurchaseInstructionRow(
number: "5",
text: "Check Available Purchases to manually finish if needed"
)
}
}
Expand Down Expand Up @@ -412,8 +417,8 @@ struct ProductsContentView: View {

var consumableProducts: [OpenIapProduct] {
store.products.filter { product in
// Filter out premium subscription products
!product.id.contains("premium") && product.type == "inapp"
// Filter out subscription products
!product.typeIOS.isSubs
}
}

Expand Down
173 changes: 134 additions & 39 deletions Example/OpenIapExample/Screens/SubscriptionFlowScreen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ struct SubscriptionFlowScreen: View {
if store.isLoading {
LoadingCard(text: "Loading subscriptions...")
} else {
let subscriptionProducts = store.products.filter { $0.id.contains("premium") }
let subscriptionProducts = store.products.filter { $0.typeIOS.isSubs }

if subscriptionProducts.isEmpty {
EmptyStateCard(
Expand All @@ -52,25 +52,63 @@ struct SubscriptionFlowScreen: View {
)
} else {
ForEach(subscriptionProducts, id: \.id) { product in
let isSubscribed = store.purchases.contains { purchase in
purchase.id == product.id &&
purchase.purchaseState == .purchased &&
(purchase.isAutoRenewing || (purchase.expiryTime != nil && purchase.expiryTime! > Date()))
let activePurchase = store.purchases.first { purchase in
purchase.productId == product.id
}
let isSubscribed = activePurchase != nil
let isCancelled = activePurchase?.isAutoRenewing == false

SubscriptionCard(
product: product,
isSubscribed: isSubscribed,
isLoading: store.purchasingProductIds.contains(product.id)
) {
if !isSubscribed {
store.purchaseProduct(product)
isCancelled: isCancelled,
isLoading: store.purchasingProductIds.contains(product.id),
onSubscribe: {
if !isSubscribed || isCancelled {
store.purchaseProduct(product)
}
},
onManage: {
Task {
await store.manageSubscriptions()
}
}
}
)
}
}
}

// Instructions Card
VStack(alignment: .leading, spacing: 12) {
HStack {
Image(systemName: "info.circle")
.foregroundColor(AppColors.secondary)
Text("Subscription Flow")
.font(.headline)
}
.padding(.bottom, 8)

VStack(alignment: .leading, spacing: 8) {
Label("Purchase → Auto receipt validation", systemImage: "1.circle.fill")
.font(.subheadline)
Label("Server validates receipt (see StoreViewModel)", systemImage: "2.circle.fill")
.font(.subheadline)
Label("Subscriptions auto-finish (no manual finish needed)", systemImage: "3.circle.fill")
.font(.subheadline)
Label("Re-subscriptions may take 10-30 seconds in Sandbox", systemImage: "4.circle.fill")
.font(.subheadline)
}
.foregroundColor(AppColors.primaryText)
}
.padding()
.background(AppColors.secondary.opacity(0.05))
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(AppColors.secondary.opacity(0.3), lineWidth: 1)
)
.cornerRadius(8)
.padding(.horizontal)

Button(action: {
Task {
await store.restorePurchases()
Expand Down Expand Up @@ -124,15 +162,17 @@ struct SubscriptionFlowScreen: View {
struct SubscriptionCard: View {
let product: OpenIapProduct
let isSubscribed: Bool
let isCancelled: Bool
let isLoading: Bool
let onSubscribe: () -> Void
let onManage: () -> Void

var body: some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
VStack(alignment: .leading, spacing: 4) {
HStack {
Text(product.localizedTitle)
Text(product.title)
.font(.headline)

if isSubscribed {
Expand All @@ -155,12 +195,12 @@ struct SubscriptionCard: View {
Spacer()

VStack(alignment: .trailing, spacing: 2) {
Text(product.localizedPrice)
Text(product.displayPrice)
.font(.title2)
.fontWeight(.bold)
.foregroundColor(isSubscribed ? AppColors.success : AppColors.secondary)

if product.type == "subs" {
if product.typeIOS.isSubs {
Text("per month")
.font(.caption)
.foregroundColor(.secondary)
Expand All @@ -172,7 +212,7 @@ struct SubscriptionCard: View {
.font(.body)
.foregroundColor(AppColors.primaryText)

if product.type == "subs" {
if product.typeIOS.isSubs {
HStack {
Image(systemName: "arrow.triangle.2.circlepath")
.font(.caption)
Expand All @@ -187,32 +227,87 @@ struct SubscriptionCard: View {
}

if isSubscribed {
HStack {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(AppColors.success)

Text("Currently Subscribed")
.fontWeight(.medium)
.foregroundColor(AppColors.success)

Spacer()
VStack(spacing: 12) {
HStack {
Image(systemName: isCancelled ? "exclamationmark.triangle.fill" : "checkmark.circle.fill")
.foregroundColor(isCancelled ? AppColors.warning : AppColors.success)

Text(isCancelled ? "Subscription Cancelled" : "Currently Subscribed")
.fontWeight(.medium)
.foregroundColor(isCancelled ? AppColors.warning : AppColors.success)

Spacer()

Text(isCancelled ? "Expires Soon" : "Active")
.font(.caption)
.fontWeight(.semibold)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background((isCancelled ? AppColors.warning : AppColors.success).opacity(0.2))
.foregroundColor(isCancelled ? AppColors.warning : AppColors.success)
.cornerRadius(4)
}
.padding()
.background((isCancelled ? AppColors.warning : AppColors.success).opacity(0.1))
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(isCancelled ? AppColors.warning : AppColors.success, lineWidth: 1)
)
.cornerRadius(8)

Text("Active")
.font(.caption)
.fontWeight(.semibold)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(AppColors.success.opacity(0.2))
.foregroundColor(AppColors.success)
.cornerRadius(4)
if isCancelled {
// Re-subscribe button for cancelled subscriptions
Button(action: onSubscribe) {
HStack {
if isLoading {
ProgressView()
.scaleEffect(0.8)
.tint(.white)
} else {
Image(systemName: "arrow.clockwise.circle")
}
Text(isLoading ? "Reactivating..." : "Reactivate Subscription")
.fontWeight(.medium)
Spacer()
Text(product.displayPrice)
.fontWeight(.semibold)
}
.padding()
.background(isLoading ? AppColors.secondary.opacity(0.7) : AppColors.secondary)
.foregroundColor(.white)
.cornerRadius(8)
}
.disabled(isLoading)

Text("Subscription will remain active until expiry")
.font(.caption2)
.foregroundColor(.secondary)
.frame(maxWidth: .infinity, alignment: .center)
} else {
// Manage/Cancel Subscription Button
Button(action: onManage) {
HStack {
Image(systemName: "gear")
.font(.system(size: 14))
Text("Manage Subscription")
.fontWeight(.medium)
Spacer()
Image(systemName: "chevron.right")
.font(.caption)
}
.padding(.horizontal, 12)
.padding(.vertical, 10)
.background(Color.gray.opacity(0.15))
.foregroundColor(AppColors.primaryText)
.cornerRadius(8)
}

Text("Cancel anytime in Settings")
.font(.caption2)
.foregroundColor(.secondary)
.frame(maxWidth: .infinity, alignment: .center)
}
}
.padding()
.background(AppColors.success.opacity(0.1))
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(AppColors.success, lineWidth: 1)
)
.cornerRadius(8)
} else {
Button(action: onSubscribe) {
HStack {
Expand All @@ -230,7 +325,7 @@ struct SubscriptionCard: View {
Spacer()

if !isLoading {
Text(product.localizedPrice)
Text(product.displayPrice)
.fontWeight(.semibold)
}
}
Expand Down
Loading