Skip to content

Commit d27eb7b

Browse files
authored
Merge branch 'trunk' into woomob-619-xcode-warnings-performing-io-on-the-main-thread-can-cause
2 parents a8ea09e + ab58d66 commit d27eb7b

27 files changed

+1166
-60
lines changed

Modules/Sources/Experiments/DefaultFeatureFlagService.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ public struct DefaultFeatureFlagService: FeatureFlagService {
104104
case .pointOfSaleBarcodeScanningi2:
105105
return buildConfig == .localDeveloper || buildConfig == .alpha
106106
case .orderAddressMapSearch:
107-
return buildConfig == .localDeveloper || buildConfig == .alpha
107+
return true
108108
default:
109109
return true
110110
}

RELEASE-NOTES.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
- [*] Fix initialization of authenticator to avoid crashes during login [https://github.com/woocommerce/woocommerce-ios/pull/15953]
88
- [*] Shipping Labels: Made HS tariff number field required in customs form for EU destinations [https://github.com/woocommerce/woocommerce-ios/pull/15946]
99
- [*] Order Details: Attempt to improve performance by using a simplified version of product objects. [https://github.com/woocommerce/woocommerce-ios/pull/15959]
10+
- [*] Order Details > Edit Shipping/Billing Address: Added map-based address lookup support for iOS 17+. [https://github.com/woocommerce/woocommerce-ios/pull/15964]
1011
- [*] Order Creation: Prevent subscription products to be added to an order [https://github.com/woocommerce/woocommerce-ios/pull/15960]
1112
- [internal] Replace COTS_DEVICE reader model name with TAP_TO_PAY_DEVICE. [https://github.com/woocommerce/woocommerce-ios/pull/15961]
1213

WooCommerce/Classes/Analytics/WooAnalyticsStat.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -450,6 +450,8 @@ enum WooAnalyticsStat: String {
450450
case orderDetailEditFlowFailed = "order_detail_edit_flow_failed"
451451
case orderDetailPaymentLinkShared = "order_detail_payment_link_shared"
452452
case orderDetailTrashButtonTapped = "order_detail_trash_tapped"
453+
case orderDetailEditAddressMapPickerTapped = "order_detail_edit_address_map_picker_tapped"
454+
case orderDetailEditAddressMapPickerUseAddressTapped = "order_detail_edit_address_map_picker_use_address_tapped"
453455

454456
// MARK: Test order
455457
//

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,11 +29,11 @@ enum PointOfSaleBarcodeScannerType {
2929
var analyticsName: String {
3030
switch self {
3131
case .starBSH20B:
32-
return "Star_BSH_20B"
32+
return "star_bsh_20b"
3333
case .tera12002D:
34-
return "Tera_1200_2D"
34+
return "tera_1200_2d"
3535
case .netum1228BC:
36-
return "Netum_1228BC"
36+
return "netum_1228bc"
3737
case .other:
3838
return "other"
3939
}

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

Lines changed: 12 additions & 3 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 {
@@ -196,9 +205,9 @@ private extension PointOfSaleBarcodeScannerSetupCompleteView {
196205
comment: "Title shown when scanner setup is successfully completed"
197206
)
198207
static let instruction = NSLocalizedString(
199-
"pos.barcodeScannerSetup.complete.instruction",
200-
value: "You are ready to start scanning products. \nRead more about barcode and QR code scanner support.",
201-
comment: "Message shown when scanner setup is complete, with additional information link"
208+
"pos.barcodeScannerSetup.complete.instruction.2",
209+
value: "You are ready to start scanning products. Next time you need to connect your scanner, just turn it on and it will reconnect automatically.",
210+
comment: "Message shown when scanner setup is complete"
202211
)
203212
}
204213
}
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
}

0 commit comments

Comments
 (0)