Skip to content

Commit b91595b

Browse files
authored
[Woo POS] Improve VoiceOver accessibility for barcode scanning errors (#15793)
2 parents ceb87ae + ca5a868 commit b91595b

File tree

8 files changed

+107
-8
lines changed

8 files changed

+107
-8
lines changed

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

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@ extension Cart {
1111
title: title(for: error),
1212
subtitle: subtitle(for: error),
1313
quantity: 1,
14-
state: .error
14+
state: .error,
15+
accessibilityLabel: accessibilityLabel(for: error)
1516
)
1617

1718
return purchasableItems[index]
@@ -35,6 +36,12 @@ extension Cart {
3536
private func subtitle(for error: PointOfSaleBarcodeScanError) -> String {
3637
return error.localizedDescription
3738
}
39+
40+
private func accessibilityLabel(for error: PointOfSaleBarcodeScanError) -> String {
41+
let errorDescription = error.localizedDescription
42+
let scannedValue = title(for: error)
43+
return "\(errorDescription). \(scannedValue)"
44+
}
3845
}
3946

4047
extension PointOfSaleBarcodeScanError {

WooCommerce/Classes/POS/Models/Cart.swift

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import enum Yosemite.PointOfSaleBarcodeScanError
66
struct Cart {
77
var purchasableItems: [Cart.PurchasableItem] = []
88
var coupons: [Cart.CouponItem] = []
9+
10+
var accessibilityFocusedItemID: UUID? = nil
911
}
1012

1113
protocol CartItem {
@@ -26,6 +28,7 @@ extension Cart {
2628
let quantity: Int
2729
let type: CartItemType = .purchasableItem
2830
let state: ItemState
31+
let accessibilityLabel: String?
2932

3033
enum ItemState {
3134
case loaded(POSOrderableItem)
@@ -42,12 +45,13 @@ extension Cart {
4245
}
4346
}
4447

45-
init(id: UUID, title: String, subtitle: String?, quantity: Int, state: ItemState) {
48+
init(id: UUID, title: String, subtitle: String?, quantity: Int, state: ItemState, accessibilityLabel: String? = nil) {
4649
self.id = id
4750
self.title = title
4851
self.subtitle = subtitle
4952
self.quantity = quantity
5053
self.state = state
54+
self.accessibilityLabel = accessibilityLabel
5155
}
5256

5357
init(id: UUID, item: POSOrderableItem, title: String, subtitle: String?, quantity: Int) {
@@ -56,6 +60,7 @@ extension Cart {
5660
self.subtitle = subtitle
5761
self.quantity = quantity
5862
self.state = .loaded(item)
63+
self.accessibilityLabel = nil
5964
}
6065

6166
static func loading(id: UUID) -> PurchasableItem {

WooCommerce/Classes/POS/Models/PointOfSaleAggregateModel.swift

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -207,12 +207,16 @@ extension PointOfSaleAggregateModel {
207207
productType: .init(cartItem: cartItem)
208208
)
209209
)
210+
211+
cart.accessibilityFocusedItemID = cartItem.id
210212
}
211213
} catch {
212214
DDLogInfo("Failed to find item by barcode: \(error)")
213-
if let _ = cart.updateLoadingItem(id: placeholderItemID, with: error) {
215+
if let errorItem = cart.updateLoadingItem(id: placeholderItemID, with: error) {
214216
// Only play a sound and track analytics if the item still exists in the cart.
215-
await soundPlayer.playSound(.barcodeScanFailure)
217+
await soundPlayer.playSound(.barcodeScanFailure, completion: { [weak self] in
218+
self?.cart.accessibilityFocusedItemID = errorItem.id
219+
})
216220

217221
analytics.track(
218222
event: .PointOfSale.addItemToCart(

WooCommerce/Classes/POS/Presentation/CartView.swift

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ struct CartView: View {
2424
}
2525

2626
@State private var shouldShowItemImages: Bool = false
27+
@AccessibilityFocusState private var accessibilityFocusedItem: UUID?
2728

2829
private var shouldShowCoupons: Bool {
2930
posModel.cart.coupons.isNotEmpty
@@ -90,9 +91,17 @@ struct CartView: View {
9091
})
9192
.id(cartItem.id)
9293
.transition(.opacity)
94+
.accessibilityFocused($accessibilityFocusedItem, equals: cartItem.id)
9395
}
9496
}
9597
.padding(.bottom, Constants.cartLastItemBottomPadding)
98+
.onChange(of: posModel.cart.accessibilityFocusedItemID) { itemID in
99+
if let itemID = itemID {
100+
Task { @MainActor in
101+
accessibilityFocusedItem = itemID
102+
}
103+
}
104+
}
96105
.animation(Constants.cartAnimation, value: posModel.cart.purchasableItems.map(\.id))
97106
.animation(Constants.cartAnimation, value: posModel.cart.coupons.map(\.id))
98107
.background(GeometryReader { geometry in

WooCommerce/Classes/POS/Presentation/ItemRowView.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,10 @@ struct ItemRowView: View {
7777
.padding(.leading, showProductImage ? 0 : Constants.cardContentHorizontalPadding * (1 / scale))
7878
.padding(.vertical, Constants.verticalPadding * (1 / scale))
7979
.accessibilityElement(children: .combine)
80+
.if(cartItem.accessibilityLabel != nil) { view in
81+
view
82+
.accessibilityLabel(cartItem.accessibilityLabel ?? "")
83+
}
8084

8185
if let onItemRemoveTapped {
8286
CartRowRemoveButton {

WooCommerce/Classes/POS/Presentation/PointOfSaleBarcodeScannerInformationModal.swift

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,13 @@ struct PointOfSaleBarcodeScannerInformationModal: View {
1616

1717
PointOfSaleInformationModalParagraphView {
1818
Text(bulletPointWithLink)
19+
.accessibilityLabel(bulletPointWithLinkAccessibilityLabel)
1920
Text(AttributedString(Localization.barcodeInfoSecondaryMessage))
21+
.accessibilityLabel(Localization.barcodeInfoSecondaryMessageAccessible)
2022
Text(AttributedString(Localization.barcodeInfoTertiaryMessage))
23+
.accessibilityLabel(Localization.barcodeInfoTertiaryMessageAccessible)
2124
Text(AttributedString(Localization.barcodeInfoQuaternaryMessage))
25+
.accessibilityLabel(Localization.barcodeInfoQuaternaryMessageAccessible)
2226
}
2327
.padding(.leading, POSSpacing.medium)
2428

@@ -37,6 +41,10 @@ struct PointOfSaleBarcodeScannerInformationModal: View {
3741
secondary.append(moreDetails)
3842
return secondary
3943
}
44+
45+
private var bulletPointWithLinkAccessibilityLabel: String {
46+
return Localization.barcodeInfoPrimaryMessageAccessible + " " + Localization.barcodeInfoMoreDetailsLinkAccessible
47+
}
4048
}
4149

4250
private extension PointOfSaleBarcodeScannerInformationModal {
@@ -65,6 +73,11 @@ private extension PointOfSaleBarcodeScannerInformationModal {
6573
value: "More details.",
6674
comment: "Link text in the barcode info modal in POS, leading to more details about barcode setup"
6775
)
76+
static let barcodeInfoMoreDetailsLinkAccessible = NSLocalizedString(
77+
"pos.barcodeInfoModal.moreDetailsLink.accessible",
78+
value: "More details, link.",
79+
comment: "Accessible version of more details link in barcode info modal, announcing it as a link for screen readers"
80+
)
6881
static let barcodeInfoSecondaryMessage = NSLocalizedString(
6982
"pos.barcodeInfoModal.secondaryMessage",
7083
value: "• Refer to your Bluetooth barcode scanner's instructions to set HID mode.",
@@ -86,6 +99,28 @@ private extension PointOfSaleBarcodeScannerInformationModal {
8699
"Tap on the keyboard icon to show it again.",
87100
comment: "Quinary message in the barcode info modal in POS, explaining scanner keyboard emulation and how to show software keyboard again"
88101
)
102+
103+
// Accessibility-friendly versions without bullet points
104+
static let barcodeInfoPrimaryMessageAccessible = NSLocalizedString(
105+
"pos.barcodeInfoModal.primaryMessage.accessible",
106+
value: "First: Set up barcodes in the \"G-T-I-N, U-P-C, E-A-N, I-S-B-N\" field by navigating to Products, then Product Details, then Inventory.",
107+
comment: "Accessible version of primary bullet point in barcode info modal, without bullet character for screen readers"
108+
)
109+
static let barcodeInfoSecondaryMessageAccessible = NSLocalizedString(
110+
"pos.barcodeInfoModal.secondaryMessage.accessible",
111+
value: "Second: Refer to your Bluetooth barcode scanner's instructions to set H-I-D mode.",
112+
comment: "Accessible version of secondary bullet point in barcode info modal, without bullet character for screen readers"
113+
)
114+
static let barcodeInfoTertiaryMessageAccessible = NSLocalizedString(
115+
"pos.barcodeInfoModal.tertiaryMessage.accessible",
116+
value: "Third: Connect your barcode scanner in iOS Bluetooth settings.",
117+
comment: "Accessible version of tertiary bullet point in barcode info modal, without bullet character for screen readers"
118+
)
119+
static let barcodeInfoQuaternaryMessageAccessible = NSLocalizedString(
120+
"pos.barcodeInfoModal.quaternaryMessage.accessible",
121+
value: "Fourth: Scan barcodes while on the item list to add products to the cart.",
122+
comment: "Accessible version of quaternary bullet point in barcode info modal, without bullet character for screen readers"
123+
)
89124
}
90125
}
91126

WooCommerce/Classes/POS/Utils/Audio/PointOfSaleSoundPlayer.swift

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,33 +11,64 @@ struct PointOfSaleSound: Equatable, Hashable {
1111
}
1212

1313
protocol PointOfSaleSoundPlayerProtocol {
14+
func playSound(_ sound: PointOfSaleSound, completion: @escaping (() -> Void)) async
1415
func playSound(_ sound: PointOfSaleSound) async
1516
}
1617

17-
actor PointOfSaleSoundPlayer: PointOfSaleSoundPlayerProtocol {
18+
actor PointOfSaleSoundPlayer: NSObject, PointOfSaleSoundPlayerProtocol {
1819
private var playerCache: [PointOfSaleSound: AVAudioPlayer] = [:]
20+
private var completionHandlers: [AVAudioPlayer: () -> Void] = [:]
1921

2022
func playSound(_ sound: PointOfSaleSound) async {
23+
await playSound(sound, completion: {})
24+
}
25+
26+
func playSound(_ sound: PointOfSaleSound, completion: @escaping (() -> Void)) async {
2127
guard let url = Bundle.main.url(forResource: sound.name, withExtension: sound.type) else {
2228
DDLogError("Sound file not found: \(sound.name).\(sound.type)")
29+
completion()
2330
return
2431
}
2532

2633
if let cachedPlayer = playerCache[sound] {
2734
if !cachedPlayer.isPlaying {
28-
cachedPlayer.currentTime = 0
29-
cachedPlayer.play()
35+
play(cachedPlayer, completion: completion)
36+
} else {
37+
completion()
3038
}
3139
return
3240
}
3341

3442
do {
3543
let audioPlayer = try AVAudioPlayer(contentsOf: url)
3644
audioPlayer.prepareToPlay()
37-
audioPlayer.play()
45+
play(audioPlayer, completion: completion)
3846
playerCache[sound] = audioPlayer
3947
} catch {
4048
DDLogError("Failed to play sound: \(error)")
49+
completion()
50+
}
51+
}
52+
53+
private func play(_ player: AVAudioPlayer, completion: @escaping (() -> Void)) {
54+
completionHandlers[player] = completion
55+
player.delegate = self
56+
player.currentTime = 0
57+
player.play()
58+
}
59+
}
60+
61+
extension PointOfSaleSoundPlayer: AVAudioPlayerDelegate {
62+
nonisolated func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) {
63+
Task { @MainActor in
64+
await handlePlayerFinished(player)
65+
}
66+
}
67+
68+
private func handlePlayerFinished(_ player: AVAudioPlayer) {
69+
if let completion = completionHandlers[player] {
70+
completionHandlers.removeValue(forKey: player)
71+
completion()
4172
}
4273
}
4374
}

WooCommerce/WooCommerceTests/POS/Mocks/MockPointOfSaleSoundPlayer.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,8 @@ final class MockPointOfSaleSoundPlayer: PointOfSaleSoundPlayerProtocol {
77
func playSound(_ sound: PointOfSaleSound) {
88
onPlaySound?(sound)
99
}
10+
11+
func playSound(_ sound: WooCommerce.PointOfSaleSound, completion: @escaping () -> Void) async {
12+
onPlaySound?(sound)
13+
}
1014
}

0 commit comments

Comments
 (0)