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
- The message bar no longer covers the tab bar and blocks tab taps until it is closed: with the tabs showing, it sits above the tab bar. Its close button is a labeled "Close" button (new string, Japanese 閉じる) with a 32 pt target (the symbol stays 16 pt); the tabs' accessibility label no longer replaces the message's labels
- 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
1 change: 1 addition & 0 deletions NativeAppTemplate/Constants.swift
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,7 @@ enum Strings {

static let ok = String(localized: "OK")
static let cancel = String(localized: "Cancel")
static let close = String(localized: "Close")
static let save = String(localized: "Save")
static let edit = String(localized: "Edit")
static let delete = String(localized: "Delete")
Expand Down
10 changes: 10 additions & 0 deletions NativeAppTemplate/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,16 @@
}
}
},
"Close" : {
"localizations" : {
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "閉じる"
}
}
}
},
"Complete" : {
"localizations" : {
"ja" : {
Expand Down
27 changes: 24 additions & 3 deletions NativeAppTemplate/UI/App Root/AppTabView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -84,12 +84,23 @@ struct AppTabView_Previews: PreviewProvider {
AppTabView(
shopListView: { Text(verbatim: "SHOPS") },
settingsView: { Text(verbatim: "SETTINGS") }
).environment(TabViewModel())
)
.environment(TabViewModel())
.environment(MessageBus())
}
}

// MARK: - private

/// The app's message bar, in a tab
private struct TabMessageBarView: View {
@Environment(MessageBus.self) private var messageBus

var body: some View {
MessageBarView(messageBus: messageBus)
}
}

@MainActor private func tab(
content: @escaping () -> some View,
navigationPath: Binding<NavigationPath>?,
Expand All @@ -99,21 +110,31 @@ struct AppTabView_Previews: PreviewProvider {
) -> some View {
if let navigationPath {
NavigationStack(path: navigationPath, root: content)
// Before the overlay: applied after it, it would replace the message's labels
.accessibility(label: .init(text))
// Inside the tab, so the bar sits above the tab bar instead of covering it
.overlay(alignment: .bottom) {
TabMessageBarView()
}
.tabItem {
Text(text)
Image(systemName: imageName)
}
.tag(tab)
.environment(\.mainTab, tab)
.accessibility(label: .init(text))
} else {
NavigationStack(root: content)
// Before the overlay: applied after it, it would replace the message's labels
.accessibility(label: .init(text))
// Inside the tab, so the bar sits above the tab bar instead of covering it
.overlay(alignment: .bottom) {
TabMessageBarView()
}
.tabItem {
Text(text)
Image(systemName: imageName)
}
.tag(tab)
.environment(\.mainTab, tab)
.accessibility(label: .init(text))
}
}
16 changes: 12 additions & 4 deletions NativeAppTemplate/UI/App Root/MainView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,11 @@ struct MainView: View {
ZStack {
contentView
.background(Color.backgroundColor)
.overlay(MessageBarView(messageBus: messageBus), alignment: .bottom)
.overlay(alignment: .bottom) {
if !content.hasTabBar {
MessageBarView(messageBus: messageBus)
}
}
.alert(Strings.sessionExpired, isPresented: sessionExpiredAlertBinding) {
Button(Strings.ok) {}
} message: {
Expand Down Expand Up @@ -53,11 +57,15 @@ private extension MainView {
)
}

@ViewBuilder var contentView: some View {
switch MainViewModel.content(
var content: MainContent {
MainViewModel.content(
sessionController: sessionController,
isRebuildingRepositories: dataManager.isRebuildingRepositories
) {
)
}

@ViewBuilder var contentView: some View {
switch content {
case .onboarding:
OnboardingView()
case .acceptPrivacy:
Expand Down
11 changes: 11 additions & 0 deletions NativeAppTemplate/UI/App Root/MainViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,17 @@ enum MainContent: Equatable {
case rebuildingRepositories
case needsAppUpdate
case tabs

/// Shown in `AppTabView`. Its tabs show the message bar themselves, above the tab
/// bar, so a message doesn't cover the tabs until it is closed.
var hasTabBar: Bool {
switch self {
case .offline, .rebuildingRepositories, .needsAppUpdate, .tabs:
true
case .onboarding, .acceptPrivacy, .acceptTerms, .permissionsLoading, .permissionsError, .sessionLoading:
false
}
}
}

@Observable
Expand Down
4 changes: 4 additions & 0 deletions NativeAppTemplate/UI/App Root/SnackbarView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,12 @@ struct SnackbarView: View {
Image(systemName: "xmark")
.resizable()
.frame(width: NativeAppTemplateConstants.Spacing.sm, height: NativeAppTemplateConstants.Spacing.sm)
// The symbol stays small; the area to tap is larger
.frame(width: NativeAppTemplateConstants.Spacing.lg, height: NativeAppTemplateConstants.Spacing.lg)
.contentShape(.rect)
}
.foregroundStyle(.snackText)
.accessibilityLabel(Strings.close)
}
.padding(.vertical, NativeAppTemplateConstants.Spacing.sm)
.padding(.horizontal, NativeAppTemplateConstants.Spacing.md)
Expand Down
91 changes: 91 additions & 0 deletions NativeAppTemplateTests/Testing/AccessibilityElements.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
//
// AccessibilityElements.swift
// NativeAppTemplate
//

import SwiftUI
import Testing
import UIKit

/// What VoiceOver, XCUITest and WebDriverAgent can reach in a SwiftUI view
@MainActor
enum AccessibilityElements {
struct Element {
let label: String
let traits: UIAccessibilityTraits
/// In screen coordinates
let frame: CGRect
}

/// Hosts `view` in a window, lays it out and returns every accessibility element
/// with a label
static func of(_ view: some View) throws -> [Element] {
try beginAccessibility()
defer { try? endAccessibility() }
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 elements: [Element] = []
collect(from: controller.view, into: &elements)
return elements
}

/// Suites run in parallel, and the run loop below lets another test's call start
/// and end in the middle of this one; accessibility stays on until the last ends.
private static var accessibilityUsers = 0

private static func beginAccessibility() throws {
if accessibilityUsers == 0 {
try setAccessibilityEnabled(true)
}
accessibilityUsers += 1
}

private static func endAccessibility() throws {
accessibilityUsers -= 1
if accessibilityUsers == 0 {
try setAccessibilityEnabled(false)
}
}

/// 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 static func collect(from element: NSObject, into elements: inout [Element]) {
if element.isAccessibilityElement, let label = element.accessibilityLabel, !label.isEmpty {
elements.append(Element(
label: label,
traits: element.accessibilityTraits,
frame: element.accessibilityFrame
))
}
let children: [NSObject] = if let accessibilityElements = element.accessibilityElements as? [NSObject] {
accessibilityElements
} else if let view = element as? UIView {
view.subviews
} else {
[]
}
for child in children {
collect(from: child, into: &elements)
}
}
}
15 changes: 15 additions & 0 deletions NativeAppTemplateTests/UI/App Root/MainContentTest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -117,4 +117,19 @@ struct MainContentTest {

#expect(content() == .needsAppUpdate)
}

/// MainView shows the message bar over the whole screen only without a tab bar;
/// with one, each tab shows it above the tab bar (it covered the tab bar and
/// blocked tab taps until closed). The cases with a tab bar are the ones
/// MainView.contentView shows in AppTabView.
@Test
func contentInAppTabViewHasATabBar() {
let withTabBar: [MainContent] = [.offline, .rebuildingRepositories, .needsAppUpdate, .tabs]
let withoutTabBar: [MainContent] = [
.onboarding, .acceptPrivacy, .acceptTerms, .permissionsLoading, .permissionsError, .sessionLoading
]

#expect(withTabBar.allSatisfy { $0.hasTabBar })
#expect(!withoutTabBar.contains { $0.hasTabBar })
}
}
51 changes: 51 additions & 0 deletions NativeAppTemplateTests/UI/App Root/SnackbarViewTest.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
//
// SnackbarViewTest.swift
// NativeAppTemplate
//

@testable import NativeAppTemplate
import SwiftUI
import Testing

/// The snackbar's close button, as VoiceOver and UI automation reach it. On the
/// same view in another app, a WebDriverAgent dump of a message listed its text but
/// no close button, and the button was a 16 pt square (the symbol's frame,
/// NativeAppTemplateConstants.Spacing.sm).
@MainActor
struct SnackbarViewTest {
private let message = "Something went wrong."

private func closeButton() throws -> AccessibilityElements.Element? {
try AccessibilityElements.of(
SnackbarView(state: SnackbarState(status: .warning, message: message), visible: .constant(true))
).first { $0.traits.contains(.button) }
}

/// A button named "Close" (Strings.close; the tests run in English)
@Test
func closeButtonIsALabeledButton() throws {
let button = try #require(try closeButton())

#expect(button.label == "Close")
}

/// At least NativeAppTemplateConstants.Spacing.lg (32 pt) square to tap, while the
/// symbol stays 16 pt
@Test
func closeButtonIsLargeEnoughToTap() throws {
let button = try #require(try closeButton())

#expect(button.frame.width >= NativeAppTemplateConstants.Spacing.lg)
#expect(button.frame.height >= NativeAppTemplateConstants.Spacing.lg)
}

/// The message itself is still read
@Test
func messageIsRead() throws {
let labels = try AccessibilityElements.of(
SnackbarView(state: SnackbarState(status: .warning, message: message), visible: .constant(true))
).map(\.label)

#expect(labels.contains(message))
}
}
61 changes: 8 additions & 53 deletions NativeAppTemplateTests/UI/Shared/ValidationMessageTest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
@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.
Expand Down Expand Up @@ -37,58 +36,14 @@ struct ValidationMessageTest {

// 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.
/// The labels of every accessibility element next to the message and in it
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)
}
try AccessibilityElements.of(
VStack {
Text(siblingLabel)
Text(messageText)
.validationMessage(isShown: isShown)
}
).map(\.label)
}
}
Loading