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

Commit 2a3787f

Browse files
authored
feat: add AllProductsView screen for iOS (#13)
## Summary - Added AllProductsView screen that displays all IAP products (in-app purchases and subscriptions) - Matches functionality of Android's AllProductsScreen - Integrated with existing OpenIapStore for product fetching ## Changes - Created `AllProductsView.swift` with product listing UI - Added "OpenIAP Example" button in HomeScreen navigation - Updated Xcode project file to include new screen ## Test Plan - [x] Build and run the iOS example app - [x] Navigate to AllProductsView via "OpenIAP Example" button - [x] Verify all products are displayed correctly - [x] Test connection/disconnection handling - [x] Verify product type badges (in-app vs subs)
1 parent ce206d0 commit 2a3787f

File tree

3 files changed

+409
-27
lines changed

3 files changed

+409
-27
lines changed

Example/Martie.xcodeproj/project.pbxproj

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
C0E1F5ED2C8F1A9500123456 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = C0E1F5EC2C8F1A9500123456 /* Assets.xcassets */; };
1313
C0E1F5F02C8F1A9500123456 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = C0E1F5EF2C8F1A9500123456 /* Preview Assets.xcassets */; };
1414
C0E1F5F82C8F1AA300123456 /* HomeScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0E1F5F72C8F1AA300123456 /* HomeScreen.swift */; };
15+
C0APV0022D20000000000001 /* AllProductsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0APV0012D20000000000001 /* AllProductsView.swift */; };
1516
C0E1F5FA2C8F1AAB00123456 /* PurchaseFlowScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0E1F5F92C8F1AAB00123456 /* PurchaseFlowScreen.swift */; };
1617
C0E1F5FC2C8F1AB000123456 /* SubscriptionFlowScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0E1F5FB2C8F1AB000123456 /* SubscriptionFlowScreen.swift */; };
1718
C0E1F5FE2C8F1AB500123456 /* AvailablePurchasesScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0E1F5FD2C8F1AB500123456 /* AvailablePurchasesScreen.swift */; };
@@ -48,6 +49,7 @@
4849
C0E1F5FB2C8F1AB000123456 /* SubscriptionFlowScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscriptionFlowScreen.swift; sourceTree = "<group>"; };
4950
C0E1F5FD2C8F1AB500123456 /* AvailablePurchasesScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvailablePurchasesScreen.swift; sourceTree = "<group>"; };
5051
C0E1F5FF2C8F1ABA00123456 /* OfferCodeScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OfferCodeScreen.swift; sourceTree = "<group>"; };
52+
C0APV0012D20000000000001 /* AllProductsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AllProductsView.swift; sourceTree = "<group>"; };
5153
C0E1F6032C8F1AC500123456 /* AppColors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppColors.swift; sourceTree = "<group>"; };
5254
C0IAC0002D10000000000001 /* IapCompat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IapCompat.swift; sourceTree = "<group>"; };
5355
C0UI10012D00000000000001 /* FeatureCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeatureCard.swift; sourceTree = "<group>"; };
@@ -123,6 +125,7 @@
123125
children = (
124126
C0UI20012D00000000000001 /* uis */,
125127
C0E1F5F72C8F1AA300123456 /* HomeScreen.swift */,
128+
C0APV0012D20000000000001 /* AllProductsView.swift */,
126129
C0E1F5F92C8F1AAB00123456 /* PurchaseFlowScreen.swift */,
127130
C0E1F5FB2C8F1AB000123456 /* SubscriptionFlowScreen.swift */,
128131
C0E1F5FD2C8F1AB500123456 /* AvailablePurchasesScreen.swift */,
@@ -248,6 +251,7 @@
248251
files = (
249252
C0E1F5EB2C8F1A9400123456 /* ContentView.swift in Sources */,
250253
C0E1F5F82C8F1AA300123456 /* HomeScreen.swift in Sources */,
254+
C0APV0022D20000000000001 /* AllProductsView.swift in Sources */,
251255
C0E1F5FA2C8F1AAB00123456 /* PurchaseFlowScreen.swift in Sources */,
252256
C0E1F5FC2C8F1AB000123456 /* SubscriptionFlowScreen.swift in Sources */,
253257
C0E1F5FE2C8F1AB500123456 /* AvailablePurchasesScreen.swift in Sources */,
Lines changed: 362 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,362 @@
1+
import SwiftUI
2+
import OpenIAP
3+
4+
@available(iOS 15.0, *)
5+
struct AllProductsView: View {
6+
@StateObject private var store = OpenIapStore()
7+
@State private var isLoading = false
8+
@State private var errorMessage: String?
9+
@Environment(\.dismiss) private var dismiss
10+
11+
// Product IDs from other screens
12+
private let allProductIds: [String] = [
13+
"dev.hyo.martie.10bulbs",
14+
"dev.hyo.martie.30bulbs",
15+
"dev.hyo.martie.certified",
16+
"dev.hyo.martie.premium",
17+
"dev.hyo.martie.premium_year"
18+
]
19+
20+
// Enum to unify product types for display
21+
private enum UnifiedProduct {
22+
case regular(ProductIOS)
23+
case subscription(ProductSubscriptionIOS)
24+
25+
var id: String {
26+
switch self {
27+
case .regular(let product):
28+
return product.id
29+
case .subscription(let sub):
30+
return sub.id
31+
}
32+
}
33+
34+
var sortOrder: Int {
35+
switch self {
36+
case .regular(let product):
37+
switch product.typeIOS {
38+
case .nonConsumable:
39+
return 1 // First
40+
case .consumable:
41+
return 2 // Second
42+
default:
43+
return 3 // Third
44+
}
45+
case .subscription:
46+
return 4 // Last
47+
}
48+
}
49+
}
50+
51+
// All products sorted by type: non-consumable, consumable, then subscriptions
52+
private var sortedAllProducts: [UnifiedProduct] {
53+
let regularProducts = store.iosProducts.map { UnifiedProduct.regular($0) }
54+
let subscriptionProducts = store.iosSubscriptionProducts.map { UnifiedProduct.subscription($0) }
55+
let allProducts = regularProducts + subscriptionProducts
56+
57+
return allProducts.sorted { first, second in
58+
first.sortOrder < second.sortOrder
59+
}
60+
}
61+
62+
63+
var body: some View {
64+
NavigationView {
65+
ZStack {
66+
Color(UIColor.systemGroupedBackground)
67+
.ignoresSafeArea()
68+
69+
ScrollView {
70+
VStack(spacing: 16) {
71+
if !store.isConnected {
72+
connectionWarningCard
73+
}
74+
75+
if isLoading {
76+
loadingCard
77+
}
78+
79+
if !isLoading && sortedAllProducts.isEmpty && store.isConnected {
80+
emptyStateCard
81+
}
82+
83+
// Display all products sorted by type: non-consumable, consumable, then subscriptions
84+
ForEach(sortedAllProducts, id: \.id) { unifiedProduct in
85+
switch unifiedProduct {
86+
case .regular(let product):
87+
productCard(for: product)
88+
case .subscription(let subscription):
89+
subscriptionCard(for: subscription)
90+
}
91+
}
92+
93+
if let error = errorMessage {
94+
errorCard(message: error)
95+
}
96+
}
97+
.padding()
98+
}
99+
}
100+
.navigationTitle("All Products")
101+
.navigationBarTitleDisplayMode(.large)
102+
.toolbar {
103+
ToolbarItem(placement: .navigationBarLeading) {
104+
Button(action: {
105+
Task {
106+
try? await store.endConnection()
107+
}
108+
dismiss()
109+
}) {
110+
Image(systemName: "arrow.left")
111+
.foregroundColor(.primary)
112+
}
113+
}
114+
}
115+
}
116+
.navigationViewStyle(StackNavigationViewStyle())
117+
.onAppear {
118+
Task {
119+
await initializeStore()
120+
}
121+
}
122+
}
123+
124+
private var connectionWarningCard: some View {
125+
HStack {
126+
Image(systemName: "exclamationmark.triangle.fill")
127+
.foregroundColor(.orange)
128+
.font(.title3)
129+
130+
VStack(alignment: .leading, spacing: 4) {
131+
Text("Not Connected")
132+
.font(.headline)
133+
Text("Billing service is not connected")
134+
.font(.caption)
135+
.foregroundColor(.secondary)
136+
}
137+
138+
Spacer()
139+
140+
Button("Retry") {
141+
Task {
142+
await initializeStore()
143+
}
144+
}
145+
.buttonStyle(.bordered)
146+
.controlSize(.small)
147+
}
148+
.padding()
149+
.background(Color.orange.opacity(0.1))
150+
.cornerRadius(12)
151+
}
152+
153+
private var loadingCard: some View {
154+
HStack {
155+
ProgressView()
156+
.progressViewStyle(CircularProgressViewStyle())
157+
158+
Text("Loading products...")
159+
.font(.subheadline)
160+
.foregroundColor(.secondary)
161+
.padding(.leading, 8)
162+
}
163+
.frame(maxWidth: .infinity)
164+
.padding()
165+
.background(Color(UIColor.secondarySystemGroupedBackground))
166+
.cornerRadius(12)
167+
}
168+
169+
private var emptyStateCard: some View {
170+
VStack(spacing: 12) {
171+
Image(systemName: "bag")
172+
.font(.largeTitle)
173+
.foregroundColor(.secondary)
174+
175+
Text("No products available")
176+
.font(.headline)
177+
.foregroundColor(.secondary)
178+
}
179+
.frame(maxWidth: .infinity)
180+
.padding(.vertical, 32)
181+
.background(Color(UIColor.secondarySystemGroupedBackground))
182+
.cornerRadius(12)
183+
}
184+
185+
private func productCard(for product: ProductIOS) -> some View {
186+
VStack(alignment: .leading, spacing: 12) {
187+
HStack(alignment: .top) {
188+
VStack(alignment: .leading, spacing: 4) {
189+
Text(product.displayName ?? product.displayNameIOS)
190+
.font(.headline)
191+
192+
if !product.description.isEmpty {
193+
Text(product.description)
194+
.font(.caption)
195+
.foregroundColor(.secondary)
196+
.lineLimit(2)
197+
}
198+
}
199+
200+
Spacer()
201+
202+
// Product type badges
203+
HStack(spacing: 4) {
204+
// Main type badge
205+
Text(product.type == .subs ? "subs" : "in-app")
206+
.font(.caption)
207+
.fontWeight(.medium)
208+
.padding(.horizontal, 8)
209+
.padding(.vertical, 4)
210+
.background(product.type == .subs ? Color.blue.opacity(0.1) : Color.green.opacity(0.1))
211+
.foregroundColor(product.type == .subs ? .blue : .green)
212+
.cornerRadius(6)
213+
214+
// Detailed type badge for non-subscription products
215+
if product.type != .subs {
216+
Text(getDetailedProductType(product.typeIOS))
217+
.font(.caption2)
218+
.fontWeight(.medium)
219+
.padding(.horizontal, 6)
220+
.padding(.vertical, 3)
221+
.background(getTypeColor(product.typeIOS).opacity(0.1))
222+
.foregroundColor(getTypeColor(product.typeIOS))
223+
.cornerRadius(4)
224+
}
225+
}
226+
}
227+
228+
HStack {
229+
Text(product.displayPrice ?? "--")
230+
.font(.title2)
231+
.fontWeight(.bold)
232+
.foregroundColor(.blue)
233+
234+
Spacer()
235+
236+
Text("SKU: \(product.id)")
237+
.font(.caption2)
238+
.foregroundColor(.secondary)
239+
}
240+
}
241+
.padding()
242+
.background(Color(UIColor.secondarySystemGroupedBackground))
243+
.cornerRadius(12)
244+
.shadow(color: .black.opacity(0.05), radius: 2, x: 0, y: 1)
245+
}
246+
247+
private func errorCard(message: String) -> some View {
248+
HStack {
249+
Image(systemName: "exclamationmark.circle.fill")
250+
.foregroundColor(.red)
251+
252+
Text(message)
253+
.font(.subheadline)
254+
.foregroundColor(.red)
255+
256+
Spacer()
257+
}
258+
.padding()
259+
.background(Color.red.opacity(0.1))
260+
.cornerRadius(12)
261+
}
262+
263+
private func initializeStore() async {
264+
isLoading = true
265+
errorMessage = nil
266+
267+
do {
268+
try await store.initConnection()
269+
270+
if store.isConnected {
271+
// Fetch all products using "all" type
272+
try await store.fetchProducts(
273+
skus: allProductIds,
274+
type: .all
275+
)
276+
} else {
277+
errorMessage = "Failed to connect to App Store"
278+
}
279+
} catch {
280+
errorMessage = error.localizedDescription
281+
}
282+
283+
isLoading = false
284+
}
285+
286+
private func getDetailedProductType(_ type: ProductTypeIOS) -> String {
287+
switch type {
288+
case .consumable:
289+
return "consumable"
290+
case .nonConsumable:
291+
return "non-consumable"
292+
case .autoRenewableSubscription:
293+
return "auto-renewable"
294+
case .nonRenewingSubscription:
295+
return "non-renewing"
296+
}
297+
}
298+
299+
private func getTypeColor(_ type: ProductTypeIOS) -> Color {
300+
switch type {
301+
case .consumable:
302+
return .orange
303+
case .nonConsumable:
304+
return .purple
305+
case .autoRenewableSubscription:
306+
return .blue
307+
case .nonRenewingSubscription:
308+
return .indigo
309+
}
310+
}
311+
312+
private func subscriptionCard(for subscription: ProductSubscriptionIOS) -> some View {
313+
VStack(alignment: .leading, spacing: 12) {
314+
HStack(alignment: .top) {
315+
VStack(alignment: .leading, spacing: 4) {
316+
Text(subscription.displayName ?? subscription.displayNameIOS)
317+
.font(.headline)
318+
319+
if !subscription.description.isEmpty {
320+
Text(subscription.description)
321+
.font(.caption)
322+
.foregroundColor(.secondary)
323+
.lineLimit(2)
324+
}
325+
}
326+
327+
Spacer()
328+
329+
// Subscription badge
330+
Text("subs")
331+
.font(.caption)
332+
.fontWeight(.medium)
333+
.padding(.horizontal, 8)
334+
.padding(.vertical, 4)
335+
.background(Color.blue.opacity(0.1))
336+
.foregroundColor(.blue)
337+
.cornerRadius(6)
338+
}
339+
340+
HStack {
341+
Text(subscription.displayPrice)
342+
.font(.title2)
343+
.fontWeight(.bold)
344+
.foregroundColor(.blue)
345+
346+
Spacer()
347+
348+
Text("SKU: \(subscription.id)")
349+
.font(.caption2)
350+
.foregroundColor(.secondary)
351+
}
352+
}
353+
.padding()
354+
.background(Color(UIColor.secondarySystemGroupedBackground))
355+
.cornerRadius(12)
356+
.shadow(color: .black.opacity(0.05), radius: 2, x: 0, y: 1)
357+
}
358+
}
359+
360+
#Preview {
361+
AllProductsView()
362+
}

0 commit comments

Comments
 (0)