Skip to content
This repository was archived by the owner on Oct 16, 2025. It is now read-only.

Commit ec62d78

Browse files
authored
feat: add purchase detail modal (#10)
## Summary - regenerate Swift models from the openiap-gql release and add post-processing to strip protocol default args - integrate the new StoreKit bridge/helpers and adjust OpenIapStore to send subscription purchases through RequestSubscriptionProps - refresh the sample app (purchase/subscription/available flows) to use the generated types, surface a purchase detail sheet, and render every subscription SKU with accurate status
1 parent a7f543b commit ec62d78

38 files changed

+3254
-3101
lines changed

.vscode/settings.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
"inapp",
55
"netrc",
66
"openiap",
7+
"preorder",
78
"skus",
89
"swiftpm",
910
"tvos",

AGENTS.md

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -106,8 +106,8 @@ func requestPurchaseIOS() // Should be requestPurchase() if cross-platform
106106

107107
- **Sources/Models/**: OpenIAP official types that match [openiap.dev/docs/types](https://www.openiap.dev/docs/types)
108108

109-
- `Product.swift` - OpenIapProduct and related types
110-
- `Purchase.swift` - OpenIapPurchase and related types
109+
- `Product.swift` - ProductIOS and related types
110+
- `Purchase.swift` - PurchaseIOS and related types
111111
- `ActiveSubscription.swift` - ActiveSubscription type
112112
- `PurchaseError.swift` - PurchaseError type
113113
- `Receipt.swift` - Receipt validation types
@@ -122,7 +122,6 @@ func requestPurchaseIOS() // Should be requestPurchase() if cross-platform
122122
- `OpenIapModule.swift` - Core implementation
123123
- `OpenIapStore.swift` - SwiftUI-friendly store
124124
- `OpenIapProtocol.swift` - API interface definitions
125-
- `OpenIapError.swift` - Error definitions
126125

127126
### Naming Rules
128127

CONTRIBUTING.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ swift test
4242
#### OpenIap Prefix (Public Models)
4343

4444
- Prefix all public model types with `OpenIap`.
45-
- Examples: `OpenIapProduct`, `OpenIapPurchase`, `OpenIapProductRequest`, `OpenIapRequestPurchaseProps`, `OpenIapPurchaseOptions`, `OpenIapReceiptValidationProps`, `OpenIapReceiptValidationResult`, `OpenIapActiveSubscription`, `OpenIapPurchaseState`, `OpenIapPurchaseOffer`, `OpenIapProductType`, `OpenIapProductTypeIOS`.
45+
- Examples: `ProductIOS`, `PurchaseIOS`, `ProductIOSRequest`, `RequestPurchaseProps`, `PurchaseIOSOptions`, `ReceiptValidationProps`, `ReceiptValidationResultIOS`, `ActiveSubscription`, `PurchaseIOSState`, `PurchaseIOSOffer`, `ProductIOSType`, `ProductIOSTypeIOS`.
4646
- Private/internal helper types do not need the prefix.
4747
- When renaming existing types, add a public `typealias` from the old name to the new name to preserve source compatibility, then migrate usages incrementally.
4848

Example/Martie.xcodeproj/project.pbxproj

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
C0E1F6002C8F1ABA00123456 /* OfferCodeScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0E1F5FF2C8F1ABA00123456 /* OfferCodeScreen.swift */; };
1919
C0E1F6042C8F1AC500123456 /* AppColors.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0E1F6032C8F1AC500123456 /* AppColors.swift */; };
2020
C0E1F6072C8F1AD000123456 /* OpenIAP in Frameworks */ = {isa = PBXBuildFile; productRef = C0E1F6062C8F1AD000123456 /* OpenIAP */; };
21+
C0IAC0012D10000000000001 /* IapCompat.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0IAC0002D10000000000001 /* IapCompat.swift */; };
2122
C0UI00012D00000000000001 /* FeatureCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0UI10012D00000000000001 /* FeatureCard.swift */; };
2223
C0UI00022D00000000000002 /* LoadingCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0UI10022D00000000000002 /* LoadingCard.swift */; };
2324
C0UI00032D00000000000003 /* EmptyStateCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0UI10032D00000000000003 /* EmptyStateCard.swift */; };
@@ -33,6 +34,7 @@
3334
C0UI000D2D0000000000000D /* TestingNotesCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0UI100D2D0000000000000D /* TestingNotesCard.swift */; };
3435
C0UI000E2D0000000000000E /* TestingNote.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0UI100E2D0000000000000E /* TestingNote.swift */; };
3536
C0UI000F2D0000000000000F /* PurchaseCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0UI100F2D0000000000000F /* PurchaseCard.swift */; };
37+
C0UI00102D00000000000010 /* PurchaseDetailSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0UI10102D00000000000010 /* PurchaseDetailSheet.swift */; };
3638
/* End PBXBuildFile section */
3739

3840
/* Begin PBXFileReference section */
@@ -47,6 +49,7 @@
4749
C0E1F5FD2C8F1AB500123456 /* AvailablePurchasesScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvailablePurchasesScreen.swift; sourceTree = "<group>"; };
4850
C0E1F5FF2C8F1ABA00123456 /* OfferCodeScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OfferCodeScreen.swift; sourceTree = "<group>"; };
4951
C0E1F6032C8F1AC500123456 /* AppColors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppColors.swift; sourceTree = "<group>"; };
52+
C0IAC0002D10000000000001 /* IapCompat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IapCompat.swift; sourceTree = "<group>"; };
5053
C0UI10012D00000000000001 /* FeatureCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeatureCard.swift; sourceTree = "<group>"; };
5154
C0UI10022D00000000000002 /* LoadingCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingCard.swift; sourceTree = "<group>"; };
5255
C0UI10032D00000000000003 /* EmptyStateCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmptyStateCard.swift; sourceTree = "<group>"; };
@@ -62,6 +65,7 @@
6265
C0UI100D2D0000000000000D /* TestingNotesCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestingNotesCard.swift; sourceTree = "<group>"; };
6366
C0UI100E2D0000000000000E /* TestingNote.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestingNote.swift; sourceTree = "<group>"; };
6467
C0UI100F2D0000000000000F /* PurchaseCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PurchaseCard.swift; sourceTree = "<group>"; };
68+
C0UI10102D00000000000010 /* PurchaseDetailSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PurchaseDetailSheet.swift; sourceTree = "<group>"; };
6569
/* End PBXFileReference section */
6670

6771
/* Begin PBXFrameworksBuildPhase section */
@@ -138,6 +142,7 @@
138142
isa = PBXGroup;
139143
children = (
140144
C0E1F6032C8F1AC500123456 /* AppColors.swift */,
145+
C0IAC0002D10000000000001 /* IapCompat.swift */,
141146
);
142147
path = Models;
143148
sourceTree = "<group>";
@@ -160,6 +165,7 @@
160165
C0UI100D2D0000000000000D /* TestingNotesCard.swift */,
161166
C0UI100E2D0000000000000E /* TestingNote.swift */,
162167
C0UI100F2D0000000000000F /* PurchaseCard.swift */,
168+
C0UI10102D00000000000010 /* PurchaseDetailSheet.swift */,
163169
);
164170
path = uis;
165171
sourceTree = "<group>";
@@ -195,7 +201,7 @@
195201
attributes = {
196202
BuildIndependentTargetsInParallel = 1;
197203
LastSwiftUpdateCheck = 1500;
198-
LastUpgradeCheck = 1500;
204+
LastUpgradeCheck = 2600;
199205
TargetAttributes = {
200206
C0E1F5E42C8F1A9400123456 = {
201207
CreatedOnToolsVersion = 15.0;
@@ -247,6 +253,7 @@
247253
C0E1F5FE2C8F1AB500123456 /* AvailablePurchasesScreen.swift in Sources */,
248254
C0E1F6002C8F1ABA00123456 /* OfferCodeScreen.swift in Sources */,
249255
C0E1F6042C8F1AC500123456 /* AppColors.swift in Sources */,
256+
C0IAC0012D10000000000001 /* IapCompat.swift in Sources */,
250257
C0E1F5E92C8F1A9400123456 /* OpenIapExampleApp.swift in Sources */,
251258
C0UI00012D00000000000001 /* FeatureCard.swift in Sources */,
252259
C0UI00022D00000000000002 /* LoadingCard.swift in Sources */,
@@ -263,6 +270,7 @@
263270
C0UI000D2D0000000000000D /* TestingNotesCard.swift in Sources */,
264271
C0UI000E2D0000000000000E /* TestingNote.swift in Sources */,
265272
C0UI000F2D0000000000000F /* PurchaseCard.swift in Sources */,
273+
C0UI00102D00000000000010 /* PurchaseDetailSheet.swift in Sources */,
266274
);
267275
runOnlyForDeploymentPostprocessing = 0;
268276
};
@@ -304,6 +312,7 @@
304312
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
305313
COPY_PHASE_STRIP = NO;
306314
DEBUG_INFORMATION_FORMAT = dwarf;
315+
DEVELOPMENT_TEAM = PRDQGB267K;
307316
ENABLE_STRICT_OBJC_MSGSEND = YES;
308317
ENABLE_TESTABILITY = YES;
309318
ENABLE_USER_SCRIPT_SANDBOXING = YES;
@@ -327,6 +336,7 @@
327336
MTL_FAST_MATH = YES;
328337
ONLY_ACTIVE_ARCH = YES;
329338
SDKROOT = iphoneos;
339+
STRING_CATALOG_GENERATE_SYMBOLS = YES;
330340
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
331341
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
332342
};
@@ -367,6 +377,7 @@
367377
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
368378
COPY_PHASE_STRIP = NO;
369379
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
380+
DEVELOPMENT_TEAM = PRDQGB267K;
370381
ENABLE_NS_ASSERTIONS = NO;
371382
ENABLE_STRICT_OBJC_MSGSEND = YES;
372383
ENABLE_USER_SCRIPT_SANDBOXING = YES;
@@ -383,6 +394,7 @@
383394
MTL_ENABLE_DEBUG_INFO = NO;
384395
MTL_FAST_MATH = YES;
385396
SDKROOT = iphoneos;
397+
STRING_CATALOG_GENERATE_SYMBOLS = YES;
386398
SWIFT_COMPILATION_MODE = wholemodule;
387399
VALIDATE_PRODUCT = YES;
388400
};
@@ -396,7 +408,6 @@
396408
CODE_SIGN_STYLE = Automatic;
397409
CURRENT_PROJECT_VERSION = 1;
398410
DEVELOPMENT_ASSET_PATHS = "\"OpenIapExample/Preview Content\"";
399-
DEVELOPMENT_TEAM = PRDQGB267K;
400411
ENABLE_PREVIEWS = YES;
401412
GENERATE_INFOPLIST_FILE = YES;
402413
INFOPLIST_KEY_CFBundleDisplayName = OpenIAP;
@@ -427,7 +438,6 @@
427438
CODE_SIGN_STYLE = Automatic;
428439
CURRENT_PROJECT_VERSION = 1;
429440
DEVELOPMENT_ASSET_PATHS = "\"OpenIapExample/Preview Content\"";
430-
DEVELOPMENT_TEAM = PRDQGB267K;
431441
ENABLE_PREVIEWS = YES;
432442
GENERATE_INFOPLIST_FILE = YES;
433443
INFOPLIST_KEY_CFBundleDisplayName = OpenIAP;
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import OpenIAP
2+
3+
// Maintain previous sample code naming expectations while using generated models
4+
@available(iOS 15.0, *)
5+
typealias OpenIapProduct = ProductIOS
6+
@available(iOS 15.0, *)
7+
typealias OpenIapPurchase = PurchaseIOS
8+
@available(iOS 15.0, *)
9+
typealias OpenIapError = PurchaseError
10+
@available(iOS 15.0, *)
11+
typealias OpenIapActiveSubscription = ActiveSubscription
12+
13+
@available(iOS 15.0, *)
14+
extension PurchaseState {
15+
var isAcknowledged: Bool {
16+
switch self {
17+
case .purchased, .restored:
18+
return true
19+
default:
20+
return false
21+
}
22+
}
23+
}
24+
25+
@available(iOS 15.0, *)
26+
extension PurchaseIOS {
27+
var isSubscription: Bool {
28+
expirationDateIOS != nil || isAutoRenewing
29+
}
30+
}
31+
32+
@available(iOS 15.0, *)
33+
extension ProductIOS {
34+
var productIdentifier: String { id }
35+
}
36+
37+
@available(iOS 15.0, *)
38+
extension OpenIAP.Product {
39+
func asIOS() -> OpenIapProduct? {
40+
if case let .productIos(value) = self {
41+
return value
42+
}
43+
return nil
44+
}
45+
}
46+
47+
@available(iOS 15.0, *)
48+
extension OpenIAP.Purchase {
49+
func asIOS() -> OpenIapPurchase? {
50+
if case let .purchaseIos(value) = self {
51+
return value
52+
}
53+
return nil
54+
}
55+
}

Example/OpenIapExample/Screens/AvailablePurchasesScreen.swift

Lines changed: 35 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ struct AvailablePurchasesScreen: View {
66
@StateObject private var iapStore = OpenIapStore()
77
@State private var showError = false
88
@State private var errorMessage = ""
9+
@State private var selectedPurchase: OpenIapPurchase?
910

1011
var body: some View {
1112
ScrollView {
@@ -33,6 +34,14 @@ struct AvailablePurchasesScreen: View {
3334
.disabled(iapStore.status.loadings.restorePurchases)
3435
}
3536
}
37+
.sheet(isPresented: Binding(
38+
get: { selectedPurchase != nil },
39+
set: { if !$0 { selectedPurchase = nil } }
40+
)) {
41+
if let purchase = selectedPurchase {
42+
PurchaseDetailSheet(purchase: purchase)
43+
}
44+
}
3645
.alert("Error", isPresented: $showError) {
3746
Button("OK") {}
3847
} message: {
@@ -60,15 +69,11 @@ struct AvailablePurchasesScreen: View {
6069
}
6170

6271
private var uniqueActivePurchases: [OpenIapPurchase] {
63-
let allActivePurchases = iapStore.availablePurchases.filter { purchase in
72+
let allActivePurchases = iapStore.iosAvailablePurchases.filter { purchase in
6473
// Show active purchases (purchased or restored state)
6574
purchase.purchaseState == .purchased || purchase.purchaseState == .restored
6675
}.filter { purchase in
67-
// Check product type if we can determine it
68-
let isSubscription = purchase.productId.contains("premium") ||
69-
purchase.productId.contains("subscription")
70-
71-
if isSubscription {
76+
if purchase.isSubscription {
7277
// Active subscriptions: check auto-renewing or expiry time
7378
if purchase.isAutoRenewing {
7479
return true // Always show auto-renewing subscriptions
@@ -99,12 +104,14 @@ struct AvailablePurchasesScreen: View {
99104
)
100105
} else {
101106
VStack(spacing: 12) {
102-
ForEach(uniqueActivePurchases, id: \.id) { purchase in
103-
ActivePurchaseCard(purchase: purchase) {
107+
ForEach(uniqueActivePurchases, id: \.transactionId) { purchase in
108+
ActivePurchaseCard(purchase: purchase, onConsume: {
104109
Task {
105110
await finishPurchase(purchase)
106111
}
107-
}
112+
}, onShowDetails: {
113+
selectedPurchase = purchase
114+
})
108115
}
109116
}
110117
.padding(.horizontal)
@@ -126,16 +133,18 @@ struct AvailablePurchasesScreen: View {
126133

127134
@ViewBuilder
128135
private var purchaseHistoryContent: some View {
129-
if iapStore.availablePurchases.isEmpty {
136+
if iapStore.iosAvailablePurchases.isEmpty {
130137
EmptyStateCard(
131138
icon: "clock",
132139
title: "No purchase history",
133140
subtitle: "Your purchase history will appear here"
134141
)
135142
} else {
136143
VStack(spacing: 12) {
137-
ForEach(iapStore.availablePurchases.sorted { $0.transactionDate > $1.transactionDate }, id: \.id) { purchase in
138-
PurchaseHistoryCard(purchase: purchase)
144+
ForEach(iapStore.iosAvailablePurchases.sorted { $0.transactionDate > $1.transactionDate }, id: \.transactionId) { purchase in
145+
PurchaseHistoryCard(purchase: purchase) {
146+
selectedPurchase = purchase
147+
}
139148
}
140149
}
141150
.padding(.horizontal)
@@ -277,9 +286,10 @@ struct AvailablePurchasesScreen: View {
277286
print("🔷 [AvailablePurchases] Setting up OpenIapStore...")
278287

279288
iapStore.onPurchaseSuccess = { purchase in
280-
Task { @MainActor in
281-
// Refresh purchases when new purchase comes in
282-
loadPurchases()
289+
if purchase.asIOS() != nil {
290+
Task { @MainActor in
291+
loadPurchases()
292+
}
283293
}
284294
}
285295

@@ -318,7 +328,7 @@ struct AvailablePurchasesScreen: View {
318328
Task {
319329
do {
320330
try await iapStore.getAvailablePurchases()
321-
print("✅ [AvailablePurchases] Loaded \(iapStore.availablePurchases.count) purchases")
331+
print("✅ [AvailablePurchases] Loaded \(iapStore.iosAvailablePurchases.count) purchases")
322332
} catch {
323333
await MainActor.run {
324334
errorMessage = "Failed to load purchases: \(error.localizedDescription)"
@@ -332,10 +342,10 @@ struct AvailablePurchasesScreen: View {
332342

333343
private func finishPurchase(_ purchase: OpenIapPurchase) async {
334344
do {
335-
_ = try await iapStore.finishTransaction(purchase: purchase)
345+
try await iapStore.finishTransaction(purchase: purchase)
336346
print("✅ [AvailablePurchases] Transaction finished: \(purchase.id)")
337347
// Reload purchases to update UI
338-
await loadPurchases()
348+
loadPurchases()
339349
} catch {
340350
await MainActor.run {
341351
errorMessage = "Failed to finish transaction: \(error.localizedDescription)"
@@ -349,30 +359,30 @@ struct AvailablePurchasesScreen: View {
349359
private func clearAllTransactions() async {
350360
// Note: This would require additional API in OpenIapStore
351361
// For now, just reload purchases
352-
await loadPurchases()
362+
loadPurchases()
353363
print("🧪 [AvailablePurchases] Clear transactions requested (reloaded purchases)")
354364
}
355365

356366
private func syncSubscriptions() async {
357367
// Reload purchases to sync subscription status
358-
await loadPurchases()
368+
loadPurchases()
359369
print("🧪 [AvailablePurchases] Subscription sync requested (reloaded purchases)")
360370
}
361371

362372
private func finishUnfinishedTransactions() async {
363-
let unfinishedPurchases = iapStore.availablePurchases.filter { !$0.purchaseState.isAcknowledged }
373+
let unfinishedPurchases = iapStore.iosAvailablePurchases.filter { !$0.purchaseState.isAcknowledged }
364374

365375
for purchase in unfinishedPurchases {
366376
do {
367-
_ = try await iapStore.finishTransaction(purchase: purchase)
368-
print("✅ [AvailablePurchases] Finished unfinished transaction: \(purchase.id)")
377+
try await iapStore.finishTransaction(purchase: purchase)
378+
print("✅ [AvailablePurchases] Finished unfinished transaction: \(purchase.transactionId)")
369379
} catch {
370-
print("❌ [AvailablePurchases] Failed to finish transaction \(purchase.id): \(error)")
380+
print("❌ [AvailablePurchases] Failed to finish transaction \(purchase.transactionId): \(error)")
371381
}
372382
}
373383

374384
// Reload after finishing transactions
375-
await loadPurchases()
385+
loadPurchases()
376386
}
377387
}
378388

Example/OpenIapExample/Screens/OfferCodeScreen.swift

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,8 +89,10 @@ struct OfferCodeScreen: View {
8989
print("🔷 [OfferCode] Setting up OpenIapStore...")
9090

9191
iapStore.onPurchaseSuccess = { purchase in
92-
Task { @MainActor in
93-
print("✅ [OfferCode] Offer code redeemed successfully: \(purchase.productId)")
92+
if let iosPurchase = purchase.asIOS() {
93+
Task { @MainActor in
94+
print("✅ [OfferCode] Offer code redeemed successfully: \(iosPurchase.productId)")
95+
}
9496
}
9597
}
9698

0 commit comments

Comments
 (0)