Skip to content

Commit 4c55640

Browse files
authored
Merge branch 'trunk' into woo-pos-enable-barcode-scanning-i2-feature-flag
2 parents 180cf70 + ab58d66 commit 4c55640

File tree

8 files changed

+761
-29
lines changed

8 files changed

+761
-29
lines changed

WooCommerce/Classes/POS/Presentation/Barcode Scanner Setup/PointOfSaleBarcodeScannerSetupStepViews.swift

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ struct PointOfSaleBarcodeScannerBarcodeView: View {
2626
.padding(POSPadding.medium)
2727
.background(Color.white)
2828
.clipShape(RoundedRectangle(cornerRadius: POSCornerRadiusStyle.medium.value))
29+
.accessibilityLabel(Localization.barcodeImageAccesibilityLabel)
2930
}
3031
}
3132
}
@@ -34,6 +35,14 @@ extension PointOfSaleBarcodeScannerBarcodeView {
3435
enum Constants {
3536
static let maxBarcodeSize: CGFloat = 168
3637
}
38+
39+
enum Localization {
40+
static let barcodeImageAccesibilityLabel = NSLocalizedString(
41+
"pos.barcodeScannerSetup.barcodeImage.accesibilityLabel",
42+
value: "Image of a code to be scanned by a barcode scanner.",
43+
comment: "Accessibility label of a barcode or QR code image that needs to be scanned by a barcode scanner."
44+
)
45+
}
3746
}
3847

3948
struct PointOfSaleBarcodeScannerPairingView: View {
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import Foundation
2+
import UIKit
3+
4+
// MARK: - VoiceOver State Provider
5+
6+
/// Protocol for providing VoiceOver state, enabling testable VoiceOver detection
7+
protocol VoiceOverStateProvider {
8+
var isVoiceOverRunning: Bool { get }
9+
}
10+
11+
/// System implementation of VoiceOverStateProvider that uses UIAccessibility
12+
struct SystemVoiceOverStateProvider: VoiceOverStateProvider {
13+
var isVoiceOverRunning: Bool {
14+
return UIAccessibility.isVoiceOverRunning
15+
}
16+
}
17+
18+
// MARK: - Analytics Tracker
19+
20+
/// Shared analytics tracking for barcode scanning operations.
21+
/// Used by both GameControllerBarcodeObserver and UIKitBarcodeObserver to ensure consistent analytics.
22+
final class BarcodeAnalyticsTracker {
23+
24+
/// Tracks analytics events for barcode scanning results.
25+
/// - Parameter result: The result of the barcode scanning operation
26+
func track(result: HIDBarcodeParserResult) {
27+
switch result {
28+
case .success(let barcode, let scanDurationMs):
29+
ServiceLocator.analytics.track(
30+
event: WooAnalyticsEvent.PointOfSale.barcodeScanningSuccess(
31+
scanDurationMs: scanDurationMs,
32+
barcodeLength: barcode.count
33+
)
34+
)
35+
case .failure(let error, let scanDurationMs):
36+
ServiceLocator.analytics.track(
37+
event: WooAnalyticsEvent.PointOfSale.barcodeScanningFailed(
38+
scanDurationMs: scanDurationMs,
39+
barcodeLength: error.barcode.count,
40+
failReason: error.analyticsReason
41+
)
42+
)
43+
}
44+
}
45+
}

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

Lines changed: 115 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -49,25 +49,134 @@ struct BarcodeScannerContainerRepresentable: UIViewControllerRepresentable {
4949
func updateUIViewController(_ uiViewController: UIViewController, context: Context) {}
5050
}
5151

52-
/// A UIHostingController that handles GameController keyboard input events for barcode scanning.
53-
/// This controller uses GameController framework exclusively for language-independent barcode scanning.
52+
/// A UIHostingController that dynamically switches between GameController and UIKit barcode scanning
53+
/// based on VoiceOver state. Uses GameController framework for optimal performance when possible,
54+
/// and falls back to UIKit UIPress events when VoiceOver is enabled.
5455
final class GameControllerBarcodeScannerHostingController: UIHostingController<EmptyView> {
55-
private var gameControllerBarcodeObserver: GameControllerBarcodeObserver?
56+
private(set) var gameControllerObserver: GameControllerBarcodeObserver?
57+
private(set) var uiKitObserver: UIKitBarcodeObserver?
5658

59+
private let configuration: HIDBarcodeParserConfiguration
60+
private let onScan: (Result<String, HIDBarcodeParserError>) -> Void
61+
private let voiceOverStateProvider: VoiceOverStateProvider
62+
63+
// Public initializer for production use
5764
init(
5865
configuration: HIDBarcodeParserConfiguration,
59-
onScan: @escaping (Result<String, HIDBarcodeParserError>) -> Void
66+
onScan: @escaping (Result<String, HIDBarcodeParserError>) -> Void,
67+
voiceOverStateProvider: VoiceOverStateProvider = SystemVoiceOverStateProvider()
6068
) {
69+
self.configuration = configuration
70+
self.onScan = onScan
71+
self.voiceOverStateProvider = voiceOverStateProvider
6172
super.init(rootView: EmptyView())
6273

63-
gameControllerBarcodeObserver = GameControllerBarcodeObserver(configuration: configuration, onScan: onScan)
74+
setupInitialObserver()
75+
observeVoiceOverChanges()
6476
}
6577

6678
@MainActor required dynamic init?(coder aDecoder: NSCoder) {
6779
fatalError("init(coder:) has not been implemented")
6880
}
6981

7082
deinit {
71-
gameControllerBarcodeObserver = nil
83+
NotificationCenter.default.removeObserver(self)
84+
cleanupObservers()
85+
}
86+
87+
// MARK: - Observer Management
88+
89+
private func setupInitialObserver() {
90+
switchToAppropriateObserver()
91+
}
92+
93+
private func observeVoiceOverChanges() {
94+
NotificationCenter.default.addObserver(
95+
self,
96+
selector: #selector(voiceOverStatusChanged),
97+
name: UIAccessibility.voiceOverStatusDidChangeNotification,
98+
object: nil
99+
)
100+
}
101+
102+
@objc private func voiceOverStatusChanged() {
103+
switchToAppropriateObserver()
104+
}
105+
106+
private func switchToAppropriateObserver() {
107+
// Clean up current observers
108+
cleanupObservers()
109+
110+
// Set up appropriate observer based on VoiceOver state
111+
if voiceOverStateProvider.isVoiceOverRunning {
112+
uiKitObserver = UIKitBarcodeObserver(
113+
configuration: configuration,
114+
onScan: onScan
115+
)
116+
} else {
117+
gameControllerObserver = GameControllerBarcodeObserver(
118+
configuration: configuration,
119+
onScan: onScan
120+
)
121+
}
122+
}
123+
124+
private func cleanupObservers() {
125+
gameControllerObserver = nil
126+
uiKitObserver = nil
127+
}
128+
129+
// MARK: - UIPress Event Handling
130+
131+
override var canBecomeFirstResponder: Bool {
132+
voiceOverStateProvider.isVoiceOverRunning
133+
}
134+
135+
override func viewDidAppear(_ animated: Bool) {
136+
super.viewDidAppear(animated)
137+
138+
if voiceOverStateProvider.isVoiceOverRunning {
139+
becomeFirstResponder()
140+
}
141+
}
142+
143+
override func viewWillDisappear(_ animated: Bool) {
144+
super.viewWillDisappear(animated)
145+
146+
if voiceOverStateProvider.isVoiceOverRunning {
147+
resignFirstResponder()
148+
}
149+
}
150+
151+
/// Handles the end of keyboard press events, interpreting them as barcode input.
152+
/// When a terminating character is detected, the accumulated buffer is treated as a complete
153+
/// barcode and passed to the onScan callback.
154+
/// We don't call `super` when UIKitObserver is active because we don't other responder chain items to handle our barcode as well,
155+
/// as this could cause unexpected behavior.
156+
override func pressesEnded(_ presses: Set<UIPress>, with event: UIPressesEvent?) {
157+
// Forward UIPress events to UIKit observer if active
158+
if let uiKitObserver = uiKitObserver {
159+
uiKitObserver.processUIPress(presses)
160+
} else {
161+
super.pressesEnded(presses, with: event)
162+
}
163+
}
164+
165+
override func pressesBegan(_ presses: Set<UIPress>, with event: UIPressesEvent?) {
166+
// Don't call super to prevent system from hiding software keyboard
167+
}
168+
169+
override func pressesChanged(_ presses: Set<UIPress>, with event: UIPressesEvent?) {
170+
super.pressesChanged(presses, with: event)
171+
}
172+
173+
/// `pressesCancelled` is rarely called, but Apple's documentation suggests it's possible and that crashes may occur if it's not handled.
174+
/// It makes sense to clear the buffer when this happens.
175+
/// We call super in case other presses are handled elsewhere in the responder chain.
176+
override func pressesCancelled(_ presses: Set<UIPress>, with event: UIPressesEvent?) {
177+
if let uiKitObserver = uiKitObserver {
178+
uiKitObserver.barcodeParser?.cancel()
179+
}
180+
super.pressesCancelled(presses, with: event)
72181
}
73182
}

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

Lines changed: 10 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,9 @@ final class GameControllerBarcodeObserver {
1717
/// (GCKeyboard.coalesced), so notification about connection/disconnection will only be delivered once
1818
/// until the last keyboard disconnects.
1919
private var coalescedKeyboard: GCKeyboard?
20-
private var barcodeParser: GameControllerBarcodeParser?
20+
private(set) var barcodeParser: GameControllerBarcodeParser?
2121
private let configuration: HIDBarcodeParserConfiguration
22+
private let analyticsTracker: BarcodeAnalyticsTracker
2223

2324
/// Tracks current shift state to be applied to the next character key
2425
private var isShiftPressed: Bool = false
@@ -27,9 +28,15 @@ final class GameControllerBarcodeObserver {
2728
/// - Parameters:
2829
/// - configuration: The configuration to use for the barcode parser. Defaults to the standard configuration.
2930
/// - onScan: The closure to be called when a scan is completed.
30-
init(configuration: HIDBarcodeParserConfiguration = .default, onScan: @escaping (Result<String, HIDBarcodeParserError>) -> Void) {
31+
/// - analyticsTracker: The analytics tracker to use. Defaults to a new instance.
32+
init(
33+
configuration: HIDBarcodeParserConfiguration = .default,
34+
onScan: @escaping (Result<String, HIDBarcodeParserError>) -> Void,
35+
analyticsTracker: BarcodeAnalyticsTracker = BarcodeAnalyticsTracker()
36+
) {
3137
self.onScan = onScan
3238
self.configuration = configuration
39+
self.analyticsTracker = analyticsTracker
3340
addObservers()
3441
setupCoalescedKeyboard()
3542
}
@@ -119,27 +126,7 @@ final class GameControllerBarcodeObserver {
119126
}
120127

121128
private func handleScanResult(_ result: HIDBarcodeParserResult) {
122-
trackAnalyticsEvent(for: result)
129+
analyticsTracker.track(result: result)
123130
onScan(result.asResult)
124131
}
125-
126-
private func trackAnalyticsEvent(for result: HIDBarcodeParserResult) {
127-
switch result {
128-
case .success(let barcode, let scanDurationMs):
129-
ServiceLocator.analytics.track(
130-
event: WooAnalyticsEvent.PointOfSale.barcodeScanningSuccess(
131-
scanDurationMs: scanDurationMs,
132-
barcodeLength: barcode.count
133-
)
134-
)
135-
case .failure(let error, let scanDurationMs):
136-
ServiceLocator.analytics.track(
137-
event: WooAnalyticsEvent.PointOfSale.barcodeScanningFailed(
138-
scanDurationMs: scanDurationMs,
139-
barcodeLength: error.barcode.count,
140-
failReason: error.analyticsReason
141-
)
142-
)
143-
}
144-
}
145132
}
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import Foundation
2+
import GameController
3+
import UIKit
4+
5+
/// An observer that processes UIKit UIPress events for barcode scanner input.
6+
/// This class serves as a fallback for VoiceOver scenarios where GameController framework
7+
/// keyChangeHandler doesn't work properly.
8+
final class UIKitBarcodeObserver {
9+
/// A closure that is called when a barcode scan is completed.
10+
/// The result will be a `success` with the barcode string or a `failure` with an HIDBarcodeParserError.
11+
private let onScan: (Result<String, HIDBarcodeParserError>) -> Void
12+
13+
private let configuration: HIDBarcodeParserConfiguration
14+
private(set) var barcodeParser: GameControllerBarcodeParser?
15+
private let analyticsTracker: BarcodeAnalyticsTracker
16+
private let timeProvider: TimeProvider
17+
18+
/// Initializes a new UIKit barcode scanner observer.
19+
/// - Parameters:
20+
/// - configuration: The configuration to use for the barcode parser. Defaults to the standard configuration.
21+
/// - onScan: The closure to be called when a scan is completed.
22+
/// - analyticsTracker: The analytics tracker to use. Defaults to a new instance.
23+
/// - timeProvider: The time provider to use for timing operations. Defaults to the system time provider.
24+
init(
25+
configuration: HIDBarcodeParserConfiguration = .default,
26+
onScan: @escaping (Result<String, HIDBarcodeParserError>) -> Void,
27+
analyticsTracker: BarcodeAnalyticsTracker = BarcodeAnalyticsTracker(),
28+
timeProvider: TimeProvider = DefaultTimeProvider()
29+
) {
30+
self.onScan = onScan
31+
self.configuration = configuration
32+
self.analyticsTracker = analyticsTracker
33+
self.timeProvider = timeProvider
34+
}
35+
36+
37+
/// Process UIPress events for barcode scanning.
38+
/// Translates UIKey input to GCKeyCode and feeds to existing parser infrastructure.
39+
func processUIPress(_ presses: Set<UIPress>) {
40+
// Lazily initialize parser when needed
41+
if barcodeParser == nil {
42+
barcodeParser = GameControllerBarcodeParser(
43+
configuration: configuration,
44+
onScan: { [weak self] result in
45+
self?.analyticsTracker.track(result: result)
46+
self?.onScan(result.asResult)
47+
},
48+
timeProvider: timeProvider
49+
)
50+
}
51+
52+
for press in presses {
53+
guard let key = press.key else { continue }
54+
55+
// Translate UIKey to GCKeyCode
56+
guard let keyCode = uiKeyToGCKeyCode(key) else { continue }
57+
58+
// Determine shift state from modifiers
59+
let isShiftPressed = key.modifierFlags.contains(.shift)
60+
61+
// Use existing parser with translated input
62+
barcodeParser?.processKeyPress(keyCode, isShiftPressed: isShiftPressed)
63+
}
64+
}
65+
66+
/// Translates UIKey to equivalent GCKeyCode for consistent parsing
67+
private func uiKeyToGCKeyCode(_ key: UIKey) -> GCKeyCode? {
68+
switch key.keyCode {
69+
// Numbers
70+
case .keyboard0: return .zero
71+
case .keyboard1: return .one
72+
case .keyboard2: return .two
73+
case .keyboard3: return .three
74+
case .keyboard4: return .four
75+
case .keyboard5: return .five
76+
case .keyboard6: return .six
77+
case .keyboard7: return .seven
78+
case .keyboard8: return .eight
79+
case .keyboard9: return .nine
80+
81+
// Letters
82+
case .keyboardA: return .keyA
83+
case .keyboardB: return .keyB
84+
case .keyboardC: return .keyC
85+
case .keyboardD: return .keyD
86+
case .keyboardE: return .keyE
87+
case .keyboardF: return .keyF
88+
case .keyboardG: return .keyG
89+
case .keyboardH: return .keyH
90+
case .keyboardI: return .keyI
91+
case .keyboardJ: return .keyJ
92+
case .keyboardK: return .keyK
93+
case .keyboardL: return .keyL
94+
case .keyboardM: return .keyM
95+
case .keyboardN: return .keyN
96+
case .keyboardO: return .keyO
97+
case .keyboardP: return .keyP
98+
case .keyboardQ: return .keyQ
99+
case .keyboardR: return .keyR
100+
case .keyboardS: return .keyS
101+
case .keyboardT: return .keyT
102+
case .keyboardU: return .keyU
103+
case .keyboardV: return .keyV
104+
case .keyboardW: return .keyW
105+
case .keyboardX: return .keyX
106+
case .keyboardY: return .keyY
107+
case .keyboardZ: return .keyZ
108+
109+
// Punctuation and symbols
110+
case .keyboardSpacebar: return .spacebar
111+
case .keyboardHyphen: return .hyphen
112+
case .keyboardEqualSign: return .equalSign
113+
case .keyboardOpenBracket: return .openBracket
114+
case .keyboardCloseBracket: return .closeBracket
115+
case .keyboardBackslash: return .backslash
116+
case .keyboardSemicolon: return .semicolon
117+
case .keyboardQuote: return .quote
118+
case .keyboardComma: return .comma
119+
case .keyboardPeriod: return .period
120+
case .keyboardSlash: return .slash
121+
case .keyboardGraveAccentAndTilde: return .graveAccentAndTilde
122+
case .keyboardReturnOrEnter: return .returnOrEnter
123+
case .keyboardTab: return .tab
124+
125+
default:
126+
return nil
127+
}
128+
}
129+
}

0 commit comments

Comments
 (0)