Skip to content

Commit 35a1935

Browse files
authored
Merge pull request #5 from IGRSoft/feature/restore-nothing-case
feat: add nothingToRestore case and make StoreService observable
2 parents 5fcb532 + 4d16a0f commit 35a1935

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)