-
Notifications
You must be signed in to change notification settings - Fork 121
[Woo POS][Barcodes] i2 Use GameController framework for detecting scanner input #15877
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 6 commits
2883c97
93977eb
dc4f86e
0244aae
d8e1b48
7ac693c
fa0d62b
99f3fb6
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -39,15 +39,23 @@ struct BarcodeScannerContainerRepresentable: UIViewControllerRepresentable { | |||||||||||||||||||||||||||||||||||
| let configuration: HIDBarcodeParserConfiguration | ||||||||||||||||||||||||||||||||||||
| let onScan: (Result<String, Error>) -> Void | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| func makeUIViewController(context: Context) -> BarcodeScannerHostingController { | ||||||||||||||||||||||||||||||||||||
| let controller = BarcodeScannerHostingController( | ||||||||||||||||||||||||||||||||||||
| configuration: configuration, | ||||||||||||||||||||||||||||||||||||
| onScan: onScan | ||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||
| return controller | ||||||||||||||||||||||||||||||||||||
| func makeUIViewController(context: Context) -> UIViewController { | ||||||||||||||||||||||||||||||||||||
| let featureFlagService = ServiceLocator.featureFlagService | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| if featureFlagService.isFeatureFlagEnabled(.pointOfSaleBarcodeScanningi2) { | ||||||||||||||||||||||||||||||||||||
| return GameControllerBarcodeScannerHostingController( | ||||||||||||||||||||||||||||||||||||
| configuration: configuration, | ||||||||||||||||||||||||||||||||||||
| onScan: onScan | ||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||
| } else { | ||||||||||||||||||||||||||||||||||||
| return BarcodeScannerHostingController( | ||||||||||||||||||||||||||||||||||||
| configuration: configuration, | ||||||||||||||||||||||||||||||||||||
| onScan: onScan | ||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||
| let featureFlagService = ServiceLocator.featureFlagService | |
| if featureFlagService.isFeatureFlagEnabled(.pointOfSaleBarcodeScanningi2) { | |
| return GameControllerBarcodeScannerHostingController( | |
| configuration: configuration, | |
| onScan: onScan | |
| ) | |
| } else { | |
| return BarcodeScannerHostingController( | |
| configuration: configuration, | |
| onScan: onScan | |
| ) | |
| } | |
| return GameControllerBarcodeScannerHostingController( | |
| configuration: configuration, | |
| onScan: onScan | |
| ) |
I don't actually think this needs to be feature flagged, we can just ship it – we're changing the app to match Android i1 after all.
It is helpful for testing the PR though. If you want to keep the flag, perhaps rename it to something other than i2, so we can ship this before the rest of i2.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, it was helpful for testing. I didn't want to remove it right away. However, we could simply replace one with another without any feature flagging.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,115 @@ | ||
| import Foundation | ||
| import GameController | ||
|
|
||
| /// An observer that uses the `GameController` framework to monitor for connected barcode scanners/keyboards | ||
| /// and parse their input into barcode strings. | ||
| /// | ||
| /// This class handles the low-level details of observing keyboard connections, processing raw `GCKeyCode` inputs, | ||
| /// and using the `GameControllerBarcodeParser` to produce a final barcode. | ||
| /// | ||
| final class GameControllerBarcodeObserver { | ||
| /// A closure that is called when a barcode scan is completed. | ||
| /// The result will be a `success` with the barcode string or a `failure` with an error. | ||
| let onScan: (Result<String, Error>) -> Void | ||
|
|
||
| /// Track the coalesced keyboard and its parser | ||
| /// According to Apple's documentation, all connected keyboards are coalesced into one keyboard object | ||
| /// (GCKeyboard.coalesced), so notification about connection/disconnection will only be delivered once | ||
| /// until the last keyboard disconnects. | ||
| private var coalescedKeyboard: GCKeyboard? | ||
| private var barcodeParser: GameControllerBarcodeParser? | ||
| private let configuration: HIDBarcodeParserConfiguration | ||
|
|
||
| /// Tracks current shift state to be applied to the next character key | ||
| private var isShiftPressed: Bool = false | ||
|
|
||
| /// Initializes a new barcode scanner observer. | ||
| /// - Parameters: | ||
| /// - configuration: The configuration to use for the barcode parser. Defaults to the standard configuration. | ||
| /// - onScan: The closure to be called when a scan is completed. | ||
| init(configuration: HIDBarcodeParserConfiguration = .default, onScan: @escaping (Result<String, Error>) -> Void) { | ||
| self.onScan = onScan | ||
| self.configuration = configuration | ||
| addObservers() | ||
| setupCoalescedKeyboard() | ||
| } | ||
|
|
||
| deinit { | ||
| removeObservers() | ||
| cleanupKeyboard() | ||
| } | ||
|
|
||
| /// Starts observing for keyboard connection and disconnection events. | ||
| private func addObservers() { | ||
| NotificationCenter.default.addObserver( | ||
| self, | ||
| selector: #selector(handleKeyboardDidConnect), | ||
| name: .GCKeyboardDidConnect, | ||
| object: nil | ||
| ) | ||
| NotificationCenter.default.addObserver( | ||
| self, | ||
| selector: #selector(handleKeyboardDidDisconnect), | ||
| name: .GCKeyboardDidDisconnect, | ||
| object: nil | ||
| ) | ||
| } | ||
|
|
||
| /// Stops observing for keyboard events. | ||
| private func removeObservers() { | ||
| NotificationCenter.default.removeObserver(self, name: .GCKeyboardDidConnect, object: nil) | ||
| NotificationCenter.default.removeObserver(self, name: .GCKeyboardDidDisconnect, object: nil) | ||
| } | ||
|
|
||
| /// Sets up the coalesced keyboard if one is available at initialization. | ||
| private func setupCoalescedKeyboard() { | ||
| if let keyboard = GCKeyboard.coalesced { | ||
| setupKeyboard(keyboard) | ||
| } | ||
| } | ||
|
|
||
| /// Handles the connection of a keyboard (coalesced). | ||
| @objc private func handleKeyboardDidConnect(_ notification: Notification) { | ||
| guard let keyboard = notification.object as? GCKeyboard else { | ||
| return | ||
| } | ||
| setupKeyboard(keyboard) | ||
| } | ||
|
|
||
| /// Handles the disconnection of the keyboard (coalesced). | ||
| @objc private func handleKeyboardDidDisconnect(_ notification: Notification) { | ||
| cleanupKeyboard() | ||
| } | ||
|
|
||
| /// Sets up the coalesced keyboard to handle key press events. | ||
| /// - Parameter keyboard: The coalesced `GCKeyboard` to set up. | ||
| private func setupKeyboard(_ keyboard: GCKeyboard) { | ||
| // Clean up any existing setup first | ||
| cleanupKeyboard() | ||
|
|
||
| coalescedKeyboard = keyboard | ||
| barcodeParser = GameControllerBarcodeParser(configuration: configuration, onScan: onScan) | ||
|
|
||
| keyboard.keyboardInput?.keyChangedHandler = { [weak self] _, _, keyCode, pressed in | ||
| guard let self = self else { return } | ||
|
|
||
| if keyCode == .leftShift || keyCode == .rightShift { | ||
| self.isShiftPressed = pressed | ||
| return | ||
| } | ||
|
|
||
| guard pressed else { return } | ||
|
|
||
| self.barcodeParser?.processKeyPress(keyCode, isShiftPressed: isShiftPressed) | ||
| } | ||
| } | ||
|
|
||
| /// Cleans up the coalesced keyboard and its parser. | ||
| private func cleanupKeyboard() { | ||
| coalescedKeyboard?.keyboardInput?.keyChangedHandler = nil | ||
| barcodeParser?.cancel() | ||
| coalescedKeyboard = nil | ||
| barcodeParser = nil | ||
| isShiftPressed = false | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,197 @@ | ||
| import Foundation | ||
| import GameController | ||
|
|
||
| /// Parses GameController keyboard input into barcode scans. | ||
| /// This class handles the core logic for interpreting GameController GCKeyCode input as barcode data, | ||
| /// providing language-independent barcode scanning by bypassing iOS keyboard layout processing. | ||
| final class GameControllerBarcodeParser { | ||
| /// Configuration for the barcode scanner | ||
| let configuration: HIDBarcodeParserConfiguration | ||
| /// Callback that is triggered when a barcode scan completes (success or failure) | ||
| let onScan: (Result<String, Error>) -> Void | ||
|
|
||
| private let timeProvider: TimeProvider | ||
|
|
||
| private var buffer = "" | ||
| private var lastKeyPressTime: Date? | ||
|
|
||
| init(configuration: HIDBarcodeParserConfiguration, | ||
| onScan: @escaping (Result<String, Error>) -> Void, | ||
| timeProvider: TimeProvider = DefaultTimeProvider()) { | ||
| self.configuration = configuration | ||
| self.onScan = onScan | ||
| self.timeProvider = timeProvider | ||
| } | ||
|
|
||
| /// Process a GameController key press event | ||
| /// - Parameters: | ||
| /// - keyCode: The GameController key code that was pressed | ||
| /// - isShiftPressed: Whether shift key is currently pressed | ||
| func processKeyPress(_ keyCode: GCKeyCode, isShiftPressed: Bool = false) { | ||
| guard shouldRecogniseAsScanKeystroke(keyCode, isShiftPressed: isShiftPressed) else { | ||
| return | ||
| } | ||
|
|
||
| guard let character = characterForKeyCode(keyCode, isShiftPressed: isShiftPressed) else { | ||
| return | ||
| } | ||
|
|
||
| if configuration.terminatingStrings.contains(character) { | ||
| processScan() | ||
| } else { | ||
| guard !excludedKeyCodes.contains(keyCode) else { return } | ||
| checkForTimeoutBetweenKeystrokes() | ||
| buffer.append(character) | ||
| } | ||
| } | ||
|
|
||
| private func shouldRecogniseAsScanKeystroke(_ keyCode: GCKeyCode, isShiftPressed: Bool) -> Bool { | ||
| guard let character = characterForKeyCode(keyCode, isShiftPressed: isShiftPressed) else { | ||
| // This prevents a double-trigger-pull on a Star scanner from adding an error row – | ||
| // Star use this as a shortcut to switch to the software keyboard. They send keycode 174 0xAE, which is | ||
| // undefined and reserved in UIKeyboardHIDUsage. The scanner doesn't send a character with the code. | ||
| // There seems to be no reason to handle empty input when considering scans. | ||
| return false | ||
| } | ||
|
|
||
| guard character.isNotEmpty else { | ||
| // This prevents a double-trigger-pull on a Star scanner from adding an error row – | ||
| // Star use this as a shortcut to switch to the software keyboard. They send keycode 174 0xAE, which is | ||
| // undefined and reserved in UIKeyboardHIDUsage. The scanner doesn't send a character with the code. | ||
| // There seems to be no reason to handle empty input when considering scans. | ||
| return false | ||
|
||
| } | ||
|
|
||
| if buffer.isEmpty && configuration.terminatingStrings.contains(character) { | ||
| // We prefer to show all partial scans, but if we just get an enter with no numbers, ignoring it makes testing easier | ||
| return false | ||
| } | ||
|
|
||
| return true | ||
| } | ||
|
|
||
| private func checkForTimeoutBetweenKeystrokes() { | ||
| // If characters are entered too slowly, it's probably typing and we should ignore the old input. | ||
| // The key we just received is still considered for adding to the buffer – we may simply reset the buffer first. | ||
| let currentTime = timeProvider.now() | ||
|
|
||
| if let lastTime = lastKeyPressTime, | ||
| currentTime.timeIntervalSince(lastTime) > configuration.maximumInterCharacterTime { | ||
| onScan(.failure(HIDBarcodeParserError.timedOut(barcode: buffer))) | ||
| resetScan() | ||
| } | ||
|
|
||
| lastKeyPressTime = currentTime | ||
| } | ||
|
|
||
| /// Convert GCKeyCode to ASCII character | ||
| /// Maps GameController key codes to their corresponding ASCII characters | ||
| /// - Parameters: | ||
| /// - keyCode: The GameController key code | ||
| /// - isShiftPressed: Whether shift key is pressed (affects letter case and symbols) | ||
| private func characterForKeyCode(_ keyCode: GCKeyCode, isShiftPressed: Bool) -> String? { | ||
| switch keyCode { | ||
| // Numbers and their shifted symbols | ||
| case .zero: return isShiftPressed ? ")" : "0" | ||
| case .one: return isShiftPressed ? "!" : "1" | ||
| case .two: return isShiftPressed ? "@" : "2" | ||
| case .three: return isShiftPressed ? "#" : "3" | ||
| case .four: return isShiftPressed ? "$" : "4" | ||
| case .five: return isShiftPressed ? "%" : "5" | ||
| case .six: return isShiftPressed ? "^" : "6" | ||
| case .seven: return isShiftPressed ? "&" : "7" | ||
| case .eight: return isShiftPressed ? "*" : "8" | ||
| case .nine: return isShiftPressed ? "(" : "9" | ||
|
|
||
| // Letters - lowercase without shift, uppercase with shift | ||
| case .keyA: return isShiftPressed ? "A" : "a" | ||
| case .keyB: return isShiftPressed ? "B" : "b" | ||
| case .keyC: return isShiftPressed ? "C" : "c" | ||
| case .keyD: return isShiftPressed ? "D" : "d" | ||
| case .keyE: return isShiftPressed ? "E" : "e" | ||
| case .keyF: return isShiftPressed ? "F" : "f" | ||
| case .keyG: return isShiftPressed ? "G" : "g" | ||
| case .keyH: return isShiftPressed ? "H" : "h" | ||
| case .keyI: return isShiftPressed ? "I" : "i" | ||
| case .keyJ: return isShiftPressed ? "J" : "j" | ||
| case .keyK: return isShiftPressed ? "K" : "k" | ||
| case .keyL: return isShiftPressed ? "L" : "l" | ||
| case .keyM: return isShiftPressed ? "M" : "m" | ||
| case .keyN: return isShiftPressed ? "N" : "n" | ||
| case .keyO: return isShiftPressed ? "O" : "o" | ||
| case .keyP: return isShiftPressed ? "P" : "p" | ||
| case .keyQ: return isShiftPressed ? "Q" : "q" | ||
| case .keyR: return isShiftPressed ? "R" : "r" | ||
| case .keyS: return isShiftPressed ? "S" : "s" | ||
| case .keyT: return isShiftPressed ? "T" : "t" | ||
| case .keyU: return isShiftPressed ? "U" : "u" | ||
| case .keyV: return isShiftPressed ? "V" : "v" | ||
| case .keyW: return isShiftPressed ? "W" : "w" | ||
| case .keyX: return isShiftPressed ? "X" : "x" | ||
| case .keyY: return isShiftPressed ? "Y" : "y" | ||
| case .keyZ: return isShiftPressed ? "Z" : "z" | ||
|
|
||
| // Punctuation and symbols with shift variants | ||
| case .spacebar: return " " | ||
| case .hyphen: return isShiftPressed ? "_" : "-" | ||
| case .equalSign: return isShiftPressed ? "+" : "=" | ||
| case .openBracket: return isShiftPressed ? "{" : "[" | ||
| case .closeBracket: return isShiftPressed ? "}" : "]" | ||
| case .backslash: return isShiftPressed ? "|" : "\\" | ||
| case .semicolon: return isShiftPressed ? ":" : ";" | ||
| case .quote: return isShiftPressed ? "\"" : "'" | ||
| case .comma: return isShiftPressed ? "<" : "," | ||
| case .period: return isShiftPressed ? ">" : "." | ||
| case .slash: return isShiftPressed ? "?" : "/" | ||
| case .graveAccentAndTilde: return isShiftPressed ? "~" : "`" | ||
| case .returnOrEnter: return "\r" | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Since it's |
||
| case .tab: return "\t" | ||
| default: | ||
| return nil | ||
| } | ||
| } | ||
|
|
||
| /// Key codes that should be excluded from barcode processing | ||
| private let excludedKeyCodes: Set<GCKeyCode> = [ | ||
| .capsLock, | ||
| .leftShift, | ||
| .rightShift, | ||
| .leftControl, | ||
| .rightControl, | ||
| .upArrow, | ||
| .downArrow, | ||
| .leftArrow, | ||
| .rightArrow, | ||
| .pageUp, | ||
| .pageDown, | ||
| .home, | ||
| .end, | ||
| .insert, | ||
| .deleteForward, | ||
| .deleteOrBackspace, | ||
| .printScreen, | ||
| .scrollLock, | ||
| .pause, | ||
| .escape | ||
| ] | ||
|
|
||
| /// Cancel the current scan and clear the buffer | ||
| func cancel() { | ||
| resetScan() | ||
| } | ||
|
|
||
| private func resetScan() { | ||
| buffer = "" | ||
| lastKeyPressTime = nil | ||
| } | ||
|
|
||
| private func processScan() { | ||
| checkForTimeoutBetweenKeystrokes() | ||
| if buffer.count >= configuration.minimumBarcodeLength { | ||
| onScan(.success(buffer)) | ||
| } else { | ||
| onScan(.failure(HIDBarcodeParserError.scanTooShort(barcode: buffer))) | ||
| } | ||
| resetScan() | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I wondered if we should keep this for now or replace it entirely.
We could remove
BarcodeScannerHostingController,HIDBarcodeParser, and possibly most if not all theBarcodeScannerContainercode.GameControllerBarcodeObservertheoretically allows us to observe all the input within the aggregate model, although we would lose the ability to control in which screens we want to receive the input.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
No, I think we should keep the container. We already have the need to ignore scans in some screens (search, email receipt, everything in between building a cart and payment success.
The need for granular control is likely to increase over time – for example, any time we add a text field we need to think about it, and any control will be based on view state, what's focused. Quantity selectors, customer detail entry, configuring bookings or subscription purchases – there's lots of places we may end up having more complex text field management.