diff --git a/Example/Example/Purchase+Example.swift b/Example/Example/Purchase+Example.swift index 961d6a4..5e21aab 100644 --- a/Example/Example/Purchase+Example.swift +++ b/Example/Example/Purchase+Example.swift @@ -56,10 +56,10 @@ struct PurchaseExample: View { Text("购买状态") .font(.headline) HStack { - Image(systemName: store.hasPurchased ? "checkmark.circle.fill" : "xmark.circle.fill") - .foregroundColor(store.hasPurchased ? .green : .red) + Image(systemName: statusIconName) + .foregroundColor(statusColor) - Text(store.hasPurchased ? "已购买" : "未购买") + Text(statusText) .font(.subheadline) Spacer() @@ -99,7 +99,20 @@ struct PurchaseExample: View { Text("应用功能") .font(.headline) - if store.hasNotPurchased { + if store.purchaseStatus == .loading { + VStack(alignment: .leading, spacing: 8) { + Text("同步购买状态中") + .font(.subheadline) + .foregroundColor(.secondary) + + Text("正在确认已购项目,请稍候") + .font(.caption) + .foregroundColor(.secondary) + } + .padding() + .background(Color.secondary.opacity(0.08)) + .cornerRadius(8) + } else if store.hasNotPurchased { VStack(alignment: .leading, spacing: 8) { Text("🔒 受限功能") .font(.subheadline) @@ -128,6 +141,39 @@ struct PurchaseExample: View { } } } + + private var statusText: String { + switch store.purchaseStatus { + case .loading: + "同步中" + case .purchased: + "已购买" + case .notPurchased: + "未购买" + } + } + + private var statusIconName: String { + switch store.purchaseStatus { + case .loading: + "clock.badge.questionmark" + case .purchased: + "checkmark.circle.fill" + case .notPurchased: + "xmark.circle.fill" + } + } + + private var statusColor: Color { + switch store.purchaseStatus { + case .loading: + .secondary + case .purchased: + .green + case .notPurchased: + .red + } + } } diff --git a/Example/ExampleTests/ExampleTests.swift b/Example/ExampleTests/ExampleTests.swift index 01061be..3f76492 100644 --- a/Example/ExampleTests/ExampleTests.swift +++ b/Example/ExampleTests/ExampleTests.swift @@ -63,9 +63,14 @@ final class StoreKitNetworkErrorTests { func testStoreContextInitialization() async throws { let session = try makeSession() let store = await StoreContext(products: AppProduct.allCases) + #expect(await store.purchaseStatus == .loading) + #expect(await store.hasResolvedPurchaseStatus == false) + #expect(await store.hasNotPurchased == false) try await Task.sleep(for: .milliseconds(500)) #expect(await store.products.count == 2) #expect(await store.purchasedProductIDs.count == 0) + #expect(await store.purchaseStatus == .notPurchased) + #expect(await store.hasResolvedPurchaseStatus == true) #expect(await store.hasNotPurchased == true) #expect(await store.hasPurchased == false) let product = await store.products.first(where: { $0.id == AppProduct.lifetime.id }) @@ -86,6 +91,7 @@ final class StoreKitNetworkErrorTests { try await Task.sleep(for: .milliseconds(500)) #expect(await store.products.count == 0) #expect(await store.purchasedProductIDs.count == 0) + #expect(await store.purchaseStatus == .notPurchased) #expect(await store.hasNotPurchased == true) #expect(await store.hasPurchased == false) session.clearTransactions() @@ -100,6 +106,7 @@ final class StoreKitNetworkErrorTests { try await Task.sleep(for: .milliseconds(500)) #expect(await store.products.count == 2) #expect(await store.purchasedProductIDs.count == 0) + #expect(await store.purchaseStatus == .notPurchased) #expect(await store.hasNotPurchased == true) #expect(await store.hasPurchased == false) let lifetime = await store.products.first(where: { $0.id == AppProduct.lifetime.id }) @@ -109,6 +116,7 @@ final class StoreKitNetworkErrorTests { session.disableDialogs = true await store.purchase(lifetime) } + #expect(await store.purchaseStatus == .purchased) #expect(await store.hasPurchased == true) #expect(await store.purchasedProductIDs.contains(AppProduct.lifetime.id) == true) session.clearTransactions() @@ -117,6 +125,7 @@ final class StoreKitNetworkErrorTests { #expect(await store.purchasedProductIDs.contains(AppProduct.lifetime.id) == true) session.disableDialogs = true await store.restorePurchases() + #expect(await store.purchaseStatus == .notPurchased) #expect(await store.hasPurchased == false) session.clearTransactions() session.resetToDefaultState() @@ -140,6 +149,7 @@ final class StoreKitNetworkErrorTests { } // 验证购买成功 + #expect(await store.purchaseStatus == .purchased) #expect(await store.hasPurchased == true) #expect(await store.purchasedProductIDs.contains(AppProduct.monthly.id) == true) @@ -152,6 +162,7 @@ final class StoreKitNetworkErrorTests { // 验证订阅已过期 #expect(await store.purchasedProductIDs.contains(AppProduct.monthly.id) == false) + #expect(await store.purchaseStatus == .notPurchased) #expect(await store.hasPurchased == false) // 清理 diff --git a/Sources/StoreKitHelper/StoreKitHelper.swift b/Sources/StoreKitHelper/StoreKitHelper.swift index 33e7986..698e17f 100644 --- a/Sources/StoreKitHelper/StoreKitHelper.swift +++ b/Sources/StoreKitHelper/StoreKitHelper.swift @@ -13,6 +13,16 @@ public protocol InAppProduct: CaseIterable, Identifiable where ID == ProductID { var id: ProductID { get } } +/// 购买状态 +public enum PurchaseStatus: Sendable, Equatable { + /// 当前购买状态尚未完成首次同步 + case loading + /// 当前存在有效购买 + case purchased + /// 当前不存在有效购买 + case notPurchased +} + // MARK: - StoreContext /// StoreKit 上下文,用于管理应用内购买状态 @@ -23,6 +33,8 @@ public final class StoreContext: ObservableObject { @Published public private(set) var products: [Product] = [] /// 购买的产品标识符集合 @Published public private(set) var purchasedProductIDs: Set = [] + /// 当前购买状态 + @Published public private(set) var purchaseStatus: PurchaseStatus = .loading /// 是否正在加载 @Published public private(set) var isLoading = false /// 错误信息 @@ -35,12 +47,17 @@ public final class StoreContext: ObservableObject { /// 用户是否没有购买任何产品 public var hasNotPurchased: Bool { - purchasedProductIDs.isEmpty + purchaseStatus == .notPurchased } /// 用户是否已购买任何产品 public var hasPurchased: Bool { - !purchasedProductIDs.isEmpty + purchaseStatus == .purchased + } + + /// 用户购买状态是否已完成首次同步 + public var hasResolvedPurchaseStatus: Bool { + purchaseStatus != .loading } public let productIDs: [String] @@ -84,6 +101,7 @@ public final class StoreContext: ObservableObject { if let transaction = checkVerified(verificationResult) { // 更新购买状态 purchasedProductIDs.insert(transaction.productID) + purchaseStatus = .purchased // 完成交易 await transaction.finish() @@ -188,6 +206,7 @@ public final class StoreContext: ObservableObject { if let transaction = checkVerified(verificationResult) { // 更新购买状态 purchasedProductIDs.insert(transaction.productID) + purchaseStatus = .purchased // 完成交易 await transaction.finish() } @@ -246,7 +265,23 @@ public final class StoreContext: ObservableObject { await MainActor.run { self.purchasedProductIDs = purchasedIDs + self.purchaseStatus = purchasedIDs.isEmpty ? .notPurchased : .purchased } } } +#if DEBUG +extension StoreContext { + func _setPurchasedProductIDsForTesting(_ productIDs: Set) { + purchasedProductIDs = productIDs + purchaseStatus = productIDs.isEmpty ? .notPurchased : .purchased + } + + func _setPurchaseStatusForTesting(_ status: PurchaseStatus) { + purchaseStatus = status + if status != .purchased { + purchasedProductIDs = [] + } + } +} +#endif diff --git a/Tests/StoreKitHelperTests/StoreKitHelperTests.swift b/Tests/StoreKitHelperTests/StoreKitHelperTests.swift index 7d77f27..9965537 100644 --- a/Tests/StoreKitHelperTests/StoreKitHelperTests.swift +++ b/Tests/StoreKitHelperTests/StoreKitHelperTests.swift @@ -32,7 +32,9 @@ func testStoreContextInitialization() async throws { await MainActor.run { #expect(store.products.isEmpty) // 初始时产品列表为空(需要从 App Store 加载) #expect(store.purchasedProductIDs.isEmpty) - #expect(store.hasNotPurchased == true) + #expect(store.purchaseStatus == .loading) + #expect(store.hasResolvedPurchaseStatus == false) + #expect(store.hasNotPurchased == false) #expect(store.hasPurchased == false) } } @@ -43,13 +45,20 @@ func testPurchaseStatusMethods() async throws { await MainActor.run { // 测试购买状态检查方法 + #expect(store.purchaseStatus == .loading) #expect(store.isPurchased("test.basic") == false) #expect(store.isPurchased(TestProduct.premium) == false) + store._setPurchaseStatusForTesting(.notPurchased) + #expect(store.purchaseStatus == .notPurchased) + #expect(store.hasResolvedPurchaseStatus == true) + #expect(store.hasNotPurchased == true) + #expect(store.hasPurchased == false) // 模拟购买状态(仅在测试中使用) store._setPurchasedProductIDsForTesting(["test.basic"]) #expect(store.isPurchased("test.basic") == true) #expect(store.isPurchased(TestProduct.basic) == true) #expect(store.isPurchased("test.premium") == false) + #expect(store.purchaseStatus == .purchased) #expect(store.hasPurchased == true) #expect(store.hasNotPurchased == false) }