Skip to content

Commit 4e79a59

Browse files
committed
Add GRDB data source for item list
1 parent d4900bb commit 4e79a59

File tree

5 files changed

+706
-2
lines changed

5 files changed

+706
-2
lines changed
Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
1+
import Foundation
2+
import GRDB
3+
import Combine
4+
import Observation
5+
import Storage
6+
7+
/// Observable data source for GRDB-based POS items using ValueObservation
8+
/// Provides automatic SwiftUI updates when database changes occur
9+
@Observable
10+
public final class GRDBObservableDataSource: POSObservableDataSourceProtocol {
11+
// MARK: - Observable Properties
12+
13+
public private(set) var productItems: [POSItem] = []
14+
public private(set) var variationItems: [POSItem] = []
15+
public private(set) var isLoadingProducts: Bool = false
16+
public private(set) var isLoadingVariations: Bool = false
17+
public private(set) var error: Error? = nil
18+
19+
public var hasMoreProducts: Bool {
20+
productItems.count >= (pageSize * currentProductPage) && totalProductCount > productItems.count
21+
}
22+
23+
public var hasMoreVariations: Bool {
24+
variationItems.count >= (pageSize * currentVariationPage) && totalVariationCount > variationItems.count
25+
}
26+
27+
// MARK: - Private Properties
28+
29+
private let siteID: Int64
30+
private let grdbManager: GRDBManagerProtocol
31+
private let itemMapper: PointOfSaleItemMapperProtocol
32+
private let pageSize: Int
33+
34+
private var currentProductPage: Int = 1
35+
private var currentVariationPage: Int = 1
36+
private var currentParentProduct: POSVariableParentProduct?
37+
private var totalProductCount: Int = 0
38+
private var totalVariationCount: Int = 0
39+
40+
// ValueObservation subscriptions
41+
private var productObservationCancellable: AnyCancellable?
42+
private var variationObservationCancellable: AnyCancellable?
43+
private var statisticsObservationCancellable: AnyCancellable?
44+
45+
// MARK: - Initialization
46+
47+
public init(siteID: Int64,
48+
grdbManager: GRDBManagerProtocol,
49+
itemMapper: PointOfSaleItemMapperProtocol,
50+
pageSize: Int = 20) {
51+
self.siteID = siteID
52+
self.grdbManager = grdbManager
53+
self.itemMapper = itemMapper
54+
self.pageSize = pageSize
55+
56+
setupStatisticsObservation()
57+
}
58+
59+
deinit {
60+
productObservationCancellable?.cancel()
61+
variationObservationCancellable?.cancel()
62+
statisticsObservationCancellable?.cancel()
63+
}
64+
65+
// MARK: - POSObservableDataSourceProtocol
66+
67+
public func loadProducts() {
68+
currentProductPage = 1
69+
isLoadingProducts = true
70+
setupProductObservation()
71+
}
72+
73+
public func loadMoreProducts() {
74+
guard hasMoreProducts && !isLoadingProducts else { return }
75+
76+
isLoadingProducts = true
77+
currentProductPage += 1
78+
setupProductObservation()
79+
}
80+
81+
public func loadVariations(for parentProduct: POSVariableParentProduct) {
82+
guard currentParentProduct?.productID != parentProduct.productID else {
83+
return // Same parent - idempotent
84+
}
85+
86+
currentParentProduct = parentProduct
87+
currentVariationPage = 1
88+
isLoadingVariations = true
89+
variationItems = []
90+
91+
setupVariationObservation(parentProduct: parentProduct)
92+
}
93+
94+
public func loadMoreVariations() {
95+
guard let parentProduct = currentParentProduct,
96+
hasMoreVariations && !isLoadingVariations else { return }
97+
98+
isLoadingVariations = true
99+
currentVariationPage += 1
100+
setupVariationObservation(parentProduct: parentProduct)
101+
}
102+
103+
public func refresh() {
104+
// No-op: database observation automatically updates when data changes during incremental sync
105+
}
106+
107+
// MARK: - ValueObservation Setup
108+
109+
private func setupProductObservation() {
110+
let currentPage = currentProductPage
111+
let observation = ValueObservation
112+
.tracking { [weak self] database -> [POSProduct] in
113+
guard let self else { return [] }
114+
115+
let persistedProducts = try PersistedProduct
116+
.filter(PersistedProduct.Columns.siteID == self.siteID)
117+
.limit(self.pageSize * currentPage)
118+
.fetchAll(database)
119+
120+
return try persistedProducts.map { persistedProduct in
121+
let images = try persistedProduct.request(for: PersistedProduct.images).fetchAll(database)
122+
let attributes = try persistedProduct.request(for: PersistedProduct.attributes).fetchAll(database)
123+
124+
return persistedProduct.toPOSProduct(
125+
images: images.map { $0.toProductImage() },
126+
attributes: attributes.map { $0.toProductAttribute(siteID: self.siteID) }
127+
)
128+
}
129+
}
130+
131+
productObservationCancellable = observation
132+
.publisher(in: grdbManager.databaseConnection)
133+
.sink(
134+
receiveCompletion: { [weak self] completion in
135+
if case .failure(let error) = completion {
136+
Task { @MainActor in
137+
self?.error = error
138+
self?.isLoadingProducts = false
139+
}
140+
}
141+
},
142+
receiveValue: { [weak self] observedProducts in
143+
guard let self else { return }
144+
let posItems = self.itemMapper.mapProductsToPOSItems(products: observedProducts)
145+
Task { @MainActor in
146+
self.productItems = posItems
147+
self.error = nil
148+
self.isLoadingProducts = false
149+
}
150+
}
151+
)
152+
}
153+
154+
private func setupVariationObservation(parentProduct: POSVariableParentProduct) {
155+
variationObservationCancellable?.cancel()
156+
157+
let currentPage = currentVariationPage
158+
let observation = ValueObservation
159+
.tracking { [weak self] database -> [POSProductVariation] in
160+
guard let self else { return [] }
161+
162+
let persistedVariations = try PersistedProductVariation
163+
.filter(PersistedProductVariation.Columns.siteID == self.siteID &&
164+
PersistedProductVariation.Columns.productID == parentProduct.productID)
165+
.limit(self.pageSize * currentPage)
166+
.fetchAll(database)
167+
168+
return try persistedVariations.map { persistedVariation in
169+
let attributes = try persistedVariation.request(for: PersistedProductVariation.attributes).fetchAll(database)
170+
let image = try persistedVariation.request(for: PersistedProductVariation.image).fetchOne(database)
171+
172+
return persistedVariation.toPOSProductVariation(
173+
attributes: attributes.map { $0.toProductVariationAttribute() },
174+
image: image?.toProductImage()
175+
)
176+
}
177+
}
178+
179+
variationObservationCancellable = observation
180+
.publisher(in: grdbManager.databaseConnection)
181+
.sink(
182+
receiveCompletion: { [weak self] completion in
183+
if case .failure(let error) = completion {
184+
Task { @MainActor in
185+
self?.error = error
186+
self?.isLoadingVariations = false
187+
}
188+
}
189+
},
190+
receiveValue: { [weak self] observedVariations in
191+
guard let self else { return }
192+
let posItems = self.itemMapper.mapVariationsToPOSItems(
193+
variations: observedVariations,
194+
parentProduct: parentProduct
195+
)
196+
Task { @MainActor in
197+
self.variationItems = posItems
198+
self.error = nil
199+
self.isLoadingVariations = false
200+
}
201+
}
202+
)
203+
}
204+
205+
private func setupStatisticsObservation() {
206+
let observation = ValueObservation
207+
.tracking { [weak self] database in
208+
guard let self else { return (0, 0) }
209+
210+
let productCount = try PersistedProduct
211+
.filter(PersistedProduct.Columns.siteID == self.siteID)
212+
.fetchCount(database)
213+
214+
let variationCount = try PersistedProductVariation
215+
.filter(PersistedProductVariation.Columns.siteID == self.siteID)
216+
.fetchCount(database)
217+
218+
return (productCount, variationCount)
219+
}
220+
221+
statisticsObservationCancellable = observation
222+
.publisher(in: grdbManager.databaseConnection)
223+
.sink(
224+
receiveCompletion: { completion in
225+
if case .failure = completion {
226+
// Silently ignore - statistics are not critical
227+
}
228+
},
229+
receiveValue: { [weak self] (productCount, variationCount) in
230+
Task { @MainActor in
231+
self?.totalProductCount = productCount
232+
self?.totalVariationCount = variationCount
233+
}
234+
}
235+
)
236+
}
237+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import Foundation
2+
3+
/// Protocol for observable data sources that provide POS items with automatic updates
4+
public protocol POSObservableDataSourceProtocol {
5+
/// Current products mapped to POSItems
6+
var productItems: [POSItem] { get }
7+
8+
/// Current variations for the selected parent product mapped to POSItems
9+
var variationItems: [POSItem] { get }
10+
11+
/// Loading state for products
12+
var isLoadingProducts: Bool { get }
13+
14+
/// Loading state for variations
15+
var isLoadingVariations: Bool { get }
16+
17+
/// Whether more products are available to load
18+
var hasMoreProducts: Bool { get }
19+
20+
/// Whether more variations are available for current parent
21+
var hasMoreVariations: Bool { get }
22+
23+
/// Current error, if any
24+
var error: Error? { get }
25+
26+
/// Loads the first page of products
27+
func loadProducts()
28+
29+
/// Loads the next page of products
30+
func loadMoreProducts()
31+
32+
/// Loads variations for a specific parent product
33+
func loadVariations(for parentProduct: POSVariableParentProduct)
34+
35+
/// Loads more variations for the current parent product
36+
func loadMoreVariations()
37+
38+
/// Refreshes all data
39+
/// Note: For GRDB implementations, this is a no-op as the database observation
40+
/// automatically updates when data changes during incremental sync
41+
func refresh()
42+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import Foundation
2+
import Observation
3+
import Yosemite
4+
5+
/// Mock implementation for testing and development
6+
@Observable
7+
public final class MockPOSObservableDataSource: POSObservableDataSourceProtocol {
8+
public private(set) var productItems: [POSItem] = []
9+
public private(set) var variationItems: [POSItem] = []
10+
public private(set) var isLoadingProducts: Bool = false
11+
public private(set) var isLoadingVariations: Bool = false
12+
public private(set) var hasMoreProducts: Bool = false
13+
public private(set) var hasMoreVariations: Bool = false
14+
public private(set) var error: Error? = nil
15+
16+
public init() {}
17+
18+
public func loadProducts() {
19+
isLoadingProducts = true
20+
Task { @MainActor in
21+
try? await Task.sleep(nanoseconds: 100_000_000)
22+
self.productItems = []
23+
self.isLoadingProducts = false
24+
}
25+
}
26+
27+
public func loadMoreProducts() {
28+
guard !isLoadingProducts else { return }
29+
isLoadingProducts = true
30+
Task { @MainActor in
31+
try? await Task.sleep(nanoseconds: 100_000_000)
32+
self.isLoadingProducts = false
33+
}
34+
}
35+
36+
public func loadVariations(for parentProduct: POSVariableParentProduct) {
37+
isLoadingVariations = true
38+
Task { @MainActor in
39+
try? await Task.sleep(nanoseconds: 100_000_000)
40+
self.variationItems = []
41+
self.isLoadingVariations = false
42+
}
43+
}
44+
45+
public func loadMoreVariations() {
46+
guard !isLoadingVariations else { return }
47+
isLoadingVariations = true
48+
Task { @MainActor in
49+
try? await Task.sleep(nanoseconds: 100_000_000)
50+
self.isLoadingVariations = false
51+
}
52+
}
53+
54+
public func refresh() {
55+
// No-op for mock
56+
}
57+
}

0 commit comments

Comments
 (0)