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