Skip to content

Commit 4d16a0f

Browse files
committed
feat: add nothingToRestore case and make StoreService observable
Add .nothingToRestore case to StoreRestoreResult to display informational message when no purchases are found during restore. Make StoreService observable with stored properties for hasPurchasedProducts and hasActiveSubscription to enable reactive UI updates when purchase state changes. Update example app to initialize StoreHelper on load.
1 parent 5fcb532 commit 4d16a0f

10 files changed

Lines changed: 117 additions & 30 deletions

File tree

.context/notes.md

Whitespace-only changes.

.context/todos.md

Whitespace-only changes.

Example/DeveloperSupportStoreExample.xcodeproj/project.pbxproj

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -184,7 +184,7 @@
184184
mainGroup = 8B41F63C2DEDD0D5001A66F9;
185185
minimizedProjectReferenceProxies = 1;
186186
packageReferences = (
187-
A5AEF7AC2F164C9E00186E81 /* XCLocalSwiftPackageReference "../../florence-v1" */,
187+
A5AEF7AC2F164C9E00186E81 /* XCLocalSwiftPackageReference "../" */,
188188
);
189189
preferredProjectObjectVersion = 77;
190190
productRefGroup = 8B41F6462DEDD0D5001A66F9 /* Products */;
@@ -474,9 +474,9 @@
474474
/* End XCConfigurationList section */
475475

476476
/* Begin XCLocalSwiftPackageReference section */
477-
A5AEF7AC2F164C9E00186E81 /* XCLocalSwiftPackageReference "../../florence-v1" */ = {
477+
A5AEF7AC2F164C9E00186E81 /* XCLocalSwiftPackageReference "../" */ = {
478478
isa = XCLocalSwiftPackageReference;
479-
relativePath = "../../florence-v1";
479+
relativePath = "../";
480480
};
481481
/* End XCLocalSwiftPackageReference section */
482482

Example/DeveloperSupportStoreExample/ContentView.swift

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,14 +28,15 @@ public struct ExampleStoreConfiguration: StoreConfigurationProtocol {
2828

2929
public struct ContentView: View {
3030
@State private var isStorePresented = false
31-
31+
@State private var storeService: StoreServiceProtocol = StoreService(isLoggingEnabled: true)
3232
private let configuration = ExampleStoreConfiguration()
3333

3434
public var body: some View {
3535
launcherView
3636
.sheet(isPresented: $isStorePresented) {
3737
DeveloperSupportStoreView(
3838
configuration: configuration,
39+
storeService: storeService,
3940
onPurchaseSuccess: { productId in
4041
print("Purchase successful: \(productId)")
4142
},
@@ -44,14 +45,23 @@ public struct ContentView: View {
4445
}
4546
)
4647
}
48+
.task {
49+
try? await storeService.syncStoreData()
50+
}
4751
}
4852

4953
@ViewBuilder
5054
private var launcherView: some View {
5155
VStack(spacing: 24) {
52-
Image(systemName: "heart.fill")
53-
.font(.system(size: 64))
54-
.foregroundStyle(.pink)
56+
HStack {
57+
Image(systemName: "heart.fill")
58+
.font(.system(size: 64))
59+
.foregroundStyle(storeService.hasPurchasedProducts ? .green : .pink)
60+
61+
Image(systemName: "heart.fill")
62+
.font(.system(size: 64))
63+
.foregroundStyle( storeService.hasActiveSubscription ? .green : .pink)
64+
}
5565

5666
Text("DeveloperSupportStore")
5767
.font(.title)

Sources/DeveloperSupportStore/Models/StoreRestoreResult.swift

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,10 @@ import Foundation
99

1010
/// Represents the result of a restore purchases action.
1111
public enum StoreRestoreResult: Sendable, Equatable {
12-
/// The restore completed successfully.
12+
/// The restore completed successfully with purchases found.
1313
case success
1414
/// The restore failed with an error.
1515
case failure
16+
/// The restore completed but no previous purchases were found.
17+
case nothingToRestore
1618
}

Sources/DeveloperSupportStore/Resources/Localizable.xcstrings

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,23 @@
124124
}
125125
}
126126
},
127+
"store.restore.nothingToRestore" : {
128+
"extractionState" : "manual",
129+
"localizations" : {
130+
"en" : {
131+
"stringUnit" : {
132+
"state" : "translated",
133+
"value" : "No previous purchases found"
134+
}
135+
},
136+
"uk" : {
137+
"stringUnit" : {
138+
"state" : "translated",
139+
"value" : "Попередні покупки не знайдено"
140+
}
141+
}
142+
}
143+
},
127144
"store.restore.success" : {
128145
"extractionState" : "manual",
129146
"localizations" : {

Sources/DeveloperSupportStore/Services/StoreService.swift

Lines changed: 52 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import StoreKit
1313
/// Service for handling App Store interactions using StoreHelper.
1414
///
1515
/// Products are loaded automatically from `Products.plist` in your app bundle.
16+
@Observable
1617
@MainActor
1718
public final class StoreService: StoreServiceProtocol {
1819
private let storeHelper: StoreHelper
@@ -22,6 +23,15 @@ public final class StoreService: StoreServiceProtocol {
2223
private var _subscriptionProducts: [Product] = []
2324
private var _nonConsumableProducts: [Product] = []
2425

26+
/// Indicates whether the user has an active subscription.
27+
public private(set) var hasActiveSubscription: Bool = false
28+
29+
/// Indicates whether the user has any purchased products.
30+
public private(set) var hasPurchasedProducts: Bool = false
31+
32+
/// Task for listening to transaction updates.
33+
private var transactionListenerTask: Task<Void, Never>?
34+
2535
/// Creates a new store service.
2636
///
2737
/// - Parameters:
@@ -30,6 +40,40 @@ public final class StoreService: StoreServiceProtocol {
3040
public init(storeHelper: StoreHelper = StoreHelper(), isLoggingEnabled: Bool = true) {
3141
self.storeHelper = storeHelper
3242
logger = StoreLogger(category: "StoreService", isEnabled: isLoggingEnabled)
43+
startTransactionListener()
44+
}
45+
46+
// MARK: - Purchase State
47+
48+
/// Updates purchase state properties from StoreHelper.
49+
private func updatePurchaseState() {
50+
hasPurchasedProducts = !storeHelper.purchasedProducts.isEmpty
51+
52+
if let subscriptionIds = storeHelper.subscriptionProductIds {
53+
hasActiveSubscription = storeHelper.purchasedProducts.contains { productId in
54+
subscriptionIds.contains(productId)
55+
}
56+
} else {
57+
hasActiveSubscription = false
58+
}
59+
60+
logger.notice("updatePurchaseState: hasPurchasedProducts=\(hasPurchasedProducts), hasActiveSubscription=\(hasActiveSubscription)")
61+
}
62+
63+
/// Starts listening for StoreKit transaction updates.
64+
private func startTransactionListener() {
65+
transactionListenerTask = Task { [weak self] in
66+
for await result in Transaction.updates {
67+
guard let self else { return }
68+
69+
if case .verified = result {
70+
// Transaction verified, update purchase state
71+
await MainActor.run {
72+
self.updatePurchaseState()
73+
}
74+
}
75+
}
76+
}
3377
}
3478

3579
// MARK: - Product Collections
@@ -44,14 +88,6 @@ public final class StoreService: StoreServiceProtocol {
4488
_nonConsumableProducts
4589
}
4690

47-
/// Indicates whether the user has an active subscription.
48-
public var hasActiveSubscription: Bool {
49-
guard let subscriptionIds = storeHelper.subscriptionProductIds else { return false }
50-
return storeHelper.purchasedProducts.contains { productId in
51-
subscriptionIds.contains(productId)
52-
}
53-
}
54-
5591
// MARK: - Actions
5692

5793
/// Synchronizes store data with the App Store.
@@ -86,6 +122,9 @@ public final class StoreService: StoreServiceProtocol {
86122

87123
logger.notice("_subscriptionProducts count: \(_subscriptionProducts.count)")
88124
logger.notice("_nonConsumableProducts count: \(_nonConsumableProducts.count)")
125+
126+
// Update observable purchase state
127+
updatePurchaseState()
89128
}
90129

91130
/// Initiates the purchase of a product.
@@ -151,9 +190,11 @@ public struct MockProductData: Sendable {
151190
///
152191
/// This service provides mock data for previews. For full testing with actual StoreKit
153192
/// behavior, use a StoreKit Configuration file in your test target.
193+
@Observable
154194
@MainActor
155195
public final class StoreServicePreview: StoreServiceProtocol {
156196
public var hasActiveSubscription: Bool
197+
public var hasPurchasedProducts: Bool
157198

158199
// Internal storage for mock data
159200
private let mockSubscriptions: [MockProductData]
@@ -173,18 +214,21 @@ public final class StoreServicePreview: StoreServiceProtocol {
173214
///
174215
/// - Parameters:
175216
/// - hasActiveSubscription: Whether to simulate an active subscription.
217+
/// - hasPurchasedProducts: Whether to simulate having purchased products.
176218
/// - mockSubscriptions: Mock subscription product data.
177219
/// - mockPurchases: Mock one-time purchase product data.
178220
/// - purchaseResult: The result to return from purchase attempts.
179221
/// - syncDelay: Artificial delay for sync operations (simulates network).
180222
public init(
181223
hasActiveSubscription: Bool = false,
224+
hasPurchasedProducts: Bool = false,
182225
mockSubscriptions: [MockProductData] = [],
183226
mockPurchases: [MockProductData] = [],
184227
purchaseResult: StorePurchaseResult = .success(productId: "mock.product"),
185228
syncDelay: Duration = .zero
186229
) {
187230
self.hasActiveSubscription = hasActiveSubscription
231+
self.hasPurchasedProducts = hasPurchasedProducts
188232
self.mockSubscriptions = mockSubscriptions
189233
self.mockPurchases = mockPurchases
190234
mockSubscriptionProducts = mockSubscriptions

Sources/DeveloperSupportStore/Services/StoreServiceProtocol.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@ public protocol StoreServiceProtocol: Sendable {
2525
/// Indicates whether the user has an active subscription.
2626
var hasActiveSubscription: Bool { get }
2727

28+
/// Indicates whether the user has any purchased products.
29+
var hasPurchasedProducts: Bool { get }
30+
2831
// MARK: - Actions
2932

3033
/// Synchronizes store data with the App Store.

Sources/DeveloperSupportStore/Views/RestoreOverlayView.swift

Lines changed: 24 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ struct RestoreOverlayView: View {
4343
.shadow(color: .black.opacity(0.2), radius: 8, y: 4)
4444
)
4545
.padding(.horizontal, layout.paddingBig)
46-
.padding(.top, layout.paddingDefault)
46+
.padding(.top, layout.paddingBig)
4747
.offset(y: isVisible ? 0 : -100)
4848
.opacity(isVisible ? 1 : 0)
4949

@@ -92,19 +92,17 @@ struct RestoreOverlayView: View {
9292

9393
private var backgroundColor: Color {
9494
switch result {
95-
case .success:
96-
return colors.selectedView.opacity(0.9)
97-
case .failure:
98-
return Color.red.opacity(0.9)
95+
case .success: colors.selectedView.opacity(0.9)
96+
case .failure: Color.red.opacity(0.9)
97+
case .nothingToRestore: Color.blue.opacity(0.9)
9998
}
10099
}
101100

102101
private var iconName: String {
103102
switch result {
104-
case .success:
105-
return "checkmark.circle.fill"
106-
case .failure:
107-
return "exclamationmark.triangle.fill"
103+
case .success: "checkmark.circle.fill"
104+
case .failure: "exclamationmark.triangle.fill"
105+
case .nothingToRestore: "info.circle.fill"
108106
}
109107
}
110108

@@ -114,10 +112,9 @@ struct RestoreOverlayView: View {
114112

115113
private var messageKey: LocalizedStringKey {
116114
switch result {
117-
case .success:
118-
return "store.restore.success"
119-
case .failure:
120-
return "store.restore.failure"
115+
case .success: "store.restore.success"
116+
case .failure: "store.restore.failure"
117+
case .nothingToRestore: "store.restore.nothingToRestore"
121118
}
122119
}
123120

@@ -170,3 +167,17 @@ struct RestoreOverlayView: View {
170167
)
171168
}
172169
}
170+
171+
#Preview("Nothing to Restore") {
172+
ZStack {
173+
Color.black.ignoresSafeArea()
174+
175+
RestoreOverlayView(
176+
result: .nothingToRestore,
177+
colors: .default,
178+
typography: .default,
179+
layout: .default,
180+
onDismiss: {}
181+
)
182+
}
183+
}

Sources/DeveloperSupportStore/Views/StoreViewModel.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,7 @@ public final class StoreViewModel {
126126
logger.notice(" nonConsumableProducts: \(nonConsumableProducts.count)")
127127

128128
if showResult {
129-
restoreResult = .success
129+
restoreResult = storeService.hasPurchasedProducts ? .success : .nothingToRestore
130130
}
131131
} catch {
132132
logger.error("Sync error: \(error.localizedDescription)")

0 commit comments

Comments
 (0)