diff --git a/TalerCommon/Helper/AccessibilityIdentifiers.swift b/TalerCommon/Helper/AccessibilityIdentifiers.swift new file mode 100644 index 00000000..72876b82 --- /dev/null +++ b/TalerCommon/Helper/AccessibilityIdentifiers.swift @@ -0,0 +1,67 @@ +/* + * This file is part of GNU Taler, ©2026 Taler Systems S.A. + * See LICENSE.md + */ +/** + * Stable accessibility identifiers for UI automation. + * + * Identifiers are not shown or spoken anywhere — they exist so that a UI test + * can address a control by something that does not change when its label is + * reworded or translated. Keeping them in one place makes them greppable and + * keeps them unique. + * + * Two rules when adding to this list: + * + * 1. **Put identifiers on leaves, never on containers.** SwiftUI propagates + * `.accessibilityIdentifier` to every accessibility element inside the view + * it is applied to, *overwriting* identifiers those elements already carry. + * On a container that yields a set of duplicates — useless for addressing — + * and silently clobbers anything more specific underneath. + * 2. Applying an identifier does not change what VoiceOver reads, nor the + * shape of the accessibility tree; `.accessibilityLabel`, + * `.accessibilityHint` and `.accessibilityElement(children:)` keep doing + * exactly what they did. Order still matters, so add the identifier next + * to the other accessibility modifiers rather than before them. + */ +import Foundation + +public enum A11yID { + // MARK: - toolbar and navigation leaves + public static let hamburgerButton = "button.hamburger" + public static let qrButton = "button.qr" + public static let doneButton = "button.done" + public static let plusButton = "button.plus" + public static let moreButton = "button.more" + public static let settingsButton = "button.settings" + public static let backButton = "button.back" + public static let forwardButton = "button.forward" + public static let arrowUpButton = "button.arrowUp" + public static let arrowDownButton = "button.arrowDown" + public static let zoomInButton = "button.zoomIn" + public static let zoomOutButton = "button.zoomOut" + public static let reloadButton = "button.reload" + public static let pasteButton = "button.paste" + public static let linkButton = "button.link" + + // MARK: - copy / share leaves + /// Generic fallback; call sites that matter pass their own, e.g. + /// `A11yID.copy("iban")`. + public static let copyButton = "button.copy" + public static let shareButton = "button.share" + public static func copy(_ what: String) -> String { "button.copy.\(what)" } + public static func share(_ what: String) -> String { "button.share.\(what)" } + + // MARK: - loading / stalled leaves + public static let loadingCancelButton = "button.loading.cancel" + public static let stalledRetryButton = "button.stalled.retry" + + // MARK: - empty-wallet (onboarding) leaves + public static let demoCashButton = "button.onboarding.demoCash" + public static let withdrawRealButton = "button.onboarding.withdrawReal" + + // MARK: - withdrawal flow leaves + public static let tosOpenButton = "button.withdraw.tos.open" + public static let tosAcceptButton = "button.withdraw.tos.accept" + public static let withdrawConfirmButton = "button.withdraw.confirm" + public static let withdrawAcceptButton = "button.withdraw.accept" +} diff --git a/TalerCommon/HelperViews/Buttons.swift b/TalerCommon/HelperViews/Buttons.swift index 025b7cf7..7c63b049 100755 --- a/TalerCommon/HelperViews/Buttons.swift +++ b/TalerCommon/HelperViews/Buttons.swift @@ -29,6 +29,7 @@ struct HamburgerButton : View { } .talerFont(.title) .accessibilityLabel(Text("Main Menu", comment: "a11y")) + .accessibilityIdentifier(A11yID.hamburgerButton) } } @@ -131,6 +132,7 @@ struct QRButton : View { } } .accessibilityLabel(scanText) + .accessibilityIdentifier(A11yID.qrButton) .alert("Scanning QR-codes requires access to the camera", isPresented: $showCameraAlert, actions: { openSettingsButton @@ -155,6 +157,7 @@ struct DoneButton : View { .tint(WalletColors().primaryAccent) .talerFont(.title) .accessibilityLabel(accessibilityLabelStr) + .accessibilityIdentifier(A11yID.doneButton) } } @@ -169,6 +172,7 @@ struct PlusButton : View { .tint(WalletColors().primaryAccent) .talerFont(.title) .accessibilityLabel(accessibilityLabelStr) + .accessibilityIdentifier(A11yID.plusButton) } } @@ -183,6 +187,7 @@ struct MoreButton : View { .tint(WalletColors().primaryAccent) .talerFont(.title) .accessibilityLabel(accessibilityLabelStr) + .accessibilityIdentifier(A11yID.moreButton) } } @@ -215,6 +220,7 @@ struct SettingsButton : View { .tint(WalletColors().primaryAccent) .talerFont(.title) .accessibilityLabel(accessibilityLabelStr) + .accessibilityIdentifier(A11yID.settingsButton) // if #available(iOS 26.0, *) { // return button.buttonStyle(.glass) // } @@ -232,6 +238,7 @@ struct TalerPasteButton : View { .tint(WalletColors().primaryAccent) .talerFont(.title) .accessibilityLabel(accessibilityLabelStr) + .accessibilityIdentifier(A11yID.pasteButton) // if #available(iOS 26.0, *) { // return button.buttonStyle(.glass) // } @@ -250,6 +257,7 @@ struct ZoomInButton : View { .tint(WalletColors().primaryAccent) .talerFont(.title) .accessibilityLabel(accessibilityLabelStr) + .accessibilityIdentifier(A11yID.zoomInButton) } } @@ -264,6 +272,7 @@ struct ZoomOutButton : View { .tint(WalletColors().primaryAccent) .talerFont(.title) .accessibilityLabel(accessibilityLabelStr) + .accessibilityIdentifier(A11yID.zoomOutButton) } } @@ -279,6 +288,7 @@ struct BackButton : View { .tint(WalletColors().primaryAccent) .talerFont(.largeTitle) .accessibilityLabel(Text("Back", comment: "a11y")) + .accessibilityIdentifier(A11yID.backButton) } } @@ -302,6 +312,7 @@ struct ForwardButton : View { .tint(WalletColors().primaryAccent) .talerFont(.largeTitle) .accessibilityLabel(Text("Continue", comment: "a11y")) + .accessibilityIdentifier(A11yID.forwardButton) } } @@ -315,6 +326,7 @@ struct ArrowUpButton : View { .tint(WalletColors().primaryAccent) .talerFont(.title2) .accessibilityLabel(Text("Scroll up", comment: "a11y")) + .accessibilityIdentifier(A11yID.arrowUpButton) } } @@ -328,6 +340,7 @@ struct ArrowDownButton : View { .tint(WalletColors().primaryAccent) .talerFont(.title2) .accessibilityLabel(Text("Scroll down", comment: "a11y")) + .accessibilityIdentifier(A11yID.arrowDownButton) } } @@ -342,6 +355,7 @@ struct ReloadButton : View { .tint(WalletColors().primaryAccent) .talerFont(.title) .accessibilityLabel(Text("Reload", comment: "a11y")) + .accessibilityIdentifier(A11yID.reloadButton) .disabled(disabled) } } diff --git a/TalerCommon/HelperViews/CopyShare.swift b/TalerCommon/HelperViews/CopyShare.swift index 826556c4..b115418b 100644 --- a/TalerCommon/HelperViews/CopyShare.swift +++ b/TalerCommon/HelperViews/CopyShare.swift @@ -73,6 +73,10 @@ struct CopyButton: View { let image: UIImage? let vertical: Bool let title: String? + /// Stable handle for UI tests; see A11yID. Optional so existing call + /// sites keep working, but anything a test needs to address should pass + /// one, otherwise every copy button on a screen shares an identifier. + let a11yID: String? @Environment(\.isEnabled) private var isEnabled: Bool @EnvironmentObject private var controller: Controller @@ -80,21 +84,23 @@ struct CopyButton: View { @State private var scale: CGFloat = 1.0 init(_ textToCopy: String, isCopied: Binding? = nil, - vertical: Bool, image: UIImage? = nil) { + vertical: Bool, image: UIImage? = nil, a11yID: String? = nil) { self.textToCopy = textToCopy self._isCopied = isCopied ?? Binding.constant(nil) self.image = image self.vertical = vertical self.title = nil + self.a11yID = a11yID } init(_ textToCopy: String, isCopied: Binding? = nil, - title: String, image: UIImage? = nil) { + title: String, image: UIImage? = nil, a11yID: String? = nil) { self.textToCopy = textToCopy self._isCopied = isCopied ?? Binding.constant(nil) self.image = image self.vertical = false self.title = title + self.a11yID = a11yID } func copyAction() -> Void { @@ -157,6 +163,7 @@ struct CopyButton: View { .talerFont(.body) .scaleEffect(scale) .disabled(!isEnabled) + .accessibilityIdentifier(a11yID ?? A11yID.copyButton) } } // MARK: - @@ -178,16 +185,21 @@ struct ShareButton: View { let textToShare: String let image: UIImage? let title: String + /// Stable handle for UI tests; see A11yID. + let a11yID: String? - init(_ textToShare: String, image: UIImage? = nil) { + init(_ textToShare: String, image: UIImage? = nil, a11yID: String? = nil) { self.textToShare = textToShare self.image = image self.title = String(localized: "Share") + self.a11yID = a11yID } - init(_ textToShare: String, title: String, image: UIImage? = nil) { + init(_ textToShare: String, title: String, image: UIImage? = nil, + a11yID: String? = nil) { self.textToShare = textToShare self.image = image self.title = title + self.a11yID = a11yID } @Environment(\.fromSheet) private var fromSheet @@ -242,6 +254,7 @@ struct ShareButton: View { .talerFont(.body) .scaleEffect(scale) .disabled(!isEnabled) + .accessibilityIdentifier(a11yID ?? A11yID.shareButton) } } // MARK: - diff --git a/TalerWallet1/Views/Actions/Banking/ManualWithdraw.swift b/TalerWallet1/Views/Actions/Banking/ManualWithdraw.swift index 861d324c..559c2259 100644 --- a/TalerWallet1/Views/Actions/Banking/ManualWithdraw.swift +++ b/TalerWallet1/Views/Actions/Banking/ManualWithdraw.swift @@ -278,6 +278,7 @@ struct ManualWithdrawContent: View { Text(withdrawButtonTitle(currency)) } .buttonStyle(TalerButtonStyle(type: .prominent, disabled: disabled)) + .accessibilityIdentifier(A11yID.withdrawConfirmButton) .disabled(disabled) .padding(.top) } else { diff --git a/TalerWallet1/Views/HelperViews/LoadingView.swift b/TalerWallet1/Views/HelperViews/LoadingView.swift index b0864c85..01fd9859 100644 --- a/TalerWallet1/Views/HelperViews/LoadingView.swift +++ b/TalerWallet1/Views/HelperViews/LoadingView.swift @@ -28,6 +28,7 @@ struct StalledView: View { } } .buttonStyle(TalerButtonStyle(type: .bordered, disabled: disabled)) + .accessibilityIdentifier(A11yID.stalledRetryButton) .padding(.horizontal) } @@ -135,6 +136,7 @@ struct LoadingView: View { } } .buttonStyle(TalerButtonStyle(type: .bordered, disabled: false)) + .accessibilityIdentifier(A11yID.loadingCancelButton) .padding(.horizontal) } diff --git a/TalerWallet1/Views/HelperViews/ToSButtonView.swift b/TalerWallet1/Views/HelperViews/ToSButtonView.swift index 85ead05b..7f46c5e4 100644 --- a/TalerWallet1/Views/HelperViews/ToSButtonView.swift +++ b/TalerWallet1/Views/HelperViews/ToSButtonView.swift @@ -37,6 +37,7 @@ struct ToSButtonView: View { Text("Terms of Service") // VIEW_WITHDRAW_TOS }.buttonStyle(TalerButtonStyle(type: .prominent)) .padding(.horizontal) + .accessibilityIdentifier(A11yID.tosOpenButton) #if false let title = "getPerformanceStats" Button(title) { diff --git a/TalerWallet1/Views/Main/WalletEmptyView.swift b/TalerWallet1/Views/Main/WalletEmptyView.swift index 5f97830f..cee682a0 100644 --- a/TalerWallet1/Views/Main/WalletEmptyView.swift +++ b/TalerWallet1/Views/Main/WalletEmptyView.swift @@ -101,6 +101,7 @@ struct ProdSectionView: View { aligned: .center)) .padding(.top, -6) .padding(.bottom, 6) + .accessibilityIdentifier(A11yID.withdrawRealButton) } // showDropdown } header: { if isEmpty { @@ -255,6 +256,7 @@ struct WalletEmptyView: View { } .buttonStyle(TalerButtonStyle(type: .prominent, narrow: false, disabled: withDrawStarted, aligned: .center)) .disabled(withDrawStarted) + .accessibilityIdentifier(A11yID.demoCashButton) // } header: { // let secondHeader = String(localized: "Demo", comment: "section header") // Text(secondHeader) diff --git a/TalerWallet1/Views/Sheets/WithdrawBankIntegrated/WithdrawAcceptView.swift b/TalerWallet1/Views/Sheets/WithdrawBankIntegrated/WithdrawAcceptView.swift index a05dc506..c0cb7a66 100644 --- a/TalerWallet1/Views/Sheets/WithdrawBankIntegrated/WithdrawAcceptView.swift +++ b/TalerWallet1/Views/Sheets/WithdrawBankIntegrated/WithdrawAcceptView.swift @@ -127,6 +127,7 @@ struct WithdrawAcceptView: View { Text("Confirm Withdrawal") // SHEET_WITHDRAW_ACCEPT } .buttonStyle(TalerButtonStyle(type: .prominent)) + .accessibilityIdentifier(A11yID.withdrawAcceptButton) .padding(.horizontal) } } diff --git a/TalerWallet1/Views/Sheets/WithdrawBankIntegrated/WithdrawTOSView.swift b/TalerWallet1/Views/Sheets/WithdrawBankIntegrated/WithdrawTOSView.swift index 84b0b4e3..0fb8d162 100644 --- a/TalerWallet1/Views/Sheets/WithdrawBankIntegrated/WithdrawTOSView.swift +++ b/TalerWallet1/Views/Sheets/WithdrawBankIntegrated/WithdrawTOSView.swift @@ -196,6 +196,7 @@ extension WithdrawTOSView { Button(String(localized: "Accept Terms of Service", comment: "Button"), action: acceptAction) .buttonStyle(TalerButtonStyle(type: .prominent)) .accessibility(sortPriority: 2) // read this second + .accessibilityIdentifier(A11yID.tosAcceptButton) .padding(.horizontal) } } diff --git a/TalerWallet1/Views/Transactions/ManualDetailsWireV.swift b/TalerWallet1/Views/Transactions/ManualDetailsWireV.swift index b7709d56..fbcf7f17 100644 --- a/TalerWallet1/Views/Transactions/ManualDetailsWireV.swift +++ b/TalerWallet1/Views/Transactions/ManualDetailsWireV.swift @@ -112,7 +112,7 @@ struct PayeeZip: View { } .frame(maxWidth: .infinity, alignment: .leading) .accessibilityElement(children: .combine) .accessibilityLabel(Text("Zip code", comment: "a11y")) - CopyButton(receiverZip, isCopied: $isCopied, vertical: true) + CopyButton(receiverZip, isCopied: $isCopied, vertical: true, a11yID: A11yID.copy("zip")) .accessibilityLabel(Text("Copy the zip code", comment: "a11y")) .disabled(false) } .padding(.top, -8) @@ -134,7 +134,7 @@ struct PayeeReceiver: View { } .frame(maxWidth: .infinity, alignment: .leading) .accessibilityElement(children: .combine) .accessibilityLabel(Text("Recipient", comment: "a11y")) - CopyButton(receiverStr, isCopied: $isCopied, vertical: true) + CopyButton(receiverStr, isCopied: $isCopied, vertical: true, a11yID: A11yID.copy("recipient")) .accessibilityLabel(Text("Copy the recipient", comment: "a11y")) .disabled(false) } .padding(.top, -8) @@ -156,7 +156,7 @@ struct PayeeTown: View { } .frame(maxWidth: .infinity, alignment: .leading) .accessibilityElement(children: .combine) .accessibilityLabel(Text("City", comment: "a11y")) - CopyButton(receiverTown, isCopied: $isCopied, vertical: true) + CopyButton(receiverTown, isCopied: $isCopied, vertical: true, a11yID: A11yID.copy("town")) .accessibilityLabel(Text("Copy the city", comment: "a11y")) .disabled(false) } .padding(.top, -8) @@ -177,7 +177,7 @@ struct Cryptocode: View { .accessibilityLabel(isChQRr ? Text("QR reference", comment: "a11y") : Text("Cryptocode", comment: "a11y")) .frame(maxWidth: .infinity, alignment: .leading) - CopyButton(chQRr ?? cryptoString, isCopied: $isCopied, vertical: true) + CopyButton(chQRr ?? cryptoString, isCopied: $isCopied, vertical: true, a11yID: A11yID.copy("cryptocode")) .accessibilityLabel(isChQRr ? Text("Copy the QR reference", comment: "a11y") : Text("Copy the cryptocode", comment: "a11y")) .disabled(false) @@ -200,7 +200,7 @@ struct IbanCode: View { } .frame(maxWidth: .infinity, alignment: .leading) .accessibilityElement(children: .combine) .accessibilityLabel(Text("IBAN of the recipient", comment: "a11y")) // TODO: BBAN - CopyButton(iban, isCopied: $isCopied, vertical: true) + CopyButton(iban, isCopied: $isCopied, vertical: true, a11yID: A11yID.copy("iban")) .accessibilityLabel(Text("Copy the IBAN", comment: "a11y")) // TODO: BBAN .disabled(false) } // .padding(.top, -8) @@ -224,7 +224,7 @@ struct AmountCode: View { } .frame(maxWidth: .infinity, alignment: .leading) .accessibilityElement(children: .combine) .accessibilityLabel(Text("Amount to transfer", comment: "a11y")) - CopyButton(amountValue, isCopied: $isCopied, vertical: true) + CopyButton(amountValue, isCopied: $isCopied, vertical: true, a11yID: A11yID.copy("amount")) // only digits + separator, no currency name or symbol .accessibilityLabel(Text("Copy the amount", comment: "a11y")) .disabled(false) @@ -247,7 +247,7 @@ struct XTalerCode: View { } .frame(maxWidth: .infinity, alignment: .leading) .accessibilityElement(children: .combine) .accessibilityLabel(Text("account of the recipient", comment: "a11y")) - CopyButton(xTaler, isCopied: $isCopied, vertical: true) + CopyButton(xTaler, isCopied: $isCopied, vertical: true, a11yID: A11yID.copy("subject")) .accessibilityLabel(Text("Copy the account", comment: "a11y")) .disabled(false) } .padding(.top, -8) @@ -400,10 +400,11 @@ struct ManualDetailsWireV: View { list.toolbar { ToolbarTitleMenu { if let debitPaytoStr { - CopyButton(paytoStr, isCopied: $isCopied1, title: "Copy validation payto") - CopyButton(debitPaytoStr, isCopied: $isCopied2, title: "Copy deposit payto") + CopyButton(paytoStr, isCopied: $isCopied1, title: "Copy validation payto", a11yID: A11yID.copy("validationPayto")) + CopyButton(debitPaytoStr, isCopied: $isCopied2, title: "Copy deposit payto", a11yID: A11yID.copy("depositPayto")) } else { - CopyButton(paytoStr, isCopied: $isCopied1, title: "Copy transfer payto") + CopyButton(paytoStr, isCopied: $isCopied1, title: "Copy transfer payto", + a11yID: A11yID.copy("payto")) } } }