From e42d2d3007f275695a341c96844d372f16102a54 Mon Sep 17 00:00:00 2001 From: Hyo Date: Sat, 27 Sep 2025 10:34:38 +0900 Subject: [PATCH 1/2] fix: resolve build errors in AllProductsView - Fix displayName optional handling - Fix description optional binding - Fix subscriptionCard function scope --- Example/Martie.xcodeproj/project.pbxproj | 4 + .../Screens/AllProductsView.swift | 278 ++++++++++++++++++ .../OpenIapExample/Screens/HomeScreen.swift | 10 +- 3 files changed, 291 insertions(+), 1 deletion(-) create mode 100644 Example/OpenIapExample/Screens/AllProductsView.swift diff --git a/Example/Martie.xcodeproj/project.pbxproj b/Example/Martie.xcodeproj/project.pbxproj index 7e07085..ccdadff 100644 --- a/Example/Martie.xcodeproj/project.pbxproj +++ b/Example/Martie.xcodeproj/project.pbxproj @@ -12,6 +12,7 @@ C0E1F5ED2C8F1A9500123456 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = C0E1F5EC2C8F1A9500123456 /* Assets.xcassets */; }; C0E1F5F02C8F1A9500123456 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = C0E1F5EF2C8F1A9500123456 /* Preview Assets.xcassets */; }; C0E1F5F82C8F1AA300123456 /* HomeScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0E1F5F72C8F1AA300123456 /* HomeScreen.swift */; }; + C0APV0022D20000000000001 /* AllProductsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0APV0012D20000000000001 /* AllProductsView.swift */; }; C0E1F5FA2C8F1AAB00123456 /* PurchaseFlowScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0E1F5F92C8F1AAB00123456 /* PurchaseFlowScreen.swift */; }; C0E1F5FC2C8F1AB000123456 /* SubscriptionFlowScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0E1F5FB2C8F1AB000123456 /* SubscriptionFlowScreen.swift */; }; C0E1F5FE2C8F1AB500123456 /* AvailablePurchasesScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0E1F5FD2C8F1AB500123456 /* AvailablePurchasesScreen.swift */; }; @@ -48,6 +49,7 @@ C0E1F5FB2C8F1AB000123456 /* SubscriptionFlowScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscriptionFlowScreen.swift; sourceTree = ""; }; C0E1F5FD2C8F1AB500123456 /* AvailablePurchasesScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvailablePurchasesScreen.swift; sourceTree = ""; }; C0E1F5FF2C8F1ABA00123456 /* OfferCodeScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OfferCodeScreen.swift; sourceTree = ""; }; + C0APV0012D20000000000001 /* AllProductsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AllProductsView.swift; sourceTree = ""; }; C0E1F6032C8F1AC500123456 /* AppColors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppColors.swift; sourceTree = ""; }; C0IAC0002D10000000000001 /* IapCompat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IapCompat.swift; sourceTree = ""; }; C0UI10012D00000000000001 /* FeatureCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeatureCard.swift; sourceTree = ""; }; @@ -123,6 +125,7 @@ children = ( C0UI20012D00000000000001 /* uis */, C0E1F5F72C8F1AA300123456 /* HomeScreen.swift */, + C0APV0012D20000000000001 /* AllProductsView.swift */, C0E1F5F92C8F1AAB00123456 /* PurchaseFlowScreen.swift */, C0E1F5FB2C8F1AB000123456 /* SubscriptionFlowScreen.swift */, C0E1F5FD2C8F1AB500123456 /* AvailablePurchasesScreen.swift */, @@ -248,6 +251,7 @@ files = ( C0E1F5EB2C8F1A9400123456 /* ContentView.swift in Sources */, C0E1F5F82C8F1AA300123456 /* HomeScreen.swift in Sources */, + C0APV0022D20000000000001 /* AllProductsView.swift in Sources */, C0E1F5FA2C8F1AAB00123456 /* PurchaseFlowScreen.swift in Sources */, C0E1F5FC2C8F1AB000123456 /* SubscriptionFlowScreen.swift in Sources */, C0E1F5FE2C8F1AB500123456 /* AvailablePurchasesScreen.swift in Sources */, diff --git a/Example/OpenIapExample/Screens/AllProductsView.swift b/Example/OpenIapExample/Screens/AllProductsView.swift new file mode 100644 index 0000000..61a21eb --- /dev/null +++ b/Example/OpenIapExample/Screens/AllProductsView.swift @@ -0,0 +1,278 @@ +import SwiftUI +import OpenIAP + +@available(iOS 15.0, *) +struct AllProductsView: View { + @StateObject private var store = OpenIapStore() + @State private var isLoading = false + @State private var errorMessage: String? + @Environment(\.dismiss) private var dismiss + + // Product IDs from other screens + private let allProductIds: [String] = [ + "dev.hyo.martie.10bulbs", + "dev.hyo.martie.30bulbs", + "dev.hyo.martie.premium", + "dev.hyo.martie.premium_year" + ] + + + var body: some View { + NavigationView { + ZStack { + Color(UIColor.systemGroupedBackground) + .ignoresSafeArea() + + ScrollView { + VStack(spacing: 16) { + if !store.isConnected { + connectionWarningCard + } + + if isLoading { + loadingCard + } + + if !isLoading && store.iosProducts.isEmpty && store.iosSubscriptionProducts.isEmpty && store.isConnected { + emptyStateCard + } + + // Display regular products + ForEach(store.iosProducts, id: \.id) { product in + productCard(for: product) + } + + // Display subscription products + ForEach(store.iosSubscriptionProducts, id: \.id) { subscription in + subscriptionCard(for: subscription) + } + + if let error = errorMessage { + errorCard(message: error) + } + } + .padding() + } + } + .navigationTitle("All Products") + .navigationBarTitleDisplayMode(.large) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button(action: { + Task { + try? await store.endConnection() + } + dismiss() + }) { + Image(systemName: "arrow.left") + .foregroundColor(.primary) + } + } + } + } + .navigationViewStyle(StackNavigationViewStyle()) + .onAppear { + Task { + await initializeStore() + } + } + } + + private var connectionWarningCard: some View { + HStack { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundColor(.orange) + .font(.title3) + + VStack(alignment: .leading, spacing: 4) { + Text("Not Connected") + .font(.headline) + Text("Billing service is not connected") + .font(.caption) + .foregroundColor(.secondary) + } + + Spacer() + + Button("Retry") { + Task { + await initializeStore() + } + } + .buttonStyle(.bordered) + .controlSize(.small) + } + .padding() + .background(Color.orange.opacity(0.1)) + .cornerRadius(12) + } + + private var loadingCard: some View { + HStack { + ProgressView() + .progressViewStyle(CircularProgressViewStyle()) + + Text("Loading products...") + .font(.subheadline) + .foregroundColor(.secondary) + .padding(.leading, 8) + } + .frame(maxWidth: .infinity) + .padding() + .background(Color(UIColor.secondarySystemGroupedBackground)) + .cornerRadius(12) + } + + private var emptyStateCard: some View { + VStack(spacing: 12) { + Image(systemName: "bag") + .font(.largeTitle) + .foregroundColor(.secondary) + + Text("No products available") + .font(.headline) + .foregroundColor(.secondary) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 32) + .background(Color(UIColor.secondarySystemGroupedBackground)) + .cornerRadius(12) + } + + private func productCard(for product: ProductIOS) -> some View { + VStack(alignment: .leading, spacing: 12) { + HStack(alignment: .top) { + VStack(alignment: .leading, spacing: 4) { + Text(product.displayName ?? product.displayNameIOS) + .font(.headline) + + if !product.description.isEmpty { + Text(product.description) + .font(.caption) + .foregroundColor(.secondary) + .lineLimit(2) + } + } + + Spacer() + + // Product type badge + Text(product.type == .subs ? "subs" : "in-app") + .font(.caption) + .fontWeight(.medium) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(product.type == .subs ? Color.blue.opacity(0.1) : Color.green.opacity(0.1)) + .foregroundColor(product.type == .subs ? .blue : .green) + .cornerRadius(6) + } + + HStack { + Text(product.displayPrice ?? "--") + .font(.title2) + .fontWeight(.bold) + .foregroundColor(.blue) + + Spacer() + + Text("SKU: \(product.id)") + .font(.caption2) + .foregroundColor(.secondary) + } + } + .padding() + .background(Color(UIColor.secondarySystemGroupedBackground)) + .cornerRadius(12) + .shadow(color: .black.opacity(0.05), radius: 2, x: 0, y: 1) + } + + private func errorCard(message: String) -> some View { + HStack { + Image(systemName: "exclamationmark.circle.fill") + .foregroundColor(.red) + + Text(message) + .font(.subheadline) + .foregroundColor(.red) + + Spacer() + } + .padding() + .background(Color.red.opacity(0.1)) + .cornerRadius(12) + } + + private func initializeStore() async { + isLoading = true + errorMessage = nil + + do { + try await store.initConnection() + + if store.isConnected { + // Fetch all products using "all" type + try await store.fetchProducts( + skus: allProductIds, + type: .all + ) + } else { + errorMessage = "Failed to connect to App Store" + } + } catch { + errorMessage = error.localizedDescription + } + + isLoading = false + } + + private func subscriptionCard(for subscription: ProductSubscriptionIOS) -> some View { + VStack(alignment: .leading, spacing: 12) { + HStack(alignment: .top) { + VStack(alignment: .leading, spacing: 4) { + Text(subscription.displayName ?? subscription.displayNameIOS) + .font(.headline) + + if !subscription.description.isEmpty { + Text(subscription.description) + .font(.caption) + .foregroundColor(.secondary) + .lineLimit(2) + } + } + + Spacer() + + // Subscription badge + Text("subs") + .font(.caption) + .fontWeight(.medium) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Color.blue.opacity(0.1)) + .foregroundColor(.blue) + .cornerRadius(6) + } + + HStack { + Text(subscription.displayPrice) + .font(.title2) + .fontWeight(.bold) + .foregroundColor(.blue) + + Spacer() + + Text("SKU: \(subscription.id)") + .font(.caption2) + .foregroundColor(.secondary) + } + } + .padding() + .background(Color(UIColor.secondarySystemGroupedBackground)) + .cornerRadius(12) + .shadow(color: .black.opacity(0.05), radius: 2, x: 0, y: 1) + } +} + +#Preview { + AllProductsView() +} \ No newline at end of file diff --git a/Example/OpenIapExample/Screens/HomeScreen.swift b/Example/OpenIapExample/Screens/HomeScreen.swift index 77242a6..bcc6b9f 100644 --- a/Example/OpenIapExample/Screens/HomeScreen.swift +++ b/Example/OpenIapExample/Screens/HomeScreen.swift @@ -41,11 +41,19 @@ struct HomeScreen: View { .padding(.horizontal) LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 16), count: 2), spacing: 16) { + FeatureCard( + title: "OpenIAP\nExample", + subtitle: "View all products", + icon: "bag.fill", + color: AppColors.primary, + destination: AnyView(AllProductsView()) + ) + FeatureCard( title: "Purchase\nFlow", subtitle: "Test product purchases", icon: "cart.fill", - color: AppColors.primary, + color: Color.teal, destination: AnyView(PurchaseFlowScreen()) ) From 27f864c90cd610fe6d442c7f82b146f93f348380 Mon Sep 17 00:00:00 2001 From: Hyo Date: Sat, 27 Sep 2025 16:42:41 +0900 Subject: [PATCH 2/2] chore(example): show all products in main menu --- .../Screens/AllProductsView.swift | 120 +++++++++++++++--- .../OpenIapExample/Screens/HomeScreen.swift | 76 ++++++----- 2 files changed, 144 insertions(+), 52 deletions(-) diff --git a/Example/OpenIapExample/Screens/AllProductsView.swift b/Example/OpenIapExample/Screens/AllProductsView.swift index 61a21eb..763b3a4 100644 --- a/Example/OpenIapExample/Screens/AllProductsView.swift +++ b/Example/OpenIapExample/Screens/AllProductsView.swift @@ -12,10 +12,53 @@ struct AllProductsView: View { private let allProductIds: [String] = [ "dev.hyo.martie.10bulbs", "dev.hyo.martie.30bulbs", + "dev.hyo.martie.certified", "dev.hyo.martie.premium", "dev.hyo.martie.premium_year" ] + // Enum to unify product types for display + private enum UnifiedProduct { + case regular(ProductIOS) + case subscription(ProductSubscriptionIOS) + + var id: String { + switch self { + case .regular(let product): + return product.id + case .subscription(let sub): + return sub.id + } + } + + var sortOrder: Int { + switch self { + case .regular(let product): + switch product.typeIOS { + case .nonConsumable: + return 1 // First + case .consumable: + return 2 // Second + default: + return 3 // Third + } + case .subscription: + return 4 // Last + } + } + } + + // All products sorted by type: non-consumable, consumable, then subscriptions + private var sortedAllProducts: [UnifiedProduct] { + let regularProducts = store.iosProducts.map { UnifiedProduct.regular($0) } + let subscriptionProducts = store.iosSubscriptionProducts.map { UnifiedProduct.subscription($0) } + let allProducts = regularProducts + subscriptionProducts + + return allProducts.sorted { first, second in + first.sortOrder < second.sortOrder + } + } + var body: some View { NavigationView { @@ -33,18 +76,18 @@ struct AllProductsView: View { loadingCard } - if !isLoading && store.iosProducts.isEmpty && store.iosSubscriptionProducts.isEmpty && store.isConnected { + if !isLoading && sortedAllProducts.isEmpty && store.isConnected { emptyStateCard } - // Display regular products - ForEach(store.iosProducts, id: \.id) { product in - productCard(for: product) - } - - // Display subscription products - ForEach(store.iosSubscriptionProducts, id: \.id) { subscription in - subscriptionCard(for: subscription) + // Display all products sorted by type: non-consumable, consumable, then subscriptions + ForEach(sortedAllProducts, id: \.id) { unifiedProduct in + switch unifiedProduct { + case .regular(let product): + productCard(for: product) + case .subscription(let subscription): + subscriptionCard(for: subscription) + } } if let error = errorMessage { @@ -156,15 +199,30 @@ struct AllProductsView: View { Spacer() - // Product type badge - Text(product.type == .subs ? "subs" : "in-app") - .font(.caption) - .fontWeight(.medium) - .padding(.horizontal, 8) - .padding(.vertical, 4) - .background(product.type == .subs ? Color.blue.opacity(0.1) : Color.green.opacity(0.1)) - .foregroundColor(product.type == .subs ? .blue : .green) - .cornerRadius(6) + // Product type badges + HStack(spacing: 4) { + // Main type badge + Text(product.type == .subs ? "subs" : "in-app") + .font(.caption) + .fontWeight(.medium) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(product.type == .subs ? Color.blue.opacity(0.1) : Color.green.opacity(0.1)) + .foregroundColor(product.type == .subs ? .blue : .green) + .cornerRadius(6) + + // Detailed type badge for non-subscription products + if product.type != .subs { + Text(getDetailedProductType(product.typeIOS)) + .font(.caption2) + .fontWeight(.medium) + .padding(.horizontal, 6) + .padding(.vertical, 3) + .background(getTypeColor(product.typeIOS).opacity(0.1)) + .foregroundColor(getTypeColor(product.typeIOS)) + .cornerRadius(4) + } + } } HStack { @@ -225,6 +283,32 @@ struct AllProductsView: View { isLoading = false } + private func getDetailedProductType(_ type: ProductTypeIOS) -> String { + switch type { + case .consumable: + return "consumable" + case .nonConsumable: + return "non-consumable" + case .autoRenewableSubscription: + return "auto-renewable" + case .nonRenewingSubscription: + return "non-renewing" + } + } + + private func getTypeColor(_ type: ProductTypeIOS) -> Color { + switch type { + case .consumable: + return .orange + case .nonConsumable: + return .purple + case .autoRenewableSubscription: + return .blue + case .nonRenewingSubscription: + return .indigo + } + } + private func subscriptionCard(for subscription: ProductSubscriptionIOS) -> some View { VStack(alignment: .leading, spacing: 12) { HStack(alignment: .top) { diff --git a/Example/OpenIapExample/Screens/HomeScreen.swift b/Example/OpenIapExample/Screens/HomeScreen.swift index bcc6b9f..d5ba22c 100644 --- a/Example/OpenIapExample/Screens/HomeScreen.swift +++ b/Example/OpenIapExample/Screens/HomeScreen.swift @@ -3,52 +3,57 @@ import OpenIAP @available(iOS 15.0, *) struct HomeScreen: View { + @State private var showAllProducts = false + var body: some View { GeometryReader { geometry in ScrollView { VStack(spacing: 20) { Spacer(minLength: 0) - VStack(alignment: .leading, spacing: 16) { - HStack { - Image(systemName: "bag.fill") - .font(.largeTitle) - .foregroundColor(AppColors.primary) - - VStack(alignment: .leading, spacing: 4) { - Text("OpenIAP Example") - .font(.headline) - - Text("iOS") - .font(.caption) - .padding(.horizontal, 8) - .padding(.vertical, 2) - .background(AppColors.secondary.opacity(0.2)) - .cornerRadius(4) + Button(action: { + showAllProducts = true + }) { + VStack(alignment: .leading, spacing: 16) { + HStack { + Image(systemName: "bag.fill") + .font(.largeTitle) + .foregroundColor(AppColors.primary) + + VStack(alignment: .leading, spacing: 4) { + Text("OpenIAP Example") + .font(.headline) + .foregroundColor(.primary) + + Text("iOS") + .font(.caption) + .padding(.horizontal, 8) + .padding(.vertical, 2) + .background(AppColors.secondary.opacity(0.2)) + .cornerRadius(4) + } + + Spacer() + + Image(systemName: "chevron.right") + .foregroundColor(.secondary) + .font(.system(size: 14)) } - - Spacer() + + Text("Test in-app purchases and subscription features with StoreKit integration.") + .font(.subheadline) + .foregroundColor(.secondary) + .multilineTextAlignment(.leading) } - - Text("Test in-app purchases and subscription features with StoreKit integration.") - .font(.subheadline) - .foregroundColor(.secondary) + .padding() + .background(AppColors.cardBackground) + .cornerRadius(12) + .shadow(radius: 2) } - .padding() - .background(AppColors.cardBackground) - .cornerRadius(12) - .shadow(radius: 2) + .buttonStyle(PlainButtonStyle()) .padding(.horizontal) LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 16), count: 2), spacing: 16) { - FeatureCard( - title: "OpenIAP\nExample", - subtitle: "View all products", - icon: "bag.fill", - color: AppColors.primary, - destination: AnyView(AllProductsView()) - ) - FeatureCard( title: "Purchase\nFlow", subtitle: "Test product purchases", @@ -92,5 +97,8 @@ struct HomeScreen: View { .background(AppColors.background) .navigationTitle("OpenIAP Samples") .navigationBarTitleDisplayMode(.large) + .sheet(isPresented: $showAllProducts) { + AllProductsView() + } } }