Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 4 additions & 0 deletions NativeAppTemplate.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -297,6 +298,7 @@
412B1F0A34D186286A11F085 /* Keychain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Keychain.swift; sourceTree = "<group>"; };
99D40C223000EA09B764706B /* SaveThenDismiss.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SaveThenDismiss.swift; sourceTree = "<group>"; };
A1B2C3D401000004 /* GlassCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlassCard.swift; sourceTree = "<group>"; };
A1B2C3D40F000001 /* ValidationMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ValidationMessage.swift; sourceTree = "<group>"; };
A1B2C3D4E5F6A7B8C9D0E1F3 /* KeychainReinstallCleanup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainReinstallCleanup.swift; sourceTree = "<group>"; };
A2B3C4D500000001 /* CodedError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodedError.swift; sourceTree = "<group>"; };
A2B3C4D500000003 /* AppError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppError.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -761,6 +763,7 @@
A1B2C3D401000004 /* GlassCard.swift */,
01E0A5F925BD148800298D35 /* MainButtonView.swift */,
99D40C223000EA09B764706B /* SaveThenDismiss.swift */,
A1B2C3D40F000001 /* ValidationMessage.swift */,
);
path = Shared;
sourceTree = "<group>";
Expand Down Expand Up @@ -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 */,
Expand Down
2 changes: 1 addition & 1 deletion NativeAppTemplate/UI/App Root/SignUpView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ private extension SignUpView {
} footer: {
Text(Strings.fullNameIsRequired)
.font(.caption)
.foregroundStyle(viewModel.isNameBlank ? .validationError : .clear)
.validationMessage(isShown: viewModel.isNameBlank)
}

Section {
Expand Down
8 changes: 3 additions & 5 deletions NativeAppTemplate/UI/Settings/PasswordEditView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Expand Down Expand Up @@ -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
)
}
}
Expand Down
2 changes: 1 addition & 1 deletion NativeAppTemplate/UI/Settings/ShopkeeperEditView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
29 changes: 29 additions & 0 deletions NativeAppTemplate/UI/Shared/ValidationMessage.swift
Original file line number Diff line number Diff line change
@@ -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))
}
}
4 changes: 2 additions & 2 deletions NativeAppTemplate/UI/Shop List/ShopCreateView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ struct ShopCreateView: View {
.font(.uiFootnote)
Text(Strings.shopNameIsInvalid)
.font(.uiFootnote)
.foregroundStyle(viewModel.hasInvalidDataName ? .validationError : .clear)
.validationMessage(isShown: viewModel.hasInvalidDataName)
}
}

Expand All @@ -65,7 +65,7 @@ struct ShopCreateView: View {
.font(.uiFootnote)
Text(Strings.shopDescriptionIsInvalid)
.font(.uiFootnote)
.foregroundStyle(viewModel.hasInvalidDataDescription ? .validationError : .clear)
.validationMessage(isShown: viewModel.hasInvalidDataDescription)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ private extension ItemTagEditView {
.font(.uiFootnote)
Text(Strings.itemTagNameIsInvalid)
.font(.uiFootnote)
.foregroundStyle(viewModel.hasInvalidDataName ? .validationError : .clear)
.validationMessage(isShown: viewModel.hasInvalidDataName)
}
}

Expand All @@ -75,7 +75,7 @@ private extension ItemTagEditView {
.font(.uiFootnote)
Text(Strings.itemTagDescriptionIsInvalid)
.font(.uiFootnote)
.foregroundStyle(viewModel.hasInvalidDataDescription ? .validationError : .clear)
.validationMessage(isShown: viewModel.hasInvalidDataDescription)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ private extension ItemTagCreateView {
.font(.uiFootnote)
Text(Strings.itemTagNameIsInvalid)
.font(.uiFootnote)
.foregroundStyle(viewModel.hasInvalidDataName ? .validationError : .clear)
.validationMessage(isShown: viewModel.hasInvalidDataName)
}
}

Expand All @@ -72,7 +72,7 @@ private extension ItemTagCreateView {
.font(.uiFootnote)
Text(Strings.itemTagDescriptionIsInvalid)
.font(.uiFootnote)
.foregroundStyle(viewModel.hasInvalidDataDescription ? .validationError : .clear)
.validationMessage(isShown: viewModel.hasInvalidDataDescription)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ private extension ShopBasicSettingsView {
.font(.uiFootnote)
Text(Strings.shopNameIsInvalid)
.font(.uiFootnote)
.foregroundStyle(viewModel.hasInvalidDataName ? .validationError : .clear)
.validationMessage(isShown: viewModel.hasInvalidDataName)
}
}

Expand All @@ -74,7 +74,7 @@ private extension ShopBasicSettingsView {
.font(.uiFootnote)
Text(Strings.shopDescriptionIsInvalid)
.font(.uiFootnote)
.foregroundStyle(viewModel.hasInvalidDataDescription ? .validationError : .clear)
.validationMessage(isShown: viewModel.hasInvalidDataDescription)
}
}

Expand Down
94 changes: 94 additions & 0 deletions NativeAppTemplateTests/UI/Shared/ValidationMessageTest.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
Loading