diff --git a/Package.swift b/Package.swift index 340b1a2..9b618ea 100644 --- a/Package.swift +++ b/Package.swift @@ -28,7 +28,7 @@ let package = Package( .library(name: "SpeziValidation", targets: ["SpeziValidation"]) ], dependencies: [ - .package(url: "https://github.com/StanfordSpezi/Spezi.git", from: "1.3.0"), + .package(url: "https://github.com/StanfordSpezi/Spezi.git", from: "1.8.0"), .package(url: "https://github.com/apple/swift-collections.git", from: "1.1.0"), .package(url: "https://github.com/pointfreeco/swift-snapshot-testing.git", from: "1.17.0") ] + swiftLintPackage(), diff --git a/Sources/SpeziValidation/ValidationEngine.swift b/Sources/SpeziValidation/ValidationEngine.swift index 776995f..5b89ec2 100644 --- a/Sources/SpeziValidation/ValidationEngine.swift +++ b/Sources/SpeziValidation/ValidationEngine.swift @@ -17,7 +17,8 @@ import SwiftUI /// processed input and a the respective recovery suggestions for failed ``ValidationRule``s. /// The state of the `ValidationEngine` is updated on each invocation of ``runValidation(input:)`` or ``submit(input:debounce:)``. @Observable -public class ValidationEngine: Identifiable { +@MainActor +public final class ValidationEngine: Identifiable { /// Determines the source of the last validation run. private enum Source: Equatable { /// The last validation was run due to change in text field or keyboard submit. @@ -31,7 +32,7 @@ public class ValidationEngine: Identifiable { /// Unique identifier for this validation engine. - public var id: ObjectIdentifier { + public nonisolated var id: ObjectIdentifier { ObjectIdentifier(self) } @@ -134,7 +135,6 @@ public class ValidationEngine: Identifiable { self.init(rules: validationRules, debounceFor: debounceDuration, configuration: configuration) } - @MainActor private func computeFailedValidations(input: String) -> [FailedValidationResult] { var results: [FailedValidationResult] = [] @@ -153,7 +153,6 @@ public class ValidationEngine: Identifiable { } - @MainActor private func computeValidation(input: String, source: Source) { self.source = source self.inputWasEmpty = input.isEmpty @@ -174,7 +173,6 @@ public class ValidationEngine: Identifiable { /// there not further calls to this method for the configured `debounceDuration`. If set to `false` the method /// will run immediately. Note that the validation will still run instantly, if we are currently in an invalid state /// to ensure input validity is reported immediately. - @MainActor public func submit(input: String, debounce: Bool = false) { if !debounce || computedInputValid == false { // we compute instantly, if debounce is false or if we are in a invalid state @@ -191,12 +189,10 @@ public class ValidationEngine: Identifiable { /// /// The input is considered valid if all ``ValidationRule``s succeed. /// - Parameter input: The input to validate. - @MainActor public func runValidation(input: String) { computeValidation(input: input, source: .manual) } - @MainActor private func debounce(_ task: @escaping () -> Void) { debounceTask = Task { try? await Task.sleep(for: debounceDuration) diff --git a/Sources/SpeziValidation/ValidationState/CapturedValidationState.swift b/Sources/SpeziValidation/ValidationState/CapturedValidationState.swift index c3a52c0..96dee1d 100644 --- a/Sources/SpeziValidation/ValidationState/CapturedValidationState.swift +++ b/Sources/SpeziValidation/ValidationState/CapturedValidationState.swift @@ -16,9 +16,10 @@ import SwiftUI /// /// This particularly allows to run a validation from the outside of a view. @dynamicMemberLookup +@MainActor public struct CapturedValidationState { - private let engine: ValidationEngine - private let input: String + private nonisolated let engine: ValidationEngine + private nonisolated let input: String private let focusState: FocusState.Binding init(engine: ValidationEngine, input: String, focus focusState: FocusState.Binding) { @@ -46,8 +47,8 @@ public struct CapturedValidationState { } -extension CapturedValidationState: Equatable { - public static func == (lhs: CapturedValidationState, rhs: CapturedValidationState) -> Bool { +extension CapturedValidationState: Equatable, Sendable { + public static nonisolated func == (lhs: CapturedValidationState, rhs: CapturedValidationState) -> Bool { lhs.engine === rhs.engine && lhs.input == rhs.input } } diff --git a/Sources/SpeziValidation/ValidationState/ReceiveValidationModifier.swift b/Sources/SpeziValidation/ValidationState/ReceiveValidationModifier.swift index 0c36ee4..9dc3ee0 100644 --- a/Sources/SpeziValidation/ValidationState/ReceiveValidationModifier.swift +++ b/Sources/SpeziValidation/ValidationState/ReceiveValidationModifier.swift @@ -9,6 +9,16 @@ import SwiftUI +@MainActor +private struct IsolatedValidationBinding: Sendable { + let state: ValidationState.Binding + + init(_ state: ValidationState.Binding) { + self.state = state + } +} + + /// Provide access to validation state to the parent view. /// /// The internal preference key to provide parent views access to all configured ``ValidationEngine`` and input @@ -36,8 +46,12 @@ extension View { /// - Parameter state: The binding to the ``ValidationState``. /// - Returns: The modified view. public func receiveValidation(in state: ValidationState.Binding) -> some View { - onPreferenceChange(CapturedValidationStateKey.self) { entries in - state.wrappedValue = ValidationContext(entries: entries) + let binding = IsolatedValidationBinding(state) + + return onPreferenceChange(CapturedValidationStateKey.self) { entries in + Task { @Sendable @MainActor in + binding.state.wrappedValue = ValidationContext(entries: entries) + } } } } diff --git a/Sources/SpeziViews/SpeziViews.docc/SpeziViews.md b/Sources/SpeziViews/SpeziViews.docc/SpeziViews.md index 0363514..e4e331c 100644 --- a/Sources/SpeziViews/SpeziViews.docc/SpeziViews.md +++ b/Sources/SpeziViews/SpeziViews.docc/SpeziViews.md @@ -58,12 +58,14 @@ Default layouts and utilities to automatically adapt your view layouts to dynami - ``DynamicHStack`` - ``ListRow`` - ``DescriptionGridRow`` +- ``ListHeader`` ### Controls - ``AsyncButton`` - ``SwiftUICore/EnvironmentValues/processingDebounceDuration`` - ``CanvasView`` +- ``InfoButton`` - ``DismissButton`` - ``CaseIterablePicker`` diff --git a/Sources/SpeziViews/Views/Button/InfoButton.swift b/Sources/SpeziViews/Views/Button/InfoButton.swift new file mode 100644 index 0000000..dde6133 --- /dev/null +++ b/Sources/SpeziViews/Views/Button/InfoButton.swift @@ -0,0 +1,69 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import SwiftUI + + +/// Icon-only info button. +/// +/// You can use this button, e.g., on the trailing side of a list row to provide additional information about an entity. +public struct InfoButton: View { + private let label: Text + private let action: () -> Void + + public var body: some View { + Button(action: action) { + SwiftUI.Label { + label + } icon: { + Image(systemName: "info.circle") // swiftlint:disable:this accessibility_label_for_image + } + } + .labelStyle(.iconOnly) + .font(.title3) + .foregroundColor(.accentColor) + .buttonStyle(.borderless) // ensure button is clickable next to the other button + .accessibilityIdentifier("info-button") + .accessibilityAction(named: label, action) + } + + /// Create a new info button. + /// - Parameters: + /// - label: The text label. This is not shown but useful for accessibility. + /// - action: The button action. + public init(_ label: Text, action: @escaping () -> Void) { + self.label = label + self.action = action + } + + /// Create a new info button. + /// - Parameters: + /// - resource: The localized button label. This is not shown but useful for accessibility. + /// - action: The button action. + public init(_ resource: LocalizedStringResource, action: @escaping () -> Void) { + self.label = Text(resource) + self.action = action + } +} + + +#if DEBUG +#Preview { + List { + Button { + print("Primary") + } label: { + ListRow("Entry") { + InfoButton("Entry Info") { + print("Info") + } + } + } + } +} +#endif diff --git a/Sources/SpeziViews/Views/Controls/CaseIterablePicker.swift b/Sources/SpeziViews/Views/Controls/CaseIterablePicker.swift index f69d42a..709ba2c 100644 --- a/Sources/SpeziViews/Views/Controls/CaseIterablePicker.swift +++ b/Sources/SpeziViews/Views/Controls/CaseIterablePicker.swift @@ -142,7 +142,7 @@ extension CaseIterablePicker where Value: AnyOptional { // swiftlint:disable:thi /// Create a new case-iterable picker. /// - Parameters: /// - titleKey: The picker label. - /// - value: The value binding. + /// - selection: The value binding. public init(_ titleKey: LocalizedStringResource, selection: Binding) where Label == Text { self.init(selection: selection) { Text(titleKey) diff --git a/Sources/SpeziViews/Views/List/ListHeader.swift b/Sources/SpeziViews/Views/List/ListHeader.swift new file mode 100644 index 0000000..eadada1 --- /dev/null +++ b/Sources/SpeziViews/Views/List/ListHeader.swift @@ -0,0 +1,105 @@ +// +// This source file is part of the Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import SwiftUI + + +/// Header view for Lists or Forms. +/// +/// A header view that can be used in List or Form views. +public struct ListHeader: View { + private let image: Image + private let title: Title + private let instructions: Instructions + + + public var body: some View { + VStack { + VStack { + image + .foregroundColor(.accentColor) + .symbolRenderingMode(.multicolor) + .font(.custom("XXL", size: 50, relativeTo: .title)) + .accessibilityHidden(true) + title + .accessibilityAddTraits(.isHeader) + .font(.title) + .bold() + .padding(.bottom, 4) + } + .accessibilityElement(children: .combine) + instructions + .padding([.leading, .trailing], 25) + } + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity) + } + + /// Create a new list header. + /// - Parameters: + /// - image: The image view. + /// - title: The title view. + public init(@ViewBuilder image: () -> Image, @ViewBuilder title: () -> Title) where Instructions == EmptyView { + self.init(image: image, title: title) { + EmptyView() + } + } + + /// Create a new list header. + /// - Parameters: + /// - image: The image view. + /// - title: The title view. + /// - instructions: The instructions subheadline. + public init(@ViewBuilder image: () -> Image, @ViewBuilder title: () -> Title, @ViewBuilder instructions: () -> Instructions) { + self.image = image() + self.title = title() + self.instructions = instructions() + } + + /// Create a new list header. + /// - Parameters: + /// - systemImage: The name of the system symbol image. + /// - title: The title view. + public init(systemImage: String, @ViewBuilder title: () -> Title) where Image == SwiftUI.Image, Instructions == EmptyView { + // swiftlint:disable:next accessibility_label_for_image + self.init(image: { SwiftUI.Image(systemName: systemImage) }, title: title) { + EmptyView() + } + } + + /// Create a new list header. + /// - Parameters: + /// - systemImage: The name of the system symbol image. + /// - title: The title view. + /// - instructions: The instructions subheadline. + public init(systemImage: String, @ViewBuilder title: () -> Title, @ViewBuilder instructions: () -> Instructions) where Image == SwiftUI.Image { + // swiftlint:disable:next accessibility_label_for_image + self.init(image: { SwiftUI.Image(systemName: systemImage) }, title: title, instructions: instructions) + } +} + + +#if DEBUG +#Preview { + List { + ListHeader(systemImage: "person.fill.badge.plus") { + Text("Create a new Account", bundle: .module) + } instructions: { + Text("Please fill out the details below to create your new account.", bundle: .module) + } + } +} + +#Preview { + List { + ListHeader(systemImage: "person.fill.badge.plus") { + Text("Create a new Account", bundle: .module) + } + } +} +#endif diff --git a/Tests/SpeziViewsTests/SnapshotTests.swift b/Tests/SpeziViewsTests/SnapshotTests.swift index 641cb3e..ee69f18 100644 --- a/Tests/SpeziViewsTests/SnapshotTests.swift +++ b/Tests/SpeziViewsTests/SnapshotTests.swift @@ -187,4 +187,22 @@ final class SnapshotTests: XCTestCase { Text("World") } } + + @MainActor + func testListHeader() { + let listHeader0 = ListHeader(systemImage: "person.fill.badge.plus") { + Text("Create a new Account", bundle: .module) + } instructions: { + Text("Please fill out the details below to create your new account.", bundle: .module) + } + + let listHeader1 = ListHeader(systemImage: "person.fill.badge.plus") { + Text("Create a new Account", bundle: .module) + } + +#if os(iOS) + assertSnapshot(of: listHeader0, as: .image(layout: .device(config: .iPhone13Pro)), named: "list-header-instructions") + assertSnapshot(of: listHeader1, as: .image(layout: .device(config: .iPhone13Pro)), named: "list-header") +#endif + } } diff --git a/Tests/SpeziViewsTests/__Snapshots__/SnapshotTests/testListHeader.list-header-instructions.png b/Tests/SpeziViewsTests/__Snapshots__/SnapshotTests/testListHeader.list-header-instructions.png new file mode 100644 index 0000000..ee9f8b6 Binary files /dev/null and b/Tests/SpeziViewsTests/__Snapshots__/SnapshotTests/testListHeader.list-header-instructions.png differ diff --git a/Tests/SpeziViewsTests/__Snapshots__/SnapshotTests/testListHeader.list-header.png b/Tests/SpeziViewsTests/__Snapshots__/SnapshotTests/testListHeader.list-header.png new file mode 100644 index 0000000..348ac17 Binary files /dev/null and b/Tests/SpeziViewsTests/__Snapshots__/SnapshotTests/testListHeader.list-header.png differ diff --git a/Tests/UITests/TestApp/Localizable.xcstrings b/Tests/UITests/TestApp/Localizable.xcstrings index 8021305..0886cb8 100644 --- a/Tests/UITests/TestApp/Localizable.xcstrings +++ b/Tests/UITests/TestApp/Localizable.xcstrings @@ -76,6 +76,12 @@ }, "Enter your details" : { + }, + "Entity" : { + + }, + "Entity Info" : { + }, "Error Description" : { diff --git a/Tests/UITests/TestApp/ViewsTests/AsyncButtonTestView.swift b/Tests/UITests/TestApp/ViewsTests/ButtonTestView.swift similarity index 72% rename from Tests/UITests/TestApp/ViewsTests/AsyncButtonTestView.swift rename to Tests/UITests/TestApp/ViewsTests/ButtonTestView.swift index 28bad6c..3c46b4f 100644 --- a/Tests/UITests/TestApp/ViewsTests/AsyncButtonTestView.swift +++ b/Tests/UITests/TestApp/ViewsTests/ButtonTestView.swift @@ -23,10 +23,12 @@ enum CustomError: Error, LocalizedError { } -struct AsyncButtonTestView: View { +struct ButtonTestView: View { @State private var showCompleted = false @State private var viewState: ViewState = .idle + @State private var showInfo = false + var body: some View { List { if showCompleted { @@ -49,6 +51,21 @@ struct AsyncButtonTestView: View { } .disabled(showCompleted) .viewStateAlert(state: $viewState) + + Section { + HStack { + Button { + viewState = .error(CustomError.error) + } label: { + Text("Entity") + .foregroundStyle(.primary) + } + Spacer() + InfoButton("Entity Info") { + showCompleted = true + } + } + } } } } @@ -57,7 +74,7 @@ struct AsyncButtonTestView: View { #if DEBUG struct AsyncButtonTestView_Previews: PreviewProvider { static var previews: some View { - AsyncButtonTestView() + ButtonTestView() } } #endif diff --git a/Tests/UITests/TestApp/ViewsTests/SpeziViewsTests.swift b/Tests/UITests/TestApp/ViewsTests/SpeziViewsTests.swift index 6d9fe2f..bb5036e 100644 --- a/Tests/UITests/TestApp/ViewsTests/SpeziViewsTests.swift +++ b/Tests/UITests/TestApp/ViewsTests/SpeziViewsTests.swift @@ -27,7 +27,7 @@ enum SpeziViewsTests: String, TestAppTests { case conditionalModifier = "Conditional Modifier" case defaultErrorOnly = "Default Error Only" case defaultErrorDescription = "Default Error Description" - case asyncButton = "Async Button" + case button = "Buttons" case listRow = "List Row" case managedViewUpdate = "Managed View Update" case caseIterablePicker = "Case Iterable Picker" @@ -130,8 +130,8 @@ enum SpeziViewsTests: String, TestAppTests { @ViewBuilder @MainActor - private var asyncButton: some View { - AsyncButtonTestView() + private var button: some View { + ButtonTestView() } @MainActor @@ -172,8 +172,8 @@ enum SpeziViewsTests: String, TestAppTests { defaultErrorOnly case .defaultErrorDescription: defaultErrorDescription - case .asyncButton: - asyncButton + case .button: + button case .listRow: listRow case .managedViewUpdate: diff --git a/Tests/UITests/TestAppUITests/SpeziViews/ViewsTests.swift b/Tests/UITests/TestAppUITests/SpeziViews/ViewsTests.swift index 4c6755a..f51926c 100644 --- a/Tests/UITests/TestAppUITests/SpeziViews/ViewsTests.swift +++ b/Tests/UITests/TestAppUITests/SpeziViews/ViewsTests.swift @@ -140,7 +140,7 @@ final class ViewsTests: XCTestCase { } @MainActor - func testAsyncButtonView() throws { + func testButtonsView() throws { let app = XCUIApplication() app.launch() @@ -151,8 +151,8 @@ final class ViewsTests: XCTestCase { app.collectionViews.firstMatch.swipeUp() // on visionOS the AsyncButton is out of the frame due to the window size #endif - XCTAssert(app.buttons["Async Button"].waitForExistence(timeout: 2)) - app.buttons["Async Button"].tap() + XCTAssert(app.buttons["Buttons"].waitForExistence(timeout: 2)) + app.buttons["Buttons"].tap() XCTAssert(app.buttons["Hello World"].waitForExistence(timeout: 2)) app.buttons["Hello World"].tap() @@ -174,6 +174,14 @@ final class ViewsTests: XCTestCase { alerts.buttons["OK"].tap() XCTAssert(app.buttons["Hello Throwing World"].isEnabled) + + XCTAssert(app.buttons.matching(identifier: "info-button").firstMatch.exists) + app.buttons.matching(identifier: "info-button").firstMatch.tap() + + XCTAssertFalse(alerts.staticTexts["Custom Error"].exists) + + XCTAssert(app.staticTexts["Action executed"].waitForExistence(timeout: 2)) + app.buttons["Reset"].tap() } @MainActor diff --git a/Tests/UITests/UITests.xcodeproj/project.pbxproj b/Tests/UITests/UITests.xcodeproj/project.pbxproj index e0036d2..a152109 100644 --- a/Tests/UITests/UITests.xcodeproj/project.pbxproj +++ b/Tests/UITests/UITests.xcodeproj/project.pbxproj @@ -33,7 +33,7 @@ A97880972A4C4E6500150B2F /* ModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A97880962A4C4E6500150B2F /* ModelTests.swift */; }; A97880992A4C524D00150B2F /* DefaultErrorDescriptionTestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A97880982A4C524D00150B2F /* DefaultErrorDescriptionTestView.swift */; }; A978809B2A4C52F100150B2F /* EnvironmentTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A978809A2A4C52F100150B2F /* EnvironmentTests.swift */; }; - A998A94F2A609A9E0030624D /* AsyncButtonTestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A998A94E2A609A9E0030624D /* AsyncButtonTestView.swift */; }; + A998A94F2A609A9E0030624D /* ButtonTestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A998A94E2A609A9E0030624D /* ButtonTestView.swift */; }; A99A65122AF57CA200E63582 /* FocusedValidationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A99A65112AF57CA200E63582 /* FocusedValidationTests.swift */; }; A99A65152AF5923800E63582 /* ValidationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A99A65142AF5923800E63582 /* ValidationTests.swift */; }; A9A3535B2AF60A9E00661848 /* DefaultValidationRules.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9A3535A2AF60A9E00661848 /* DefaultValidationRules.swift */; }; @@ -84,7 +84,7 @@ A97880962A4C4E6500150B2F /* ModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModelTests.swift; sourceTree = ""; }; A97880982A4C524D00150B2F /* DefaultErrorDescriptionTestView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DefaultErrorDescriptionTestView.swift; sourceTree = ""; }; A978809A2A4C52F100150B2F /* EnvironmentTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EnvironmentTests.swift; sourceTree = ""; }; - A998A94E2A609A9E0030624D /* AsyncButtonTestView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncButtonTestView.swift; sourceTree = ""; }; + A998A94E2A609A9E0030624D /* ButtonTestView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ButtonTestView.swift; sourceTree = ""; }; A99A65112AF57CA200E63582 /* FocusedValidationTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FocusedValidationTests.swift; sourceTree = ""; }; A99A65142AF5923800E63582 /* ValidationTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ValidationTests.swift; sourceTree = ""; }; A9A3535A2AF60A9E00661848 /* DefaultValidationRules.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DefaultValidationRules.swift; sourceTree = ""; }; @@ -177,7 +177,7 @@ 2FA9485D29DE90710081C086 /* ViewsTests */ = { isa = PBXGroup; children = ( - A998A94E2A609A9E0030624D /* AsyncButtonTestView.swift */, + A998A94E2A609A9E0030624D /* ButtonTestView.swift */, 2FA9486029DE90720081C086 /* CanvasTestView.swift */, A90575B32CD03B2C00B94001 /* CaseIterablePickerTests.swift */, 97A0A50F2B8D7FD7006102EF /* ConditionalModifierTestView.swift */, @@ -374,7 +374,7 @@ 97EE16AC2B16D5AB004D25A3 /* OperationStateTestView.swift in Sources */, 9731B58F2B167053007676C0 /* ViewStateMapperView.swift in Sources */, A9F85B742B32A05C005F16E6 /* ViewStateExample.swift in Sources */, - A998A94F2A609A9E0030624D /* AsyncButtonTestView.swift in Sources */, + A998A94F2A609A9E0030624D /* ButtonTestView.swift in Sources */, 2FA9486A29DE90720081C086 /* GeometryReaderTestView.swift in Sources */, A9F85B722B32A052005F16E6 /* NameFieldsExample.swift in Sources */, 2FA9486729DE90720081C086 /* CanvasTestView.swift in Sources */, @@ -823,7 +823,7 @@ repositoryURL = "https://github.com/StanfordBDHG/XCTestExtensions"; requirement = { kind = upToNextMinorVersion; - minimumVersion = 0.4.9; + minimumVersion = 1.1.0; }; }; A9BA82B22C29FF7C00472FF3 /* XCRemoteSwiftPackageReference "Spezi" */ = { @@ -831,7 +831,7 @@ repositoryURL = "https://github.com/StanfordSpezi/Spezi.git"; requirement = { kind = upToNextMajorVersion; - minimumVersion = 1.3.0; + minimumVersion = 1.8.0; }; }; /* End XCRemoteSwiftPackageReference section */