diff --git a/CHANGELOG.md b/CHANGELOG.md index ba9c63a..b2d86bb 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 +- 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 diff --git a/NativeAppTemplate/Constants.swift b/NativeAppTemplate/Constants.swift index 204c8ae..008f03a 100644 --- a/NativeAppTemplate/Constants.swift +++ b/NativeAppTemplate/Constants.swift @@ -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") diff --git a/NativeAppTemplate/Localizable.xcstrings b/NativeAppTemplate/Localizable.xcstrings index cbc8cd5..489ebb2 100644 --- a/NativeAppTemplate/Localizable.xcstrings +++ b/NativeAppTemplate/Localizable.xcstrings @@ -237,6 +237,16 @@ } } }, + "Close" : { + "localizations" : { + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "閉じる" + } + } + } + }, "Complete" : { "localizations" : { "ja" : { diff --git a/NativeAppTemplate/UI/App Root/AppTabView.swift b/NativeAppTemplate/UI/App Root/AppTabView.swift index afa5052..2c2381d 100644 --- a/NativeAppTemplate/UI/App Root/AppTabView.swift +++ b/NativeAppTemplate/UI/App Root/AppTabView.swift @@ -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?, @@ -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)) } } diff --git a/NativeAppTemplate/UI/App Root/MainView.swift b/NativeAppTemplate/UI/App Root/MainView.swift index 74d0dfe..5d817cf 100644 --- a/NativeAppTemplate/UI/App Root/MainView.swift +++ b/NativeAppTemplate/UI/App Root/MainView.swift @@ -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: { @@ -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: diff --git a/NativeAppTemplate/UI/App Root/MainViewModel.swift b/NativeAppTemplate/UI/App Root/MainViewModel.swift index b69274f..19f78f0 100644 --- a/NativeAppTemplate/UI/App Root/MainViewModel.swift +++ b/NativeAppTemplate/UI/App Root/MainViewModel.swift @@ -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 diff --git a/NativeAppTemplate/UI/App Root/SnackbarView.swift b/NativeAppTemplate/UI/App Root/SnackbarView.swift index acc07ff..ae5de49 100644 --- a/NativeAppTemplate/UI/App Root/SnackbarView.swift +++ b/NativeAppTemplate/UI/App Root/SnackbarView.swift @@ -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) diff --git a/NativeAppTemplateTests/Testing/AccessibilityElements.swift b/NativeAppTemplateTests/Testing/AccessibilityElements.swift new file mode 100644 index 0000000..64cdebd --- /dev/null +++ b/NativeAppTemplateTests/Testing/AccessibilityElements.swift @@ -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) + } + } +} diff --git a/NativeAppTemplateTests/UI/App Root/MainContentTest.swift b/NativeAppTemplateTests/UI/App Root/MainContentTest.swift index 2936465..f3fb96a 100644 --- a/NativeAppTemplateTests/UI/App Root/MainContentTest.swift +++ b/NativeAppTemplateTests/UI/App Root/MainContentTest.swift @@ -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 }) + } } diff --git a/NativeAppTemplateTests/UI/App Root/SnackbarViewTest.swift b/NativeAppTemplateTests/UI/App Root/SnackbarViewTest.swift new file mode 100644 index 0000000..a0f37ec --- /dev/null +++ b/NativeAppTemplateTests/UI/App Root/SnackbarViewTest.swift @@ -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)) + } +} diff --git a/NativeAppTemplateTests/UI/Shared/ValidationMessageTest.swift b/NativeAppTemplateTests/UI/Shared/ValidationMessageTest.swift index 2011945..7dbee67 100644 --- a/NativeAppTemplateTests/UI/Shared/ValidationMessageTest.swift +++ b/NativeAppTemplateTests/UI/Shared/ValidationMessageTest.swift @@ -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. @@ -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) } }