Skip to content

Commit 30fea92

Browse files
authored
[Woo POS][Barcodes] Show an error row for short and incomplete scans (#15823)
2 parents c50ba96 + ef39897 commit 30fea92

File tree

11 files changed

+289
-105
lines changed

11 files changed

+289
-105
lines changed

Modules/Sources/Yosemite/PointOfSale/Items/PointOfSaleBarcodeScanService.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@ public enum PointOfSaleBarcodeScanError: Error {
1818
case notFound(scannedCode: String)
1919
case loadingError(scannedCode: String, underlyingError: Error)
2020
case mappingError(scannedCode: String, underlyingError: Error)
21+
case scanTooShort(scannedCode: String)
22+
case timedOut(scannedCode: String)
23+
case parsingError(underlyingError: Error)
2124
}
2225

2326
/// Service for handling barcode scanning in Point of Sale

Modules/Tests/YosemiteTests/PointOfSale/POSProductOrVariationResolverTests.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -276,6 +276,10 @@ extension PointOfSaleBarcodeScanError: @retroactive Equatable {
276276
return lhsScannedCode == rhsScannedCode
277277
case let (.mappingError(lhsScannedCode, _), .mappingError(rhsScannedCode, _)):
278278
return lhsScannedCode == rhsScannedCode
279+
case let (.scanTooShort(lhsScannedCode), .scanTooShort(rhsScannedCode)):
280+
return lhsScannedCode == rhsScannedCode
281+
case let (.timedOut(lhsScannedCode), .timedOut(rhsScannedCode)):
282+
return lhsScannedCode == rhsScannedCode
279283
default:
280284
return false
281285
}

WooCommerce/Classes/POS/Models/Cart+BarcodeScanError.swift

Lines changed: 53 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,27 @@ extension Cart {
66
mutating func updateLoadingItem(id: UUID, with error: PointOfSaleBarcodeScanError) -> Cart.PurchasableItem? {
77
guard let index = purchasableItems.firstIndex(where: { $0.id == id }) else { return nil }
88

9-
purchasableItems[index] = Cart.PurchasableItem(
9+
purchasableItems[index] = errorItem(id: id, error: error)
10+
11+
return purchasableItems[index]
12+
}
13+
14+
@discardableResult
15+
mutating func addErrorItem(error: PointOfSaleBarcodeScanError) -> Cart.PurchasableItem {
16+
let errorItem = errorItem(id: UUID(), error: error)
17+
purchasableItems.insert(errorItem, at: purchasableItems.startIndex)
18+
return errorItem
19+
}
20+
21+
private func errorItem(id: UUID, error: PointOfSaleBarcodeScanError) -> Cart.PurchasableItem {
22+
Cart.PurchasableItem(
1023
id: id,
1124
title: title(for: error),
1225
subtitle: subtitle(for: error),
1326
quantity: 1,
1427
state: .error,
1528
accessibilityLabel: accessibilityLabel(for: error)
1629
)
17-
18-
return purchasableItems[index]
1930
}
2031

2132
private func title(for error: PointOfSaleBarcodeScanError) -> String {
@@ -25,11 +36,15 @@ extension Cart {
2536
.variationCouldNotBeConverted(let scannedCode),
2637
.notFound(let scannedCode),
2738
.loadingError(let scannedCode, _),
28-
.mappingError(let scannedCode, _):
39+
.mappingError(let scannedCode, _),
40+
.scanTooShort(let scannedCode),
41+
.timedOut(let scannedCode):
2942
return scannedCode
3043
case .unsupportedProductType(_, let productName, _),
3144
.downloadableProduct(_, let productName):
3245
return productName
46+
case .parsingError:
47+
return Localization.scanFailed
3348
}
3449
}
3550

@@ -59,6 +74,12 @@ extension PointOfSaleBarcodeScanError {
5974
} else {
6075
return Localization.networkRequestFailed
6176
}
77+
case .scanTooShort:
78+
return Localization.barcodeTooShort
79+
case .timedOut:
80+
return Localization.incompleteScan
81+
case .parsingError:
82+
return Localization.parsingError
6283
}
6384
}
6485

@@ -92,5 +113,33 @@ extension PointOfSaleBarcodeScanError {
92113
value: "Parent product not found for variation",
93114
comment: "Error message shown when parent product is not found for a variation."
94115
)
116+
117+
static let barcodeTooShort = NSLocalizedString(
118+
"pointOfSale.barcodeScan.error.barcodeTooShort",
119+
value: "Barcode too short",
120+
comment: "Error message shown when scan is too short."
121+
)
122+
123+
static let incompleteScan = NSLocalizedString(
124+
"pointOfSale.barcodeScan.error.incompleteScan",
125+
value: "Partial barcode scan",
126+
comment: "Error message shown when scan is incomplete."
127+
)
128+
129+
static let parsingError = NSLocalizedString(
130+
"pointOfSale.barcodeScan.error.parsingError",
131+
value: "Couldn't read barcode",
132+
comment: "Error message shown when parsing barcode data fails."
133+
)
134+
}
135+
}
136+
137+
private extension Cart {
138+
enum Localization {
139+
static let scanFailed = NSLocalizedString(
140+
"pointOfSale.barcodeScan.error.scanFailed",
141+
value: "Scan failed",
142+
comment: "Error message when scanning a barcode fails for an unknown reason, before lookup."
143+
)
95144
}
96145
}

WooCommerce/Classes/POS/Models/PointOfSaleAggregateModel.swift

Lines changed: 63 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ protocol PointOfSaleAggregateModelProtocol {
3434
var couponsSearchController: PointOfSaleSearchingItemsControllerProtocol { get }
3535

3636
var cart: Cart { get }
37-
func barcodeScanned(_ barcode: String)
37+
func barcodeScanned(_ result: Result<String, Error>)
3838
func addToCart(_ item: POSItem)
3939
func remove(cartItem: CartItem)
4040
func removeAllItemsFromCart(types: [CartItemType])
@@ -185,49 +185,77 @@ extension PointOfSaleAggregateModel {
185185
// MARK: - Barcode Scanning
186186
@available(iOS 17.0, *)
187187
extension PointOfSaleAggregateModel {
188-
func barcodeScanned(_ barcode: String) {
188+
func barcodeScanned(_ result: Result<String, Error>) {
189189
Task { @MainActor [weak self] in
190190
guard let self else { return }
191-
let placeholderItemID = cart.addLoadingItem().id
191+
switch result {
192+
case .success(let barcode):
193+
await handleSuccessfulScan(barcode: barcode)
194+
case .failure(let error):
195+
await handleFailedScan(error: error)
196+
}
197+
}
198+
}
192199

193-
analytics.track(
194-
event: .PointOfSale.addItemToCart(
195-
sourceViewType: .scanner,
196-
itemType: .loading
197-
)
200+
@MainActor
201+
private func handleSuccessfulScan(barcode: String) async {
202+
let placeholderItemID = cart.addLoadingItem().id
203+
204+
analytics.track(
205+
event: .PointOfSale.addItemToCart(
206+
sourceViewType: .scanner,
207+
itemType: .loading
198208
)
209+
)
199210

200-
do throws(PointOfSaleBarcodeScanError) {
201-
let item = try await barcodeScanService.getItem(barcode: barcode)
202-
if let cartItem = cart.updateLoadingItem(id: placeholderItemID, with: item) {
203-
analytics.track(
204-
event: .PointOfSale.addItemToCart(
205-
sourceViewType: .scanner,
206-
itemType: .product,
207-
productType: .init(cartItem: cartItem)
208-
)
211+
do throws(PointOfSaleBarcodeScanError) {
212+
let item = try await barcodeScanService.getItem(barcode: barcode)
213+
if let cartItem = cart.updateLoadingItem(id: placeholderItemID, with: item) {
214+
analytics.track(
215+
event: .PointOfSale.addItemToCart(
216+
sourceViewType: .scanner,
217+
itemType: .product,
218+
productType: .init(cartItem: cartItem)
209219
)
220+
)
210221

211-
cart.accessibilityFocusedItemID = cartItem.id
212-
}
213-
} catch {
214-
DDLogInfo("Failed to find item by barcode: \(error)")
215-
if let errorItem = cart.updateLoadingItem(id: placeholderItemID, with: error) {
216-
// Only play a sound and track analytics if the item still exists in the cart.
217-
await soundPlayer.playSound(.barcodeScanFailure, completion: { [weak self] in
218-
self?.cart.accessibilityFocusedItemID = errorItem.id
219-
})
220-
221-
analytics.track(
222-
event: .PointOfSale.addItemToCart(
223-
sourceViewType: .scanner,
224-
itemType: .error,
225-
error: error
226-
)
227-
)
228-
}
222+
cart.accessibilityFocusedItemID = cartItem.id
229223
}
224+
} catch {
225+
DDLogInfo("Failed to find item by barcode: \(error)")
226+
if let _ = cart.updateLoadingItem(id: placeholderItemID, with: error) {
227+
await handleErrorItemAdded(error)
228+
}
229+
}
230+
}
231+
232+
@MainActor
233+
private func handleFailedScan(error: Error) async {
234+
let scanError = switch error {
235+
case HIDBarcodeParserError.scanTooShort(let barcode):
236+
PointOfSaleBarcodeScanError.scanTooShort(scannedCode: barcode)
237+
case HIDBarcodeParserError.timedOut(let barcode):
238+
PointOfSaleBarcodeScanError.timedOut(scannedCode: barcode)
239+
default:
240+
PointOfSaleBarcodeScanError.parsingError(underlyingError: error)
230241
}
242+
243+
cart.addErrorItem(error: scanError)
244+
await handleErrorItemAdded(scanError)
245+
}
246+
247+
@MainActor
248+
private func handleErrorItemAdded(_ error: PointOfSaleBarcodeScanError) async {
249+
// Only play a sound and track analytics if the item still exists in the cart.
250+
await soundPlayer.playSound(.barcodeScanFailure)
251+
252+
analytics.track(
253+
event: .PointOfSale.addItemToCart(
254+
sourceViewType: .scanner,
255+
itemType: .error,
256+
error: error
257+
)
258+
)
231259
}
232260
}
233261

WooCommerce/Classes/POS/Presentation/Barcode Scanning/BarcodeScannerContainer.swift

Lines changed: 9 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,12 @@ import SwiftUI
1010
struct BarcodeScannerContainer: View {
1111
/// Configuration for the barcode scanner
1212
let configuration: HIDBarcodeParserConfiguration
13-
/// Callback that is triggered when a barcode is successfully scanned
14-
let onScan: (String) -> Void
13+
/// Callback that is triggered when a barcode scan completes (success or failure)
14+
let onScan: (Result<String, Error>) -> Void
1515

1616
init(
1717
configuration: HIDBarcodeParserConfiguration = .default,
18-
onScan: @escaping (String) -> Void
18+
onScan: @escaping (Result<String, Error>) -> Void
1919
) {
2020
self.configuration = configuration
2121
self.onScan = onScan
@@ -37,7 +37,7 @@ struct BarcodeScannerContainer: View {
3737
/// keyboard input for barcode scanning.
3838
struct BarcodeScannerContainerRepresentable: UIViewControllerRepresentable {
3939
let configuration: HIDBarcodeParserConfiguration
40-
let onScan: (String) -> Void
40+
let onScan: (Result<String, Error>) -> Void
4141

4242
func makeUIViewController(context: Context) -> BarcodeScannerHostingController {
4343
let controller = BarcodeScannerHostingController(
@@ -47,28 +47,23 @@ struct BarcodeScannerContainerRepresentable: UIViewControllerRepresentable {
4747
return controller
4848
}
4949

50-
func updateUIViewController(_ uiViewController: BarcodeScannerHostingController, context: Context) {
51-
uiViewController.configuration = configuration
52-
uiViewController.onScan = onScan
53-
}
50+
func updateUIViewController(_ uiViewController: BarcodeScannerHostingController, context: Context) {}
5451
}
5552

5653
/// A UIHostingController that handles keyboard input events for barcode scanning.
5754
/// This controller captures keyboard input and interprets it as barcode data when a terminating
5855
/// character is detected.
5956
class BarcodeScannerHostingController: UIHostingController<EmptyView> {
60-
var configuration: HIDBarcodeParserConfiguration
61-
var onScan: (String) -> Void
62-
57+
private let configuration: HIDBarcodeParserConfiguration
6358
private let scanner: HIDBarcodeParser
6459

6560
init(
6661
configuration: HIDBarcodeParserConfiguration,
67-
onScan: @escaping (String) -> Void
62+
onScan: @escaping (Result<String, Error>) -> Void
6863
) {
6964
self.configuration = configuration
70-
self.onScan = onScan
71-
self.scanner = HIDBarcodeParser(configuration: configuration, onScan: onScan)
65+
self.scanner = HIDBarcodeParser(configuration: configuration,
66+
onScan: onScan)
7267
super.init(rootView: EmptyView())
7368
}
7469

WooCommerce/Classes/POS/Presentation/Barcode Scanning/BarcodeScanningModifier.swift

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ struct BarcodeScanningModifier: ViewModifier {
77
/// Whether barcode scanning is enabled
88
@Binding var enabled: Bool
99
/// Callback that is triggered when a barcode is successfully scanned
10-
let onScan: (String) -> Void
10+
let onScan: (Result<String, Error>) -> Void
1111

1212
private var isBarcodeScani1FeatureEnabled: Bool {
1313
ServiceLocator.featureFlagService.isFeatureFlagEnabled(.pointOfSaleBarcodeScanningi1)
@@ -18,9 +18,7 @@ struct BarcodeScanningModifier: ViewModifier {
1818
content
1919

2020
if enabled && isBarcodeScani1FeatureEnabled {
21-
BarcodeScannerContainer() { scannedCode in
22-
onScan(scannedCode)
23-
}
21+
BarcodeScannerContainer(onScan: onScan)
2422
}
2523
}
2624
}
@@ -33,7 +31,8 @@ extension View {
3331
/// - enabled: A binding to control whether barcode scanning is enabled. Defaults to a constant true binding.
3432
/// - onScan: Callback that is triggered when a barcode is successfully scanned.
3533
/// - Returns: A view with barcode scanning capability.
36-
func barcodeScanning(enabled: Binding<Bool> = .constant(true), onScan: @escaping (String) -> Void) -> some View {
34+
func barcodeScanning(enabled: Binding<Bool> = .constant(true),
35+
onScan: @escaping (Result<String, Error>) -> Void) -> some View {
3736
modifier(BarcodeScanningModifier(enabled: enabled, onScan: onScan))
3837
}
3938
}

WooCommerce/Classes/POS/Presentation/Barcode Scanning/HIDBarcodeParser.swift

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,16 +7,16 @@ import UIKit
77
final class HIDBarcodeParser {
88
/// Configuration for the barcode scanner
99
let configuration: HIDBarcodeParserConfiguration
10-
/// Callback that is triggered when a barcode is successfully scanned
11-
let onScan: (String) -> Void
10+
/// Callback that is triggered when a barcode scan completes (success or failure)
11+
let onScan: (Result<String, Error>) -> Void
1212

1313
private let timeProvider: TimeProvider
1414

1515
private var buffer = ""
1616
private var lastKeyPressTime: Date?
1717

1818
init(configuration: HIDBarcodeParserConfiguration,
19-
onScan: @escaping (String) -> Void,
19+
onScan: @escaping (Result<String, Error>) -> Void,
2020
timeProvider: TimeProvider = DefaultTimeProvider()) {
2121
self.configuration = configuration
2222
self.onScan = onScan
@@ -31,6 +31,7 @@ final class HIDBarcodeParser {
3131
// If characters are entered too slowly, it's probably typing and we should ignore it
3232
if let lastTime = lastKeyPressTime,
3333
currentTime.timeIntervalSince(lastTime) > configuration.maximumInterCharacterTime {
34+
onScan(.failure(HIDBarcodeParserError.timedOut(barcode: buffer)))
3435
resetScan()
3536
}
3637

@@ -138,7 +139,9 @@ final class HIDBarcodeParser {
138139

139140
private func processScan() {
140141
if buffer.count >= configuration.minimumBarcodeLength {
141-
onScan(buffer)
142+
onScan(.success(buffer))
143+
} else {
144+
onScan(.failure(HIDBarcodeParserError.scanTooShort(barcode: buffer)))
142145
}
143146
resetScan()
144147
}
@@ -159,7 +162,12 @@ struct HIDBarcodeParserConfiguration {
159162
/// Default configuration suitable for most barcode scanners
160163
static let `default` = HIDBarcodeParserConfiguration(
161164
terminatingStrings: ["\r", "\n"],
162-
minimumBarcodeLength: 4,
165+
minimumBarcodeLength: 6,
163166
maximumInterCharacterTime: 0.2
164167
)
165168
}
169+
170+
enum HIDBarcodeParserError: Error {
171+
case scanTooShort(barcode: String)
172+
case timedOut(barcode: String)
173+
}

WooCommerce/Classes/POS/Presentation/ItemListView.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -322,7 +322,7 @@ private extension ItemListView {
322322
}
323323

324324
Button {
325-
posModel.barcodeScanned(barcodeScanSimulatorText)
325+
posModel.barcodeScanned(.success(barcodeScanSimulatorText))
326326
} label: {
327327
Text("Scan!")
328328
}

WooCommerce/WooCommerceTests/POS/Mocks/MockPointOfSaleAggregateModel.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ final class MockPointOfSaleAggregateModel: PointOfSaleAggregateModelProtocol {
6262

6363
var cart: Cart = .init()
6464

65-
func barcodeScanned(_ barcode: String) { }
65+
func barcodeScanned(_ result: Result<String, Error>) { }
6666

6767
func addToCart(_ item: POSItem) { }
6868

0 commit comments

Comments
 (0)