diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f63b7b..ba9c63a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Error messages are localized (English and Japanese). A request that fails without the API's `error_message` shows "Something went wrong while communicating with the server. Please try again later." (`NATIVEAPPTEMPLATE-2001`); developer-only errors (`NATIVEAPPTEMPLATE-1001`, `2002`–`2008`, `4001`–`4004`) show "An unexpected error occurred." The API's `error_message` is shown without the " [Status: nnn]" suffix. Error codes are unchanged; status codes, field names and underlying errors go to the log (`CodedError.debugDescription`) ### Fixed +- VoiceOver read validation messages that weren't shown, e.g. "Name is required." under a filled-in name: a valid form keeps the message's space with a clear color, which still left it an accessibility element. The message is now also hidden from accessibility until it is shown (`validationMessage(isShown:)`, used by every form that hid its message this way: sign-up, profile, password, shop create/basic settings, item tag create/edit) - A failed sign-in's error message stayed on screen after the next attempt signed in; a new attempt now dismisses it - Debug builds lost the local API when launched other than from Xcode, e.g. from the Home Screen or when iOS relaunched the app after its language was changed in Settings: the endpoint came only from the scheme's environment variables, which only Xcode passes. It now comes from `API.xcconfig` (override it in the gitignored `API.local.xcconfig`) and is built into `Info.plist`; `NATIVEAPPTEMPLATE_API_*` environment variables still win. The scheme no longer sets them diff --git a/NativeAppTemplate.xcodeproj/project.pbxproj b/NativeAppTemplate.xcodeproj/project.pbxproj index be69182..7292d99 100644 --- a/NativeAppTemplate.xcodeproj/project.pbxproj +++ b/NativeAppTemplate.xcodeproj/project.pbxproj @@ -139,6 +139,7 @@ 4A8DA0DEF6F142C3A127058A /* PaginationMeta.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FE8A6D1D27C458389C4F61A /* PaginationMeta.swift */; }; 7249A60C06FE44338E16BC50 /* APISessionDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = C597C0551370446BB931F19B /* APISessionDelegate.swift */; }; A1B2C3D401000003 /* GlassCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D401000004 /* GlassCard.swift */; }; + A1B2C3D40F000002 /* ValidationMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D40F000001 /* ValidationMessage.swift */; }; A1B2C3D4E5F6A7B8C9D0E1F2 /* KeychainReinstallCleanup.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F6A7B8C9D0E1F3 /* KeychainReinstallCleanup.swift */; }; A2B3C4D500000002 /* CodedError.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2B3C4D500000001 /* CodedError.swift */; }; A2B3C4D500000004 /* AppError.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2B3C4D500000003 /* AppError.swift */; }; @@ -297,6 +298,7 @@ 412B1F0A34D186286A11F085 /* Keychain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Keychain.swift; sourceTree = ""; }; 99D40C223000EA09B764706B /* SaveThenDismiss.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SaveThenDismiss.swift; sourceTree = ""; }; A1B2C3D401000004 /* GlassCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlassCard.swift; sourceTree = ""; }; + A1B2C3D40F000001 /* ValidationMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ValidationMessage.swift; sourceTree = ""; }; A1B2C3D4E5F6A7B8C9D0E1F3 /* KeychainReinstallCleanup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainReinstallCleanup.swift; sourceTree = ""; }; A2B3C4D500000001 /* CodedError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodedError.swift; sourceTree = ""; }; A2B3C4D500000003 /* AppError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppError.swift; sourceTree = ""; }; @@ -761,6 +763,7 @@ A1B2C3D401000004 /* GlassCard.swift */, 01E0A5F925BD148800298D35 /* MainButtonView.swift */, 99D40C223000EA09B764706B /* SaveThenDismiss.swift */, + A1B2C3D40F000001 /* ValidationMessage.swift */, ); path = Shared; sourceTree = ""; @@ -948,6 +951,7 @@ 01D8AE8B2AB453C1009AFFBA /* ShopBasicSettingsView.swift in Sources */, 01E0A60125BD149200298D35 /* MainButtonView.swift in Sources */, E6D6F71FD22DECE8CC643108 /* SaveThenDismiss.swift in Sources */, + A1B2C3D40F000002 /* ValidationMessage.swift in Sources */, A1B2C3D401000003 /* GlassCard.swift in Sources */, 0182D39A25B4424B001E881D /* LoggedInShopkeeperKeychainStore.swift in Sources */, 01ED197B2A037B9E00CD4735 /* AppTabView.swift in Sources */, diff --git a/NativeAppTemplate/UI/App Root/SignUpView.swift b/NativeAppTemplate/UI/App Root/SignUpView.swift index fb69f5f..bfe632f 100644 --- a/NativeAppTemplate/UI/App Root/SignUpView.swift +++ b/NativeAppTemplate/UI/App Root/SignUpView.swift @@ -50,7 +50,7 @@ private extension SignUpView { } footer: { Text(Strings.fullNameIsRequired) .font(.caption) - .foregroundStyle(viewModel.isNameBlank ? .validationError : .clear) + .validationMessage(isShown: viewModel.isNameBlank) } Section { diff --git a/NativeAppTemplate/UI/Settings/PasswordEditView.swift b/NativeAppTemplate/UI/Settings/PasswordEditView.swift index 3c058e9..cbae010 100644 --- a/NativeAppTemplate/UI/Settings/PasswordEditView.swift +++ b/NativeAppTemplate/UI/Settings/PasswordEditView.swift @@ -52,7 +52,7 @@ private extension PasswordEditView { Text(Strings.weNeedYourCurrentPassword) .font(.uiFootnote) Text(Strings.currentPasswordIsRequired) - .foregroundStyle(viewModel.currentPassword.isBlank ? .validationError : .clear) + .validationMessage(isShown: viewModel.currentPassword.isBlank) .font(.uiFootnote) } } @@ -91,10 +91,8 @@ private extension PasswordEditView { ? Strings.passwordsDoNotMatch : Strings.confirmNewPasswordIsRequired) .font(.uiFootnote) - .foregroundStyle( - viewModel.passwordConfirmation.isBlank || viewModel.hasPasswordConfirmationMismatch - ? .validationError - : .clear + .validationMessage( + isShown: viewModel.passwordConfirmation.isBlank || viewModel.hasPasswordConfirmationMismatch ) } } diff --git a/NativeAppTemplate/UI/Settings/ShopkeeperEditView.swift b/NativeAppTemplate/UI/Settings/ShopkeeperEditView.swift index f76c24b..4457ffe 100644 --- a/NativeAppTemplate/UI/Settings/ShopkeeperEditView.swift +++ b/NativeAppTemplate/UI/Settings/ShopkeeperEditView.swift @@ -47,7 +47,7 @@ private extension ShopkeeperEditView { Text(Strings.fullName) } footer: { Text(Strings.fullNameIsRequired) - .foregroundStyle(viewModel.name.isBlank ? .validationError : .clear) + .validationMessage(isShown: viewModel.name.isBlank) } Section { diff --git a/NativeAppTemplate/UI/Shared/ValidationMessage.swift b/NativeAppTemplate/UI/Shared/ValidationMessage.swift new file mode 100644 index 0000000..fa05216 --- /dev/null +++ b/NativeAppTemplate/UI/Shared/ValidationMessage.swift @@ -0,0 +1,29 @@ +// +// ValidationMessage.swift +// NativeAppTemplate +// + +import SwiftUI + +/// A validation message that keeps its space in the layout while it is not shown. +/// +/// Hiding it with `.clear` alone leaves an invisible accessibility element, so +/// VoiceOver would read an error that isn't there; it is also hidden from +/// accessibility while not shown. +struct ValidationMessageModifier: ViewModifier { + let isShown: Bool + + func body(content: Content) -> some View { + content + .foregroundStyle(isShown ? Color.validationError : Color.clear) + .accessibilityHidden(!isShown) + } +} + +extension View { + /// Shows this text as a validation error when `isShown`; otherwise keeps its + /// space but hides it, from VoiceOver too. + func validationMessage(isShown: Bool) -> some View { + modifier(ValidationMessageModifier(isShown: isShown)) + } +} diff --git a/NativeAppTemplate/UI/Shop List/ShopCreateView.swift b/NativeAppTemplate/UI/Shop List/ShopCreateView.swift index bb571d5..ea739de 100644 --- a/NativeAppTemplate/UI/Shop List/ShopCreateView.swift +++ b/NativeAppTemplate/UI/Shop List/ShopCreateView.swift @@ -47,7 +47,7 @@ struct ShopCreateView: View { .font(.uiFootnote) Text(Strings.shopNameIsInvalid) .font(.uiFootnote) - .foregroundStyle(viewModel.hasInvalidDataName ? .validationError : .clear) + .validationMessage(isShown: viewModel.hasInvalidDataName) } } @@ -65,7 +65,7 @@ struct ShopCreateView: View { .font(.uiFootnote) Text(Strings.shopDescriptionIsInvalid) .font(.uiFootnote) - .foregroundStyle(viewModel.hasInvalidDataDescription ? .validationError : .clear) + .validationMessage(isShown: viewModel.hasInvalidDataDescription) } } diff --git a/NativeAppTemplate/UI/Shop Settings/ItemTag Detail/ItemTagEditView.swift b/NativeAppTemplate/UI/Shop Settings/ItemTag Detail/ItemTagEditView.swift index 0a24769..3d0c74f 100644 --- a/NativeAppTemplate/UI/Shop Settings/ItemTag Detail/ItemTagEditView.swift +++ b/NativeAppTemplate/UI/Shop Settings/ItemTag Detail/ItemTagEditView.swift @@ -57,7 +57,7 @@ private extension ItemTagEditView { .font(.uiFootnote) Text(Strings.itemTagNameIsInvalid) .font(.uiFootnote) - .foregroundStyle(viewModel.hasInvalidDataName ? .validationError : .clear) + .validationMessage(isShown: viewModel.hasInvalidDataName) } } @@ -75,7 +75,7 @@ private extension ItemTagEditView { .font(.uiFootnote) Text(Strings.itemTagDescriptionIsInvalid) .font(.uiFootnote) - .foregroundStyle(viewModel.hasInvalidDataDescription ? .validationError : .clear) + .validationMessage(isShown: viewModel.hasInvalidDataDescription) } } } diff --git a/NativeAppTemplate/UI/Shop Settings/ItemTag List/ItemTagCreateView.swift b/NativeAppTemplate/UI/Shop Settings/ItemTag List/ItemTagCreateView.swift index 1152413..383e3d6 100644 --- a/NativeAppTemplate/UI/Shop Settings/ItemTag List/ItemTagCreateView.swift +++ b/NativeAppTemplate/UI/Shop Settings/ItemTag List/ItemTagCreateView.swift @@ -54,7 +54,7 @@ private extension ItemTagCreateView { .font(.uiFootnote) Text(Strings.itemTagNameIsInvalid) .font(.uiFootnote) - .foregroundStyle(viewModel.hasInvalidDataName ? .validationError : .clear) + .validationMessage(isShown: viewModel.hasInvalidDataName) } } @@ -72,7 +72,7 @@ private extension ItemTagCreateView { .font(.uiFootnote) Text(Strings.itemTagDescriptionIsInvalid) .font(.uiFootnote) - .foregroundStyle(viewModel.hasInvalidDataDescription ? .validationError : .clear) + .validationMessage(isShown: viewModel.hasInvalidDataDescription) } } } diff --git a/NativeAppTemplate/UI/Shop Settings/ShopBasicSettingsView.swift b/NativeAppTemplate/UI/Shop Settings/ShopBasicSettingsView.swift index 63ae784..22f5c6f 100644 --- a/NativeAppTemplate/UI/Shop Settings/ShopBasicSettingsView.swift +++ b/NativeAppTemplate/UI/Shop Settings/ShopBasicSettingsView.swift @@ -56,7 +56,7 @@ private extension ShopBasicSettingsView { .font(.uiFootnote) Text(Strings.shopNameIsInvalid) .font(.uiFootnote) - .foregroundStyle(viewModel.hasInvalidDataName ? .validationError : .clear) + .validationMessage(isShown: viewModel.hasInvalidDataName) } } @@ -74,7 +74,7 @@ private extension ShopBasicSettingsView { .font(.uiFootnote) Text(Strings.shopDescriptionIsInvalid) .font(.uiFootnote) - .foregroundStyle(viewModel.hasInvalidDataDescription ? .validationError : .clear) + .validationMessage(isShown: viewModel.hasInvalidDataDescription) } } diff --git a/NativeAppTemplateTests/UI/Shared/ValidationMessageTest.swift b/NativeAppTemplateTests/UI/Shared/ValidationMessageTest.swift new file mode 100644 index 0000000..2011945 --- /dev/null +++ b/NativeAppTemplateTests/UI/Shared/ValidationMessageTest.swift @@ -0,0 +1,94 @@ +// +// ValidationMessageTest.swift +// NativeAppTemplate +// + +@testable import NativeAppTemplate +import SwiftUI +import Testing +import UIKit + +/// A validation message hidden with `.clear` alone was still an accessibility +/// element, so VoiceOver read "Name is required." on a valid form. +/// `validationMessage(isShown:)` must keep it out of the accessibility tree +/// until it is shown. +@MainActor +struct ValidationMessageTest { + // Both strings are made up for the test; the sibling label proves the walk + // below reaches SwiftUI's accessibility elements at all. + private let siblingLabel = "Sibling label" + private let messageText = "Name is required." + + @Test + func hiddenMessageIsNotAnAccessibilityElement() throws { + let labels = try accessibilityLabels(isShown: false) + + #expect(labels.contains(siblingLabel)) + #expect(!labels.contains(messageText)) + } + + @Test + func shownMessageIsAnAccessibilityElement() throws { + let labels = try accessibilityLabels(isShown: true) + + #expect(labels.contains(siblingLabel)) + #expect(labels.contains(messageText)) + } + + // MARK: - Helpers + + /// Hosts the message next to a visible text in a window, lays it out and + /// returns the labels of every accessibility element VoiceOver could reach. + private func accessibilityLabels(isShown: Bool) throws -> [String] { + try Self.setAccessibilityEnabled(true) + defer { try? Self.setAccessibilityEnabled(false) } + let view = VStack { + Text(siblingLabel) + Text(messageText) + .validationMessage(isShown: isShown) + } + let controller = UIHostingController(rootView: view) + // Tests run inside the app, so its window scene hosts the test window. + let scene = try #require( + UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first + ) + let window = UIWindow(windowScene: scene) + window.rootViewController = controller + window.makeKeyAndVisible() + controller.view.setNeedsLayout() + controller.view.layoutIfNeeded() + defer { window.isHidden = true } + // Let SwiftUI publish the accessibility tree after the update. + RunLoop.main.run(until: Date().addingTimeInterval(0.1)) + + var labels: [String] = [] + collectLabels(from: controller.view, into: &labels) + return labels + } + + /// SwiftUI builds its accessibility elements only while an assistive + /// technology is on, so the test turns accessibility on for this process + /// the way XCUITest and VoiceOver do (libAccessibility, part of iOS). + private static func setAccessibilityEnabled(_ enabled: Bool) throws { + let handle = try #require(dlopen("/usr/lib/libAccessibility.dylib", RTLD_NOW)) + let symbol = try #require(dlsym(handle, "_AXSApplicationAccessibilitySetEnabled")) + typealias SetEnabled = @convention(c) (Bool) -> Void + unsafeBitCast(symbol, to: SetEnabled.self)(enabled) + } + + private func collectLabels(from element: NSObject, into labels: inout [String]) { + if element.isAccessibilityElement, let label = element.accessibilityLabel, !label.isEmpty { + labels.append(label) + } + let children: [NSObject] = if let elements = element.accessibilityElements as? [NSObject] { + elements + } else if let view = element as? UIView { + view.subviews + } else { + [] + } + for child in children { + collectLabels(from: child, into: &labels) + } + } +}