Add tri-state purchase status

This commit is contained in:
小弟调调 2026-03-25 22:02:40 +08:00
parent d879dcc8eb
commit 2838432f56
4 changed files with 108 additions and 7 deletions

View file

@ -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
}
}
}

View file

@ -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)
//

View file

@ -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<String> = []
///
@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<String>) {
purchasedProductIDs = productIDs
purchaseStatus = productIDs.isEmpty ? .notPurchased : .purchased
}
func _setPurchaseStatusForTesting(_ status: PurchaseStatus) {
purchaseStatus = status
if status != .purchased {
purchasedProductIDs = []
}
}
}
#endif

View file

@ -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)
}