Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add InfoButton for list rows #50

Merged
merged 7 commits into from
Dec 18, 2024
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
2 changes: 1 addition & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
10 changes: 3 additions & 7 deletions Sources/SpeziValidation/ValidationEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@
/// 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.
Expand All @@ -31,7 +32,7 @@


/// Unique identifier for this validation engine.
public var id: ObjectIdentifier {
public nonisolated var id: ObjectIdentifier {

Check warning on line 35 in Sources/SpeziValidation/ValidationEngine.swift

View check run for this annotation

Codecov / codecov/patch

Sources/SpeziValidation/ValidationEngine.swift#L35

Added line #L35 was not covered by tests
ObjectIdentifier(self)
}

Expand Down Expand Up @@ -134,7 +135,6 @@
self.init(rules: validationRules, debounceFor: debounceDuration, configuration: configuration)
}

@MainActor
private func computeFailedValidations(input: String) -> [FailedValidationResult] {
var results: [FailedValidationResult] = []

Expand All @@ -153,7 +153,6 @@
}


@MainActor
private func computeValidation(input: String, source: Source) {
self.source = source
self.inputWasEmpty = input.isEmpty
Expand All @@ -174,7 +173,6 @@
/// 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
Expand All @@ -191,12 +189,10 @@
///
/// 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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Bool>.Binding

init(engine: ValidationEngine, input: String, focus focusState: FocusState<Bool>.Binding) {
Expand Down Expand Up @@ -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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,16 @@
import SwiftUI


@MainActor
Supereg marked this conversation as resolved.
Show resolved Hide resolved
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
Expand Down Expand Up @@ -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)
}
}
}
}
2 changes: 2 additions & 0 deletions Sources/SpeziViews/SpeziViews.docc/SpeziViews.md
Original file line number Diff line number Diff line change
Expand Up @@ -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``

Expand Down
69 changes: 69 additions & 0 deletions Sources/SpeziViews/Views/Button/InfoButton.swift
Original file line number Diff line number Diff line change
@@ -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
}

Check warning on line 42 in Sources/SpeziViews/Views/Button/InfoButton.swift

View check run for this annotation

Codecov / codecov/patch

Sources/SpeziViews/Views/Button/InfoButton.swift#L39-L42

Added lines #L39 - L42 were not covered by tests

/// 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
2 changes: 1 addition & 1 deletion Sources/SpeziViews/Views/Controls/CaseIterablePicker.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<Value>) where Label == Text {
self.init(selection: selection) {
Text(titleKey)
Expand Down
105 changes: 105 additions & 0 deletions Sources/SpeziViews/Views/List/ListHeader.swift
Original file line number Diff line number Diff line change
@@ -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<Image: View, Title: View, Instructions: View>: 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()
}
}

Check warning on line 51 in Sources/SpeziViews/Views/List/ListHeader.swift

View check run for this annotation

Codecov / codecov/patch

Sources/SpeziViews/Views/List/ListHeader.swift#L47-L51

Added lines #L47 - L51 were not covered by tests

/// 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
18 changes: 18 additions & 0 deletions Tests/SpeziViewsTests/SnapshotTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -187,4 +187,22 @@ final class SnapshotTests: XCTestCase {
Text("World")
}
}

@MainActor
func testListHeader() {
Supereg marked this conversation as resolved.
Show resolved Hide resolved
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
}
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 6 additions & 0 deletions Tests/UITests/TestApp/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,12 @@
},
"Enter your details" : {

},
"Entity" : {

},
"Entity Info" : {

},
"Error Description" : {

Expand Down
Loading
Loading