diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index 3c1a3b9..9a49907 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -20,9 +20,9 @@ jobs: name: Build and Test Swift Package uses: StanfordSpezi/.github/.github/workflows/xcodebuild-or-fastlane.yml@v2 with: - artifactname: SpeziViews.xcresult + artifactname: SpeziViews-Package.xcresult runsonlabels: '["macOS", "self-hosted"]' - scheme: SpeziViews + scheme: SpeziViews-Package buildandtestuitests: name: Build and Test UI Tests uses: StanfordSpezi/.github/.github/workflows/xcodebuild-or-fastlane.yml@v2 @@ -36,4 +36,4 @@ jobs: needs: [buildandtest, buildandtestuitests] uses: StanfordSpezi/.github/.github/workflows/create-and-upload-coverage-report.yml@v2 with: - coveragereports: SpeziViews.xcresult TestApp.xcresult + coveragereports: SpeziViews-Package.xcresult TestApp.xcresult diff --git a/Package.swift b/Package.swift index 214bdce..9c15384 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version:5.7 +// swift-tools-version:5.9 // // This source file is part of the Stanford Spezi open-source project @@ -15,19 +15,27 @@ let package = Package( name: "SpeziViews", defaultLocalization: "en", platforms: [ - .iOS(.v16) + .iOS(.v17) ], products: [ - .library(name: "SpeziViews", targets: ["SpeziViews"]) + .library(name: "SpeziViews", targets: ["SpeziViews"]), + .library(name: "SpeziPersonalInfo", targets: ["SpeziPersonalInfo"]) ], targets: [ .target( name: "SpeziViews" ), + .target( + name: "SpeziPersonalInfo", + dependencies: [ + .target(name: "SpeziViews") + ] + ), .testTarget( name: "SpeziViewsTests", dependencies: [ - .target(name: "SpeziViews") + .target(name: "SpeziViews"), + .target(name: "SpeziPersonalInfo") ] ) ] diff --git a/Sources/SpeziPersonalInfo/Fields/NameFieldRow.swift b/Sources/SpeziPersonalInfo/Fields/NameFieldRow.swift new file mode 100644 index 0000000..bd3e9e7 --- /dev/null +++ b/Sources/SpeziPersonalInfo/Fields/NameFieldRow.swift @@ -0,0 +1,139 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import SpeziViews +import SwiftUI + + +/// A `NameTextField` that always shows a description in front of the text field. +/// +/// The `NameFieldRow` uses the `DescriptionGridRow` and is to be placed into a [Grid](https://developer.apple.com/documentation/swiftui/grid) +/// view to provide a description text in front of the ``NameTextField``. +/// +/// Below is a short code example on how to collect both the given name and family name of a person within a SwiftUI `Form`. +/// ```swift +/// @State private var name = PersonNameComponents() +/// +/// var body: some View { +/// Form { +/// Grid(horizontalSpacing: 15) { // optional horizontal spacing +/// NameFieldRow(name: $name, for: \.givenName) { +/// Text(verbatim: "First") +/// } label: { +/// Text(verbatim: "enter first name") +/// } +/// +/// Divider() +/// .gridCellUnsizedAxes(.horizontal) +/// +/// NameFieldRow(name: $name, for: \.familyName) { +/// Text(verbatim: "Last") +/// } label: { +/// Text(verbatim: "enter last name") +/// } +/// } +/// } +/// } +/// ``` +public struct NameFieldRow: View { + private let description: Description + private let label: Label + private let component: WritableKeyPath + + @Binding private var name: PersonNameComponents + + + public var body: some View { + DescriptionGridRow { + description + } content: { + NameTextField(name: $name, for: component) { + label + } + } + } + + + /// Creates a name text field with a description label. + /// - Parameters: + /// - description: The localized description label displayed before the text field. + /// - name: The name to display and edit. + /// - component: The `KeyPath` to the property of the provided `PersonNameComponents` to display and edit. + /// - label: A view that describes the purpose of the text field. + public init( + _ description: LocalizedStringResource, + name: Binding, + for component: WritableKeyPath, + @ViewBuilder label: () -> Label + ) where Description == Text { + self.init(name: name, for: component, description: { Text(description) }, label: label) + } + + /// Creates a name text field with a description label. + /// - Parameters: + /// - name: The name to display and edit. + /// - component: The `KeyPath` to the property of the provided `PersonNameComponents` to display and edit. + /// - prompt: An optional `Text` prompt. Refer to the documentation of `TextField` for more information. + /// - description: The description label displayed before the text field. + /// - label: A view that describes the purpose of the text field. + public init( + name: Binding, + for component: WritableKeyPath, + @ViewBuilder description: () -> Description, + @ViewBuilder label: () -> Label + ) { + self._name = name + self.component = component + self.description = description() + self.label = label() + } +} + + +#if DEBUG +#Preview { + @State var name = PersonNameComponents() + return Grid(horizontalSpacing: 15) { + NameFieldRow(name: $name, for: \.familyName) { + Text(verbatim: "First") + } label: { + Text(verbatim: "enter first name") + } + + Divider() + .gridCellUnsizedAxes(.horizontal) + + NameFieldRow(name: $name, for: \.familyName) { + Text(verbatim: "Last") + } label: { + Text(verbatim: "enter last name") + } + } +} +#Preview { + @State var name = PersonNameComponents() + return Form { + Grid(horizontalSpacing: 15) { + NameFieldRow(name: $name, for: \.givenName) { + Text(verbatim: "First") + } label: { + Text(verbatim: "enter first name") + } + + Divider() + .gridCellUnsizedAxes(.horizontal) + + NameFieldRow(name: $name, for: \.familyName) { + Text(verbatim: "Last") + } label: { + Text(verbatim: "enter last name") + } + } + } +} +#endif diff --git a/Sources/SpeziPersonalInfo/Fields/NameTextField.swift b/Sources/SpeziPersonalInfo/Fields/NameTextField.swift new file mode 100644 index 0000000..1c7cf0b --- /dev/null +++ b/Sources/SpeziPersonalInfo/Fields/NameTextField.swift @@ -0,0 +1,122 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import SwiftUI + + +/// A TextField for properties of `PersonNameComponents`. +/// +/// The `NameTextField` view allows to create a SwiftUI [TextField](https://developer.apple.com/documentation/swiftui/textfield) for properties +/// of [PersonNameComponents](https://developer.apple.com/documentation/foundation/personnamecomponents). +/// To do so you supply a Binding to your `PersonNameComponents` value and a `KeyPath` to the property of `PersonNameComponents` you are trying to input. +/// +/// `NameTextField` modifies the underlying `TextField` to optimize for name entry and automatically sets modifiers like +/// [textContentType(_:)](https://developer.apple.com/documentation/swiftui/view/textcontenttype(_:)-ufdv). +/// +/// Below is a short code example on how to create an editable text interface for the given name of a person. +/// ```swift +/// @State private var name = PersonNameComponents() +/// +/// var body: some View { +/// NameTextField("enter first name", name: $name, for: \.givenName) +/// } +/// ``` +/// +/// - Note: A empty string will be automatically mapped to a `nil` value for the respective property of `PersonNameComponents`. +public struct NameTextField: View { + private let prompt: Text? + private let label: Label + private let nameComponent: WritableKeyPath + + @Binding private var name: PersonNameComponents + + private var componentBinding: Binding { + Binding { + name[keyPath: nameComponent] ?? "" + } set: { newValue in + name[keyPath: nameComponent] = newValue.isEmpty ? nil : newValue + } + } + + private var contentType: UITextContentType? { + switch nameComponent { + case \.namePrefix: + return .namePrefix + case \.nameSuffix: + return .nameSuffix + case \.givenName: + return .givenName + case \.middleName: + return .middleName + case \.familyName: + return .familyName + case \.nickname: + return .nickname + default: + return .name // general, catch all content type + } + } + + + public var body: some View { + TextField(text: componentBinding, prompt: prompt) { + label + } + .autocorrectionDisabled() + .textInputAutocapitalization(.words) + .textContentType(contentType) + } + + + /// Creates a name text field with an optional prompt. + /// - Parameters: + /// - label: A localized title of the text field, describing its purpose. + /// - name: The name to display and edit. + /// - component: The `KeyPath` to the property of the provided `PersonNameComponents` to display and edit. + /// - prompt: An optional `Text` prompt. Refer to the documentation of `TextField` for more information. + public init( + _ label: LocalizedStringResource, + name: Binding, + for component: WritableKeyPath, + prompt: Text? = nil + ) where Label == Text { + self.init(name: name, for: component, prompt: prompt) { + Text(label) + } + } + + /// Creates a name text field with an optional prompt. + /// - Parameters: + /// - name: The name to display and edit. + /// - component: The `KeyPath` to the property of the provided `PersonNameComponents` to display and edit. + /// - prompt: An optional `Text` prompt. Refer to the documentation of `TextField` for more information. + /// - label: A view that describes the purpose of the text field. + public init( + name: Binding, + for component: WritableKeyPath, + prompt: Text? = nil, + @ViewBuilder label: () -> Label + ) { + self._name = name + self.nameComponent = component + self.prompt = prompt + self.label = label() + } +} + + +#if DEBUG +#Preview { + @State var name = PersonNameComponents() + return List { + NameTextField(name: $name, for: \.givenName) { + Text(verbatim: "enter first name") + } + } +} +#endif diff --git a/Sources/SpeziPersonalInfo/Resources/Localizable.xcstrings b/Sources/SpeziPersonalInfo/Resources/Localizable.xcstrings new file mode 100644 index 0000000..fa56d16 --- /dev/null +++ b/Sources/SpeziPersonalInfo/Resources/Localizable.xcstrings @@ -0,0 +1,7 @@ +{ + "sourceLanguage" : "en", + "strings" : { + + }, + "version" : "1.0" +} \ No newline at end of file diff --git a/Sources/SpeziPersonalInfo/Resources/Localizable.xcstrings.license b/Sources/SpeziPersonalInfo/Resources/Localizable.xcstrings.license new file mode 100644 index 0000000..5457bcf --- /dev/null +++ b/Sources/SpeziPersonalInfo/Resources/Localizable.xcstrings.license @@ -0,0 +1,6 @@ + +This source file is part of the Stanford Spezi open-source project + +SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) + +SPDX-License-Identifier: MIT diff --git a/Sources/SpeziPersonalInfo/SpeziPersonalInfo.docc/SpeziPersonalInfo.md b/Sources/SpeziPersonalInfo/SpeziPersonalInfo.docc/SpeziPersonalInfo.md new file mode 100644 index 0000000..2d4179c --- /dev/null +++ b/Sources/SpeziPersonalInfo/SpeziPersonalInfo.docc/SpeziPersonalInfo.md @@ -0,0 +1,24 @@ +# ``SpeziPersonalInfo`` + +A SpeziViews target that provides a common set of SwiftUI views and related functionality for managing personal information. + + + +## Topics + +### Person Name + +- ``NameTextField`` +- ``NameFieldRow`` + +### User Profile + +- ``UserProfileView`` diff --git a/Sources/SpeziViews/Views/ProfileView/UserProfileView.swift b/Sources/SpeziPersonalInfo/UserProfileView.swift similarity index 65% rename from Sources/SpeziViews/Views/ProfileView/UserProfileView.swift rename to Sources/SpeziPersonalInfo/UserProfileView.swift index 5fa94db..da46578 100644 --- a/Sources/SpeziViews/Views/ProfileView/UserProfileView.swift +++ b/Sources/SpeziPersonalInfo/UserProfileView.swift @@ -63,37 +63,38 @@ public struct UserProfileView: View { #if DEBUG -struct ProfilePictureView_Previews: PreviewProvider { - static var previews: some View { - VStack(spacing: 16) { - UserProfileView( - name: PersonNameComponents(givenName: "Paul", familyName: "Schmiedmayer") - ) - .frame(width: 100, height: 50) - .padding() - UserProfileView( - name: PersonNameComponents( - namePrefix: "Prof.", - givenName: "Oliver", - middleName: "Oppers", - familyName: "Aalami" - ) - ) - .frame(width: 100, height: 100) - .padding() - .background(Color(.systemBackground)) - .colorScheme(.dark) - UserProfileView( - name: PersonNameComponents(givenName: "Vishnu", familyName: "Ravi"), - imageLoader: { - try? await Task.sleep(for: .seconds(2)) - return Image(systemName: "person.crop.artframe") - } - ) - .frame(width: 50, height: 100) - .shadow(radius: 4) - .padding() +#Preview { + UserProfileView( + name: PersonNameComponents(givenName: "Paul", familyName: "Schmiedmayer") + ) + .frame(width: 100, height: 100) + .padding() +} + +#Preview { + UserProfileView( + name: PersonNameComponents( + namePrefix: "Prof.", + givenName: "Oliver", + middleName: "Oppers", + familyName: "Aalami" + ) + ) + .frame(width: 100, height: 100) + .padding() + .background(Color(.systemBackground)) + .colorScheme(.dark) +} + +#Preview { + UserProfileView( + name: PersonNameComponents(givenName: "Vishnu", familyName: "Ravi"), + imageLoader: { + try? await Task.sleep(for: .seconds(2)) + return Image(systemName: "person.crop.circle") } - } + ) + .frame(width: 50, height: 100) + .padding() } #endif diff --git a/Sources/SpeziViews/Environment/DefaultErrorDescription.swift b/Sources/SpeziViews/Environment/DefaultErrorDescription.swift index babb902..2d6167e 100644 --- a/Sources/SpeziViews/Environment/DefaultErrorDescription.swift +++ b/Sources/SpeziViews/Environment/DefaultErrorDescription.swift @@ -14,13 +14,17 @@ import SwiftUI /// This might be helpful for views that rely on ``AnyLocalizedError``. Outer views can define a /// sensible default for a localized default error description in the case that a sub-view has to display /// an ``AnyLocalizedError`` for a generic error. -public struct DefaultErrorDescription: EnvironmentKey { - public static let defaultValue: LocalizedStringResource? = nil +struct DefaultErrorDescription: EnvironmentKey { + static let defaultValue: LocalizedStringResource? = nil } extension EnvironmentValues { - /// Refer to the documentation of ``DefaultErrorDescription``. + /// A localized string that is used as a default error description if no other description is available. + /// + /// This might be helpful for views that rely on ``AnyLocalizedError``. Outer views can define a + /// sensible default for a localized default error description in the case that a sub-view has to display + /// an ``AnyLocalizedError`` for a generic error. public var defaultErrorDescription: LocalizedStringResource? { get { self[DefaultErrorDescription.self] diff --git a/Sources/SpeziViews/Environment/ProcessingDebounceDuration.swift b/Sources/SpeziViews/Environment/ProcessingDebounceDuration.swift index 0f07d03..93ecd15 100644 --- a/Sources/SpeziViews/Environment/ProcessingDebounceDuration.swift +++ b/Sources/SpeziViews/Environment/ProcessingDebounceDuration.swift @@ -13,13 +13,18 @@ import SwiftUI /// /// This might be helpful to provide extensive customization points without introducing clutter in the initializer of views. /// The ``AsyncButton`` is one example where this `EnvironmentKey` is used. -public struct ProcessingDebounceDuration: EnvironmentKey { - public static let defaultValue: Duration = .milliseconds(150) +struct ProcessingDebounceDuration: EnvironmentKey { + static let defaultValue: Duration = .milliseconds(150) } extension EnvironmentValues { - /// Refer to the documentation of ``ProcessingDebounceDuration``. + /// A `Duration` that provides a generalized configuration for debounce durations for any processing-related operations. + /// + /// This might be helpful to provide extensive customization points without introducing clutter in the initializer of views. + /// The ``AsyncButton`` is one example where this `EnvironmentKey` is used. + /// + /// - Note: The default value is `150ms`. public var processingDebounceDuration: Duration { get { self[ProcessingDebounceDuration.self] diff --git a/Sources/SpeziViews/Model/FieldLocalizationResource.swift b/Sources/SpeziViews/Model/FieldLocalizationResource.swift deleted file mode 100644 index d3904c0..0000000 --- a/Sources/SpeziViews/Model/FieldLocalizationResource.swift +++ /dev/null @@ -1,43 +0,0 @@ -// -// This source file is part of the Stanford Spezi open-source project -// -// SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) -// -// SPDX-License-Identifier: MIT -// - -import Foundation - - -/// A ``FieldLocalizationResource`` describes a localization of a `TextField` instance using a ``FieldLocalizationResource/title`` and ``FieldLocalizationResource/placeholder``. -public struct FieldLocalizationResource: Codable { - /// The localized title of a `TextField`. - public let title: LocalizedStringResource - /// The localized placeholder of a `TextField`. - public let placeholder: LocalizedStringResource - - - /// Creates a new ``FieldLocalizationResource`` instance. - /// - Parameters: - /// - title: The localized title of a `TextField`. - /// - placeholder: The localized placeholder of a `TextField`. - public init(title: LocalizedStringResource, placeholder: LocalizedStringResource) { - self.title = title - self.placeholder = placeholder - } - - /// Creates a new ``FieldLocalizationResource`` instance. - /// - Parameters: - /// - title: The title of a `TextField` following the localization mechanisms lined out in `StringProtocol.localized()`. - /// - placeholder: The placeholder of a `TextField` following the localization mechanisms lined out in `StringProtocol.localized()`. - /// - bundle: The `Bundle` used for localization. If you have differing bundles for title and placeholder use the - /// alternative initializer ``init(title:placeholder:)``. - @_disfavoredOverload - public init( - title: Title, - placeholder: Placeholder, - bundle: Bundle? = nil - ) { - self.init(title: title.localized(bundle), placeholder: placeholder.localized(bundle)) - } -} diff --git a/Sources/SpeziViews/Model/ViewState.swift b/Sources/SpeziViews/Model/ViewState.swift index d3ac0d2..5c93648 100644 --- a/Sources/SpeziViews/Model/ViewState.swift +++ b/Sources/SpeziViews/Model/ViewState.swift @@ -50,7 +50,7 @@ extension ViewState { return errorTitle default: - return String(localized: "VIEW_STATE_DEFAULT_ERROR_TITLE", bundle: .module) + return String(localized: "Error", bundle: .module, comment: "View State default error title") } } diff --git a/Sources/SpeziViews/Resources/Localizable.xcstrings b/Sources/SpeziViews/Resources/Localizable.xcstrings new file mode 100644 index 0000000..c02e2b6 --- /dev/null +++ b/Sources/SpeziViews/Resources/Localizable.xcstrings @@ -0,0 +1,159 @@ +{ + "sourceLanguage" : "en", + "strings" : { + "DEFAULT_ERROR_DESCRIPTION" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ein unerwarteter Fehler ist aufgetreten!" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Unexpected error occurred!" + } + } + } + }, + "Enter your first name ..." : { + "comment" : "Given name placeholder", + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vornamen eingeben ..." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enter your first name ..." + } + } + } + }, + "Enter your last name ..." : { + "comment" : "Family name placeholder", + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Familienname eingeben ..." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enter your last name ..." + } + } + } + }, + "Error" : { + "comment" : "View State default error title", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fehler" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Error" + } + } + } + }, + "First Name" : { + "comment" : "Given name title", + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vorname" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "First Name" + } + } + } + }, + "Last Name" : { + "comment" : "Family name title", + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Familienname" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Last Name" + } + } + } + }, + "MARKDOWN_LOADING_ERROR" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Das Dokument konnte nicht geladen werden." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Could not load and parse the document." + } + } + } + }, + "MARKDOWN_LOADING_ERROR_FAILURE_REASON" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Der Inhalt des Dokuments konnte nicht verarbeitet werden. Grund dessen ist ein invaliden Markdown Text." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The system wasn't able to parse the given markdown text, indicating an invalid markdown text." + } + } + } + }, + "MARKDOWN_LOADING_ERROR_RECOVERY_SUGGESTION" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bitte prüfen Sie den Inhalt des Markdown Dokuments." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Please check the content of the markdown text." + } + } + } + } + }, + "version" : "1.0" +} \ No newline at end of file diff --git a/Sources/SpeziViews/Resources/Localizable.xcstrings.license b/Sources/SpeziViews/Resources/Localizable.xcstrings.license new file mode 100644 index 0000000..5457bcf --- /dev/null +++ b/Sources/SpeziViews/Resources/Localizable.xcstrings.license @@ -0,0 +1,6 @@ + +This source file is part of the Stanford Spezi open-source project + +SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) + +SPDX-License-Identifier: MIT diff --git a/Sources/SpeziViews/Resources/de.lproj/Localizable.strings b/Sources/SpeziViews/Resources/de.lproj/Localizable.strings deleted file mode 100644 index 73bb291..0000000 --- a/Sources/SpeziViews/Resources/de.lproj/Localizable.strings +++ /dev/null @@ -1,27 +0,0 @@ -// -// This source file is part of the Stanford Spezi open-source project -// -// SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) -// -// SPDX-License-Identifier: MIT -// - -// MARK: View State -"VIEW_STATE_DEFAULT_ERROR_TITLE" = "Fehler"; - -// MARK: Name Fields -"NAME_FIELD_GIVEN_NAME_TITLE" = "Vorname"; -"NAME_FIELD_GIVEN_NAME_PLACEHOLDER" = "Vorname eingeben ..."; -"NAME_FIELD_FAMILY_NAME_TITLE" = "Familienname"; -"NAME_FIELD_FAMILY_NAME_PLACEHOLDER" = "Familienname eingeben ..."; - -// MARK: MarkdownView -"MARKDOWN_LOADING_ERROR" = "Das Dokument konnte nicht geladen werden."; -"MARKDOWN_LOADING_ERROR_RECOVERY_SUGGESTION" = "Bitte prüfen Sie den Inhalt des Markdown Dokuments."; -"MARKDOWN_LOADING_ERROR_FAILURE_REASON" = "Der Inhalt des Dokuments konnte nicht verarbeitet werden. Grund dessen ist ein invaliden Markdown Text."; - -// MARK: HTMLView -"HTML_LOADING_ERROR" = "HTML konnte nicht geladen werden."; - -// MARK: AnyLocalizedError -"DEFAULT_ERROR_DESCRIPTION" = "Ein unerwarteter Fehler ist aufgetreten!"; diff --git a/Sources/SpeziViews/Resources/en.lproj/Localizable.strings b/Sources/SpeziViews/Resources/en.lproj/Localizable.strings deleted file mode 100644 index 6509a5b..0000000 --- a/Sources/SpeziViews/Resources/en.lproj/Localizable.strings +++ /dev/null @@ -1,27 +0,0 @@ -// -// This source file is part of the Stanford Spezi open-source project -// -// SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) -// -// SPDX-License-Identifier: MIT -// - -// MARK: View State -"VIEW_STATE_DEFAULT_ERROR_TITLE" = "Error"; - -// MARK: Name Fields -"NAME_FIELD_GIVEN_NAME_TITLE" = "First Name"; -"NAME_FIELD_GIVEN_NAME_PLACEHOLDER" = "Enter your first name ..."; -"NAME_FIELD_FAMILY_NAME_TITLE" = "Last Name"; -"NAME_FIELD_FAMILY_NAME_PLACEHOLDER" = "Enter your last name ..."; - -// MARK: MarkdownView -"MARKDOWN_LOADING_ERROR" = "Could not load and parse the document."; -"MARKDOWN_LOADING_ERROR_RECOVERY_SUGGESTION" = "Please check the content of the markdown text."; -"MARKDOWN_LOADING_ERROR_FAILURE_REASON" = "The system wasn't able to parse the given markdown text, indicating an invalid markdown text."; - -// MARK: HTMLView -"HTML_LOADING_ERROR" = "Could not load the HTML."; - -// MARK: AnyLocalizedError -"DEFAULT_ERROR_DESCRIPTION" = "Unexpected error occurred!"; diff --git a/Sources/SpeziViews/SpeziViews.docc/SpeziViews.md b/Sources/SpeziViews/SpeziViews.docc/SpeziViews.md new file mode 100644 index 0000000..324c00b --- /dev/null +++ b/Sources/SpeziViews/SpeziViews.docc/SpeziViews.md @@ -0,0 +1,54 @@ +# ``SpeziViews`` + +A Spezi framework that provides a common set of SwiftUI views and related functionality used across the Spezi ecosystem. + + + + +## Topics + +### Managing State + +- ``ViewState`` +- ``SwiftUI/View/viewStateAlert(state:)`` +- ``SwiftUI/View/processingOverlay(isProcessing:overlay:)-5xplv`` +- ``SwiftUI/View/processingOverlay(isProcessing:overlay:)-3df8d`` +- ``SwiftUI/EnvironmentValues/defaultErrorDescription`` +- ``AnyLocalizedError`` + +### User Input + +- ``AsyncButton`` +- ``SwiftUI/EnvironmentValues/processingDebounceDuration`` +- ``CanvasView`` + +### Displaying Content + +- ``Label`` +- ``LazyText`` +- ``MarkdownView`` +- ``DescriptionGridRow`` + +### Readers + +- ``HorizontalGeometryReader`` +- ``WidthPreferenceKey`` + +### Localization + +- ``Foundation/LocalizedStringResource/localizedString(for:)`` +- ``Swift/StringProtocol/localized(_:)`` +- ``Foundation/LocalizedStringResource/BundleDescription/atURL(from:)`` + +### Managing Focus + +- ``SwiftUI/View/onTapFocus()`` +- ``SwiftUI/View/onTapFocus(focusedField:fieldIdentifier:)-1rxxf`` diff --git a/Sources/SpeziViews/Utilities/AnyLocalizedError.swift b/Sources/SpeziViews/Utilities/AnyLocalizedError.swift index a330e73..0372aac 100644 --- a/Sources/SpeziViews/Utilities/AnyLocalizedError.swift +++ b/Sources/SpeziViews/Utilities/AnyLocalizedError.swift @@ -25,7 +25,7 @@ public struct AnyLocalizedError: LocalizedError { /// Provides a best-effort approach to create a type erased version of `LocalizedError`. /// - /// - Note: Refer to the documentation of the ``DefaultErrorDescription`` environment key on how to pass a useful and + /// - Note: Refer to the documentation of the ``SwiftUI/EnvironmentValues/defaultErrorDescription`` environment key on how to pass a useful and /// environment-defined default error description. /// /// - Parameters: @@ -37,7 +37,7 @@ public struct AnyLocalizedError: LocalizedError { /// Provides a best-effort approach to create a type erased version of `LocalizedError`. /// - /// - Note: Refer to the documentation of the ``DefaultErrorDescription`` environment key on how to pass a useful and + /// - Note: Refer to the documentation of the ``SwiftUI/EnvironmentValues/defaultErrorDescription`` environment key on how to pass a useful and /// environment-defined default error description. /// /// - Parameters: diff --git a/Sources/SpeziViews/Utilities/StringProtocol+Localization.swift b/Sources/SpeziViews/Utilities/StringProtocol+Localization.swift index 7126fbb..796007c 100644 --- a/Sources/SpeziViews/Utilities/StringProtocol+Localization.swift +++ b/Sources/SpeziViews/Utilities/StringProtocol+Localization.swift @@ -10,16 +10,6 @@ import Foundation extension StringProtocol { - /// Creates a localized version of the instance conforming to `StringProtocol`. - /// - /// String literals (`StringLiteralType`) and `String.LocalizationValue` instances are tried to be localized using the main bundle. - /// `String` instances are not localized. You have to manually localize a `String` instance using `String(localized:)`. - @available(*, deprecated, message: "The `localized` property has been renamed to the `localized()` to better communicate its manipulations.") - public var localized: LocalizedStringResource { - localized(nil) - } - - /// Creates a localized version of the instance conforming to `StringProtocol`. /// /// String literals (`StringLiteralType`) and `String.LocalizationValue` instances are tried to be localized using the provided bundle. diff --git a/Sources/SpeziViews/ViewModifier/OnTapFocus.swift b/Sources/SpeziViews/ViewModifier/OnTapFocus.swift index a55e222..d401c18 100644 --- a/Sources/SpeziViews/ViewModifier/OnTapFocus.swift +++ b/Sources/SpeziViews/ViewModifier/OnTapFocus.swift @@ -59,16 +59,4 @@ extension View { ) -> some View { modifier(OnTapFocus(focusedState: focusedField, fieldIdentifier: fieldIdentifier)) } - - /// Modifies the view to be in a focused state (e.g., `TextFields`) if it is tapped. - /// - Parameters: - /// - focusedField: The `FocusState` binding that should be set. - /// - fieldIdentifier: The identifier that the `focusedField` should be set to. - @available(*, deprecated, message: "Please move to the onTapFocus(focusedField:fieldIdentifier:) that accepts the FocusState as a Binding.") - public func onTapFocus( - focusedField: FocusState, - fieldIdentifier: FocusedField - ) -> some View { - modifier(OnTapFocus(focusedState: focusedField.projectedValue, fieldIdentifier: fieldIdentifier)) - } } diff --git a/Sources/SpeziViews/Views/Button/AsyncButton.swift b/Sources/SpeziViews/Views/Button/AsyncButton.swift index cf6efb8..0b96493 100644 --- a/Sources/SpeziViews/Views/Button/AsyncButton.swift +++ b/Sources/SpeziViews/Views/Button/AsyncButton.swift @@ -17,6 +17,23 @@ enum AsyncButtonState { /// A SwiftUI `Button` that initiates an asynchronous (throwing) action. +/// +/// The `AsyncButton` closely works together with the ``ViewState`` to control processing and error states. +/// +/// Below is a short code example on how to use `ViewState` in conjunction with the `AsyncButton` to spin of a +/// async throwing action. It relies on the ``SwiftUI/View/viewStateAlert(state:)`` modifier to present any +/// potential `LocalizedErrors` to the user. +/// +/// ```swift +/// @State private var viewState: ViewState = .idle +/// +/// var body: some View { +/// AsyncButton("Press Me", state: $viewState) { +/// +/// } +/// .viewStateAlert(state: $viewState) +/// } +/// ``` @MainActor public struct AsyncButton: View { private let role: ButtonRole? @@ -50,7 +67,7 @@ public struct AsyncButton: View { } } - /// Creates am async button that generates its label from a provided localized string. + /// Creates an async button that generates its label from a provided localized string. /// - Parameters: /// - title: The localized string used to generate the Label. /// - role: An optional button role that is passed onto the underlying `Button`. @@ -65,7 +82,7 @@ public struct AsyncButton: View { } } - /// Creates am async button that generates its label from a provided without localization. + /// Creates an async button that generates its label from a provided without localization. /// - Parameters: /// - title: The string used to generate the Label without localization. /// - role: An optional button role that is passed onto the underlying `Button`. @@ -77,7 +94,7 @@ public struct AsyncButton: View { action: @escaping () async -> Void ) where Label == Text { self.init(role: role, action: action) { - Text(title) + Text(verbatim: String(title)) } } @@ -112,7 +129,7 @@ public struct AsyncButton: View { action: @escaping () async throws -> Void ) where Label == Text { self.init(role: role, state: state, action: action) { - Text(title) + Text(verbatim: String(title)) } } @@ -211,7 +228,7 @@ public struct AsyncButton: View { #if DEBUG struct AsyncThrowingButton_Previews: PreviewProvider { struct PreviewButton: View { - var title: LocalizedStringResource = "Test Button" + var title: String = "Test Button" var role: ButtonRole? var duration: Duration = .seconds(1) var action: () async throws -> Void = {} diff --git a/Sources/SpeziViews/Views/DescriptionGridRow.swift b/Sources/SpeziViews/Views/DescriptionGridRow.swift index 48c1134..48b0ca0 100644 --- a/Sources/SpeziViews/Views/DescriptionGridRow.swift +++ b/Sources/SpeziViews/Views/DescriptionGridRow.swift @@ -48,34 +48,34 @@ struct DescriptionGridRow_Previews: PreviewProvider { Form { Grid(horizontalSpacing: 8, verticalSpacing: 8) { DescriptionGridRow { - Text("Description") + Text(verbatim: "Description") } content: { - Text("Content") + Text(verbatim: "Content") } Divider() DescriptionGridRow { - Text("Description") + Text(verbatim: "Description") } content: { - Text("Content") + Text(verbatim: "Content") } DescriptionGridRow { - Text("Description") + Text(verbatim: "Description") } content: { - Text("Content") + Text(verbatim: "Content") } } } Grid(horizontalSpacing: 8, verticalSpacing: 8) { DescriptionGridRow { - Text("Description") + Text(verbatim: "Description") } content: { - Text("Content") + Text(verbatim: "Content") } Divider() DescriptionGridRow { - Text("Description") + Text(verbatim: "Description") } content: { - Text("Content") + Text(verbatim: "Content") } } .padding(32) diff --git a/Sources/SpeziViews/Views/Drawing/CanvasView.swift b/Sources/SpeziViews/Views/Drawing/CanvasView.swift index 9a3ff2f..03a087b 100644 --- a/Sources/SpeziViews/Views/Drawing/CanvasView.swift +++ b/Sources/SpeziViews/Views/Drawing/CanvasView.swift @@ -11,7 +11,7 @@ import SwiftUI private struct _CanvasView: UIViewRepresentable { - class Coordinator: NSObject, ObservableObject, PKCanvasViewDelegate { + class Coordinator: NSObject, PKCanvasViewDelegate { let canvasView: _CanvasView @@ -178,7 +178,7 @@ struct SignatureView_Previews: PreviewProvider { static var previews: some View { VStack { - Text("\(isDrawing.description)") + Text(verbatim: "\(isDrawing.description)") CanvasView(isDrawing: $isDrawing) } } diff --git a/Sources/SpeziViews/Views/Fields/NameFieldRow.swift b/Sources/SpeziViews/Views/Fields/NameFieldRow.swift deleted file mode 100644 index 6cdeff6..0000000 --- a/Sources/SpeziViews/Views/Fields/NameFieldRow.swift +++ /dev/null @@ -1,104 +0,0 @@ -// -// This source file is part of the Stanford Spezi open-source project -// -// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) -// -// SPDX-License-Identifier: MIT -// - -import SwiftUI - - -struct FieldFocus: DynamicProperty { - let focusedState: FocusState.Binding - let fieldIdentifier: FocusedField -} - - -struct NameFieldRow: View { - private let label: Label - private let placeholder: LocalizedStringResource - - private let nameComponent: WritableKeyPath - private let contentType: UITextContentType - - private let fieldFocus: FieldFocus? - - - @Binding private var name: PersonNameComponents - - private var componentBinding: Binding { - Binding { - name[keyPath: nameComponent] ?? "" - } set: { newValue in - name[keyPath: nameComponent] = newValue - } - } - - - var body: some View { - let row = DescriptionGridRow { - label - } content: { - TextField(text: componentBinding) { - Text(placeholder) - } - .autocorrectionDisabled(true) - .textInputAutocapitalization(.never) - .textContentType(contentType) - } - - if let fieldFocus { - row - .onTapFocus( - focusedField: fieldFocus.focusedState, - fieldIdentifier: fieldFocus.fieldIdentifier - ) - } else { - row - .onTapFocus() - } - } - - - init( - _ placeholder: LocalizedStringResource, - name: Binding, - for nameComponent: WritableKeyPath, - content contentType: UITextContentType, - @ViewBuilder label: () -> Label - ) where FocusedField == UUID { - self.init(placeholder, name: name, for: nameComponent, content: contentType, focus: nil, label: label) - } - - init( - _ placeholder: LocalizedStringResource, - name: Binding, - for nameComponent: WritableKeyPath, - content contentType: UITextContentType, - focus fieldFocus: FieldFocus?, - @ViewBuilder label: () -> Label - ) { - self.placeholder = placeholder - self._name = name - self.nameComponent = nameComponent - self.contentType = contentType - self.fieldFocus = fieldFocus - self.label = label() - } -} - - -#if DEBUG -struct NameFieldRow_Previews: PreviewProvider { - @State private static var name = PersonNameComponents() - - static var previews: some View { - Grid { - NameFieldRow("First Name", name: $name, for: \.givenName, content: .givenName) { - Text("Enter first name ...") - } - } - } -} -#endif diff --git a/Sources/SpeziViews/Views/Fields/NameFields.swift b/Sources/SpeziViews/Views/Fields/NameFields.swift deleted file mode 100644 index 4dec7f2..0000000 --- a/Sources/SpeziViews/Views/Fields/NameFields.swift +++ /dev/null @@ -1,152 +0,0 @@ -// -// This source file is part of the Stanford Spezi open-source project -// -// SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) -// -// SPDX-License-Identifier: MIT -// - -import SwiftUI - - -/// ``NameFields`` provides two text fields in a grid layout that allow users to enter their given and family name and parses the results in a `PersonNameComponents` instance. -public struct NameFields: View { - public enum LocalizationDefaults { - public static var givenName: FieldLocalizationResource { - FieldLocalizationResource( - title: "NAME_FIELD_GIVEN_NAME_TITLE", - placeholder: "NAME_FIELD_GIVEN_NAME_PLACEHOLDER", - bundle: .module - ) - } - public static var familyName: FieldLocalizationResource { - FieldLocalizationResource( - title: "NAME_FIELD_FAMILY_NAME_TITLE", - placeholder: "NAME_FIELD_FAMILY_NAME_PLACEHOLDER", - bundle: .module - ) - } - } - - private let givenNamePlaceholder: LocalizedStringResource - private let familyNamePlaceholder: LocalizedStringResource - - private let givenNameLabel: GivenNameLabel - private let familyNameLabel: FamilyNameLabel - - private let givenNameFocus: FieldFocus? - private let familyNameFocus: FieldFocus? - - @Binding private var name: PersonNameComponents - - - public var body: some View { - Grid { - NameFieldRow(givenNamePlaceholder, name: $name, for: \.givenName, content: .givenName, focus: givenNameFocus) { - givenNameLabel - } - - Divider() - .gridCellUnsizedAxes(.horizontal) - - NameFieldRow(familyNamePlaceholder, name: $name, for: \.familyName, content: .familyName, focus: familyNameFocus) { - familyNameLabel - } - } - } - - - /// ``NameFields`` provides two text fields in a grid layout that allow users to enter their given and family name and parses the results in a `PersonNameComponents` instance. - /// - /// The initializer allows developers to pass in additional `FocusState` information to control and observe the focus state from outside the view. - /// - Parameters: - /// - name: Binding containing the `PersonNameComponents` parsed from the fields. - /// - givenNameField: The localization of the given name field. - /// - givenNameFieldIdentifier: The `FocusState` identifier of the given name field. - /// - familyNameField: The localization of the family name field. - /// - familyNameFieldIdentifier: The `FocusState` identifier of the family name field. - /// - focusedState: `FocusState` binding to control and observe the focus state from outside the view. - public init( // swiftlint:disable:this function_default_parameter_at_end - name: Binding, - givenNameField: FieldLocalizationResource = LocalizationDefaults.givenName, - givenNameFieldIdentifier: FocusedField, - familyNameField: FieldLocalizationResource = LocalizationDefaults.familyName, - familyNameFieldIdentifier: FocusedField, - focusedState: FocusState.Binding - ) where GivenNameLabel == Text, FamilyNameLabel == Text { - self.init( - name: name, - givenNamePlaceholder: givenNameField.placeholder, - givenNameFieldIdentifier: givenNameFieldIdentifier, - familyNamePlaceholder: familyNameField.placeholder, - familyNameFieldIdentifier: familyNameFieldIdentifier, - focusedState: focusedState - ) { // swiftlint:disable:this vertical_parameter_alignment_on_call - Text(givenNameField.title) - } familyName: { - Text(familyNameField.title) - } - } - - /// ``NameFields`` provides two text fields in a grid layout that allow users to enter their given and family name and parses the results in a `PersonNameComponents` instance. - /// - Parameters: - /// - name: Binding containing the `PersonNameComponents` parsed from the fields. - /// - givenNameField: The localization of the given name field. - /// - familyNameField: The localization of the family name field. - public init( - name: Binding, - givenNameField: FieldLocalizationResource = LocalizationDefaults.givenName, - familyNameField: FieldLocalizationResource = LocalizationDefaults.familyName - ) where GivenNameLabel == Text, FamilyNameLabel == Text, FocusedField == UUID { - self._name = name - self.givenNamePlaceholder = givenNameField.placeholder - self.familyNamePlaceholder = familyNameField.placeholder - self.givenNameLabel = Text(givenNameField.title) - self.familyNameLabel = Text(familyNameField.title) - self.givenNameFocus = nil - self.familyNameFocus = nil - } - - /// ``NameFields`` provides two text fields in a grid layout that allow users to enter their given and family name and parses the results in a `PersonNameComponents` instance. - /// - /// The initializer allows developers to pass in additional `FocusState` information to control and observe the focus state from outside the view. - /// - Parameters: - /// - name: Binding containing the `PersonNameComponents` parsed from the fields. - /// - givenNamePlaceholder: The localization of the given name field placeholder. - /// - givenNameFieldIdentifier: The `FocusState` identifier of the given name field. - /// - familyNamePlaceholder: The localization of the family name field placeholder. - /// - familyNameFieldIdentifier: The `FocusState` identifier of the family name field. - /// - focusedState: `FocusState` binding to control and observe the focus state from outside the view. - /// - givenNameLabel: The label presented in front of the given name `TextField`. - /// - familyNameLabel: The label presented in front of the family name `TextField`. - public init( // swiftlint:disable:this function_default_parameter_at_end - name: Binding, - givenNamePlaceholder: LocalizedStringResource = LocalizationDefaults.givenName.placeholder, - givenNameFieldIdentifier: FocusedField, - familyNamePlaceholder: LocalizedStringResource = LocalizationDefaults.familyName.placeholder, - familyNameFieldIdentifier: FocusedField, - focusedState: FocusState.Binding, - @ViewBuilder givenName givenNameLabel: () -> GivenNameLabel, - @ViewBuilder familyName familyNameLabel: () -> FamilyNameLabel - ) { - self._name = name - self.givenNamePlaceholder = givenNamePlaceholder - self.familyNamePlaceholder = familyNamePlaceholder - self.givenNameFocus = FieldFocus(focusedState: focusedState, fieldIdentifier: givenNameFieldIdentifier) - self.familyNameFocus = FieldFocus(focusedState: focusedState, fieldIdentifier: familyNameFieldIdentifier) - self.givenNameLabel = givenNameLabel() - self.familyNameLabel = familyNameLabel() - } -} - - -#if DEBUG -struct NameFields_Previews: PreviewProvider { - @State private static var name = PersonNameComponents() - - - static var previews: some View { - NameFields(name: $name) - } -} -#endif diff --git a/Sources/SpeziViews/Views/Text/DocumentView.swift b/Sources/SpeziViews/Views/Text/DocumentView.swift deleted file mode 100644 index e6ce491..0000000 --- a/Sources/SpeziViews/Views/Text/DocumentView.swift +++ /dev/null @@ -1,86 +0,0 @@ -// -// This source file is part of the Stanford Spezi open-source project -// -// SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) -// -// SPDX-License-Identifier: MIT -// - -import SwiftUI - - -/// Represents the type of document to be displayed. -public enum DocumentType { - /// A document written in markdown. - case markdown - /// A document written in HTML. - case html -} - -/// A ``DocumentView`` allows the display of a markdown or HTML document. -/// -/// ``` -/// @State var viewState: ViewState = .idle -/// -/// DocumentView( -/// asyncData: { -/// // Load your document from a remote source or disk storage ... -/// try? await Task.sleep(for: .seconds(5)) -/// return Data("This is a *markdown* **example** taking 5 seconds to load.".utf8) -/// }, -/// type: .markdown -/// ) -/// ``` -public struct DocumentView: View { - private let type: DocumentType - private let asyncData: () async -> Data - - @Binding private var state: ViewState - - - public var documentView: AnyView { - switch self.type { - case .markdown: - return AnyView(MarkdownView(asyncMarkdown: asyncData, state: $state)) - case .html: - return AnyView(HTMLView(asyncHTML: asyncData, state: $state)) - } - } - - public var body: some View { - documentView - } - - - /// Creates a ``DocumentView`` that displays the content of a markdown or HTML file as an utf8 representation that is loaded asynchronously. - /// - Parameters: - /// - asyncData: The async closure to load the markdown or html file as an utf8 representation. - /// - type: the type of document (i.e. markdown or html) represented by ``DocumentType``. - /// - state: A `Binding` to observe the ``ViewState`` of the ``DocumentView`` - public init( - asyncData: @escaping () async -> Data, - type: DocumentType, - state: Binding = .constant(.idle) - ) { - self.asyncData = asyncData - self.type = type - self._state = state - } - - /// Creates a ``DocumentView`` that displays the content of a markdown or HTML file as an utf8 representation that is loaded asynchronously. - /// - Parameters: - /// - data: the html or markdown data as a utf8 representation. - /// - type: the type of document (i.e. markdown or html) represented by ``DocumentType``. - /// - state: A `Binding` to observe the ``ViewState`` of the ``DocumentView`` - public init( - data: Data, - type: DocumentType, - state: Binding = .constant(.idle) - ) { - self.init( - asyncData: { data }, - type: type, - state: state - ) - } -} diff --git a/Sources/SpeziViews/Views/Text/HTMLView.swift b/Sources/SpeziViews/Views/Text/HTMLView.swift deleted file mode 100644 index f0b3f57..0000000 --- a/Sources/SpeziViews/Views/Text/HTMLView.swift +++ /dev/null @@ -1,136 +0,0 @@ -// -// This source file is part of the Stanford Spezi open-source project -// -// SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) -// -// SPDX-License-Identifier: MIT -// - -import SwiftUI -import WebKit - -/// An``HTMLView`` allows the display of HTML in a web view including the addition of a header and footer view. -/// -/// ``` -/// @State var viewState: ViewState = .idle -/// -/// HTMLView( -/// asyncHTML: { -/// // Load your HTML from a remote source or disk storage ... -/// try? await Task.sleep(for: .seconds(5)) -/// return Data("This is an HTML example taking 5 seconds to load.".utf8) -/// }, -/// state: $viewState -/// ) -/// ``` -private struct WebView: UIViewRepresentable { - class Coordinator: NSObject, WKNavigationDelegate { - let parent: WebView - - @Binding private var state: ViewState - - - init(_ parent: WebView, state: Binding) { - self.parent = parent - self._state = state - } - - - func webView(_ webView: WKWebView, didFinish navigation: WKNavigation) { - Task { @MainActor in - state = .idle - } - } - - func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation, withError error: Error) { - Task { @MainActor in - state = .error(AnyLocalizedError(error: error)) - } - } - - func webView(_ webView: WKWebView, didFail navigation: WKNavigation, withError error: Error) { - Task { @MainActor in - state = .error(AnyLocalizedError(error: error)) - } - } - } - - - let htmlContent: String - - @Binding var state: ViewState - - - func makeUIView(context: Context) -> WKWebView { - let webView = WKWebView() - webView.navigationDelegate = context.coordinator - return webView - } - - func updateUIView(_ uiView: WKWebView, context: Context) { - uiView.loadHTMLString(htmlContent, baseURL: nil) - } - - func makeCoordinator() -> Coordinator { - Coordinator(self, state: $state) - } -} - - -public struct HTMLView: View { - private let asyncHTML: () async -> Data - - @State private var html: Data? - @Binding private var state: ViewState - - - private var htmlString: String? { - guard let html else { - return nil - } - return String(decoding: html, as: UTF8.self) - } - - public var body: some View { - VStack { - if html == nil { - ProgressView() - .padding() - } else { - if let htmlString { - WebView(htmlContent: htmlString, state: $state) - } - } - } - .task { - state = .processing - html = await asyncHTML() - } - } - - /// Creates an ``HTMLView`` that displays HTML that is loaded asynchronously. - /// - Parameters: - /// - asyncHTML: The async closure to load the html in an utf8 representation. - /// - state: A `Binding` to observe the ``ViewState`` of the ``HTMLView``. - public init( - asyncHTML: @escaping () async -> Data, - state: Binding = .constant(.processing) - ) { - self.asyncHTML = asyncHTML - self._state = state - } - - /// Creates an ``HTMLView`` that displays the HTML content. - /// - Parameters: - /// - html: A `Data` instance containing the html in an utf8 representation. - /// - state: A `Binding` to observe the ``ViewState`` of the ``HTMLView``. - public init( - html: Data, - state: Binding = .constant(.processing) - ) { - self.init( - asyncHTML: { html }, - state: state - ) - } -} diff --git a/Sources/SpeziViews/Views/Text/Label.swift b/Sources/SpeziViews/Views/Text/Label.swift index 5d2c0e1..16d54af 100644 --- a/Sources/SpeziViews/Views/Text/Label.swift +++ b/Sources/SpeziViews/Views/Text/Label.swift @@ -47,7 +47,7 @@ private struct _Label: UIViewRepresentable { /// A ``Label`` is a SwiftUI-based wrapper around a `UILabel` that allows the usage of an `NSTextAlignment` to e.g. justify the text. public struct Label: View { - private let text: LocalizedStringResource + private let text: TextContent private let textStyle: UIFont.TextStyle private let textAlignment: NSTextAlignment private let textColor: UIColor @@ -68,7 +68,7 @@ public struct Label: View { ) } .accessibilityRepresentation { - Text(text) + Text(verbatim: text.localizedString(for: locale)) } } @@ -87,7 +87,7 @@ public struct Label: View { textColor: UIColor = .label, numberOfLines: Int = 0 ) { - self.text = text + self.text = .localized(text) self.textStyle = textStyle self.textAlignment = textAlignment self.textColor = textColor @@ -101,15 +101,14 @@ public struct Label: View { /// - textAlignment: The `NSTextAlignment` of the `UILabel`. Defaults to `.justified`. /// - textColor: The `UIColor` of the `UILabel`. Defaults to `.label`. /// - numberOfLines: The number of lines allowed of the `UILabel`. Defaults to 0 indicating no limit. - @_disfavoredOverload public init( - _ text: Text, + verbatim text: Text, textStyle: UIFont.TextStyle = .body, textAlignment: NSTextAlignment = .justified, textColor: UIColor = .label, numberOfLines: Int = 0 ) { - self.text = LocalizedStringResource("\(String(text))") + self.text = .string(String(text)) self.textStyle = textStyle self.textAlignment = textAlignment self.textColor = textColor @@ -121,7 +120,7 @@ public struct Label: View { #if DEBUG struct Label_Previews: PreviewProvider { static var previews: some View { - Label("This is very long text that wraps around multiple lines and adjusts the spacing between words accordingly.") + Label(verbatim: "This is very long text that wraps around multiple lines and adjusts the spacing between words accordingly.") } } #endif diff --git a/Sources/SpeziViews/Views/Text/LazyText.swift b/Sources/SpeziViews/Views/Text/LazyText.swift index 1902f74..52fd300 100644 --- a/Sources/SpeziViews/Views/Text/LazyText.swift +++ b/Sources/SpeziViews/Views/Text/LazyText.swift @@ -9,27 +9,33 @@ import SwiftUI +private struct TextLine: Identifiable { + var id: UUID + var line: String +} + + /// A lazy loading text view that is especially useful for larger text files that should not be displayed all at once. /// /// Uses a `LazyVStack` under the hood to load and display the text line-by-line. public struct LazyText: View { - private let text: LocalizedStringResource + private let content: TextContent @Environment(\.locale) private var locale - private var lines: [(id: UUID, text: String)] { - var lines: [(id: UUID, text: String)] = [] - text.localizedString(for: locale).enumerateLines { line, _ in - lines.append((UUID(), line)) + private var lines: [TextLine] { + var lines: [TextLine] = [] + content.localizedString(for: locale).enumerateLines { line, _ in + lines.append(TextLine(id: UUID(), line: line)) } return lines } public var body: some View { LazyVStack(alignment: .leading) { - ForEach(lines, id: \.id) { line in - Text(line.text) + ForEach(lines) { line in + Text(verbatim: line.line) .multilineTextAlignment(.leading) } } @@ -38,15 +44,14 @@ public struct LazyText: View { /// A lazy loading text view that is especially useful for larger text files that should not be displayed all at once. /// - Parameter text: The text without localization that should be displayed in the ``LazyText`` view. - @_disfavoredOverload - public init(text: Text) { - self.text = LocalizedStringResource("\(String(text))") + public init(verbatim text: Text) { + self.content = .string(String(text)) } /// A lazy loading text view that is especially useful for larger text files that should not be displayed all at once. /// - Parameter text: The text that should be displayed in the ``LazyText`` view. - public init(text: LocalizedStringResource) { - self.text = text + public init(_ text: LocalizedStringResource) { + self.content = .localized(text) } } @@ -56,7 +61,7 @@ struct LazyText_Previews: PreviewProvider { static var previews: some View { ScrollView { LazyText( - text: """ + verbatim: """ This is a long text ... And some more lines ... diff --git a/Sources/SpeziViews/Views/Text/TextContent.swift b/Sources/SpeziViews/Views/Text/TextContent.swift new file mode 100644 index 0000000..84f3ecf --- /dev/null +++ b/Sources/SpeziViews/Views/Text/TextContent.swift @@ -0,0 +1,24 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import Foundation + + +enum TextContent { + case string(_ value: String) + case localized(_ value: LocalizedStringResource) + + func localizedString(for locale: Locale) -> String { + switch self { + case let .string(string): + return string + case let .localized(resource): + return resource.localizedString(for: locale) + } + } +} diff --git a/Tests/SpeziViewsTests/LocalStorageTests.swift b/Tests/SpeziViewsTests/SpeziViewsTests.swift similarity index 87% rename from Tests/SpeziViewsTests/LocalStorageTests.swift rename to Tests/SpeziViewsTests/SpeziViewsTests.swift index b031aca..9525673 100644 --- a/Tests/SpeziViewsTests/LocalStorageTests.swift +++ b/Tests/SpeziViewsTests/SpeziViewsTests.swift @@ -10,7 +10,7 @@ import SpeziViews import XCTest -final class LocalStorageTests: XCTestCase { +final class SpeziViewsTests: XCTestCase { func testSpeziViews() async throws { XCTAssert(true) } diff --git a/Tests/UITests/TestApp.xctestplan b/Tests/UITests/TestApp.xctestplan index 6229a4f..3f6fca6 100644 --- a/Tests/UITests/TestApp.xctestplan +++ b/Tests/UITests/TestApp.xctestplan @@ -9,15 +9,6 @@ } ], "defaultOptions" : { - "codeCoverage" : { - "targets" : [ - { - "containerPath" : "container:..\/..", - "identifier" : "SpeziViews", - "name" : "SpeziViews" - } - ] - }, "targetForVariableExpansion" : { "containerPath" : "container:UITests.xcodeproj", "identifier" : "2F6D139128F5F384007C25D6", diff --git a/Tests/UITests/TestApp/Localizable.xcstrings b/Tests/UITests/TestApp/Localizable.xcstrings index ce6f2f9..c4dc27a 100644 --- a/Tests/UITests/TestApp/Localizable.xcstrings +++ b/Tests/UITests/TestApp/Localizable.xcstrings @@ -13,10 +13,7 @@ "Error Description" : { }, - "First Placeholder" : { - - }, - "First Title" : { + "First Name" : { }, "Hello Throwing World" : { @@ -24,23 +21,6 @@ }, "Hello World" : { - }, - "HELLO_WORLD" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Hallo Welt" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Hello World" - } - } - } }, "LABEL_TEXT" : { "localizations" : { @@ -51,6 +31,9 @@ } } } + }, + "Last Name" : { + }, "LAZY_TEXT" : { "localizations" : { @@ -65,13 +48,16 @@ "Reset" : { }, - "Second Placeholder" : { + "Show Tool Picker" : { + + }, + "SpeziPersonalInfo" : { }, - "Second Title" : { + "SpeziViews" : { }, - "Show Tool Picker" : { + "Targets" : { }, "This is a default error description!" : { @@ -79,9 +65,6 @@ }, "This is a label ...\nAn other text. This is longer and we can check if the justified text works as expected. This is a very long text." : { - }, - "This is a long text ...\n\nAnd some more lines ...\n\nAnd a third line ..." : { - }, "View State: %@" : { diff --git a/Tests/UITests/TestApp/ViewsTests/NameFieldsTestView.swift b/Tests/UITests/TestApp/PersonalInfoTests/NameFieldsTestView.swift similarity index 53% rename from Tests/UITests/TestApp/ViewsTests/NameFieldsTestView.swift rename to Tests/UITests/TestApp/PersonalInfoTests/NameFieldsTestView.swift index d3e7616..ffaa6d7 100644 --- a/Tests/UITests/TestApp/ViewsTests/NameFieldsTestView.swift +++ b/Tests/UITests/TestApp/PersonalInfoTests/NameFieldsTestView.swift @@ -6,29 +6,38 @@ // SPDX-License-Identifier: MIT // -import SpeziViews +import SpeziPersonalInfo import SwiftUI struct NameFieldsTestView: View { @State var name = PersonNameComponents() - - @FocusState var focus: String? var body: some View { VStack { - NameFields( - name: $name, - givenNameField: FieldLocalizationResource(title: "First Title", placeholder: "First Placeholder"), - familyNameField: FieldLocalizationResource(title: "Second Title", placeholder: "Second Placeholder") - ) - .padding(32) Form { - NameFields(name: $name, givenNameFieldIdentifier: "givenName", familyNameFieldIdentifier: "familyName", focusedState: $focus) + nameFields } } .navigationBarTitleDisplayMode(.inline) } + + + @ViewBuilder + private var nameFields: some View { + Grid { + NameFieldRow("First Name", name: $name, for: \.givenName) { + Text(verbatim: "enter your first name") + } + + Divider() + .gridCellUnsizedAxes(.horizontal) + + NameFieldRow("Last Name", name: $name, for: \.familyName) { + Text(verbatim: "enter your last name") + } + } + } } diff --git a/Tests/UITests/TestApp/PersonalInfoTests/SpeziPersonalInfoTests.swift b/Tests/UITests/TestApp/PersonalInfoTests/SpeziPersonalInfoTests.swift new file mode 100644 index 0000000..02fd31b --- /dev/null +++ b/Tests/UITests/TestApp/PersonalInfoTests/SpeziPersonalInfoTests.swift @@ -0,0 +1,54 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import SpeziPersonalInfo +import SwiftUI +import XCTestApp + + +enum SpeziPersonalInfoTests: String, TestAppTests { + case nameFields = "Name Fields" + case userProfile = "User Profile" + + @ViewBuilder + private var nameFields: some View { + NameFieldsTestView() + } + + @ViewBuilder + private var userProfile: some View { + UserProfileView( + name: PersonNameComponents(givenName: "Paul", familyName: "Schmiedmayer") + ) + .frame(width: 100) + UserProfileView( + name: PersonNameComponents(givenName: "Leland", familyName: "Stanford"), + imageLoader: { + try? await Task.sleep(for: .seconds(3)) + return Image(systemName: "person.crop.artframe") + } + ) + .frame(width: 200) + } + + + func view(withNavigationPath path: Binding) -> some View { + switch self { + case .nameFields: + nameFields + case .userProfile: + userProfile + } + } +} + +#if DEBUG +#Preview { + TestAppTestsView() +} +#endif diff --git a/Tests/UITests/TestApp/SpeziViewsTargetsTests.swift b/Tests/UITests/TestApp/SpeziViewsTargetsTests.swift new file mode 100644 index 0000000..9bb7ab3 --- /dev/null +++ b/Tests/UITests/TestApp/SpeziViewsTargetsTests.swift @@ -0,0 +1,44 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import SwiftUI +import XCTestApp + + +struct SpeziViewsTargetsTests: View { + @State var presentingSpeziViews = false + @State var presentingSpeziPersonalInfo = false + + + var body: some View { + NavigationStack { + List { + Button("SpeziViews") { + presentingSpeziViews = true + } + Button("SpeziPersonalInfo") { + presentingSpeziPersonalInfo = true + } + } + .navigationTitle("Targets") + } + .sheet(isPresented: $presentingSpeziViews) { + TestAppTestsView() + } + .sheet(isPresented: $presentingSpeziPersonalInfo) { + TestAppTestsView() + } + } +} + + +#if DEBUG +#Preview { + SpeziViewsTargetsTests() +} +#endif diff --git a/Tests/UITests/TestApp/TestApp.swift b/Tests/UITests/TestApp/TestApp.swift index 5755be9..a8e1577 100644 --- a/Tests/UITests/TestApp/TestApp.swift +++ b/Tests/UITests/TestApp/TestApp.swift @@ -14,7 +14,7 @@ import XCTestApp struct UITestsApp: App { var body: some Scene { WindowGroup { - TestAppTestsView() + SpeziViewsTargetsTests() } } } diff --git a/Tests/UITests/TestApp/ViewsTests/CanvasTestView.swift b/Tests/UITests/TestApp/ViewsTests/CanvasTestView.swift index c0fc984..0da0ad7 100644 --- a/Tests/UITests/TestApp/ViewsTests/CanvasTestView.swift +++ b/Tests/UITests/TestApp/ViewsTests/CanvasTestView.swift @@ -32,8 +32,8 @@ struct CanvasTestView: View { showToolPicker: $showToolPicker ) } - .onChange(of: isDrawing) { newValue in - if newValue { + .onChange(of: isDrawing) { + if isDrawing { didDrawAnything = true } } diff --git a/Tests/UITests/TestApp/ViewsTests/HTMLViewTestView.swift b/Tests/UITests/TestApp/ViewsTests/HTMLViewTestView.swift deleted file mode 100644 index 2b0bd57..0000000 --- a/Tests/UITests/TestApp/ViewsTests/HTMLViewTestView.swift +++ /dev/null @@ -1,35 +0,0 @@ -// -// This source file is part of the Stanford Spezi open-source project -// -// SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) -// -// SPDX-License-Identifier: MIT -// - -import SpeziViews -import SwiftUI - -struct HTMLViewTestView: View { - @State var viewState: ViewState = .idle - - var body: some View { - DocumentView( - asyncData: { - try? await Task.sleep(for: .seconds(5)) - return Data("This is an HTML example taking 5 seconds to load.".utf8) - }, - type: .html - ) - HTMLView( - html: Data("This is an HTML example.".utf8) - ) - } -} - -#if DEBUG -struct HTMLViewTestView_Previews: PreviewProvider { - static var previews: some View { - HTMLViewTestView() - } -} -#endif diff --git a/Tests/UITests/TestApp/ViewsTests/MarkdownViewTestView.swift b/Tests/UITests/TestApp/ViewsTests/MarkdownViewTestView.swift index 4a67dd8..9ce591b 100644 --- a/Tests/UITests/TestApp/ViewsTests/MarkdownViewTestView.swift +++ b/Tests/UITests/TestApp/ViewsTests/MarkdownViewTestView.swift @@ -14,12 +14,11 @@ struct MarkdownViewTestView: View { @State var viewState: ViewState = .idle var body: some View { - DocumentView( - asyncData: { + MarkdownView( + asyncMarkdown: { try? await Task.sleep(for: .seconds(5)) return Data("This is a *markdown* **example** taking 5 seconds to load.".utf8) - }, - type: .markdown + } ) MarkdownView( markdown: Data("This is a *markdown* **example**.".utf8) diff --git a/Tests/UITests/TestApp/SpeziViewsTests.swift b/Tests/UITests/TestApp/ViewsTests/SpeziViewsTests.swift similarity index 69% rename from Tests/UITests/TestApp/SpeziViewsTests.swift rename to Tests/UITests/TestApp/ViewsTests/SpeziViewsTests.swift index 227c666..4cea617 100644 --- a/Tests/UITests/TestApp/SpeziViewsTests.swift +++ b/Tests/UITests/TestApp/ViewsTests/SpeziViewsTests.swift @@ -13,13 +13,10 @@ import XCTestApp enum SpeziViewsTests: String, TestAppTests { case canvas = "Canvas" - case nameFields = "Name Fields" - case userProfile = "User Profile" case geometryReader = "Geometry Reader" case label = "Label" case lazyText = "Lazy Text" case markdownView = "Markdown View" - case htmlView = "HTML View" case viewState = "View State" case defaultErrorOnly = "Default Error Only" case defaultErrorDescription = "Default Error Description" @@ -31,27 +28,6 @@ enum SpeziViewsTests: String, TestAppTests { CanvasTestView() } - @ViewBuilder - private var nameFields: some View { - NameFieldsTestView() - } - - @ViewBuilder - private var userProfile: some View { - UserProfileView( - name: PersonNameComponents(givenName: "Paul", familyName: "Schmiedmayer") - ) - .frame(width: 100) - UserProfileView( - name: PersonNameComponents(givenName: "Leland", familyName: "Stanford"), - imageLoader: { - try? await Task.sleep(for: .seconds(3)) - return Image(systemName: "person.crop.artframe") - } - ) - .frame(width: 200) - } - @ViewBuilder private var geometryReader: some View { GeometryReaderTestView() @@ -80,17 +56,12 @@ enum SpeziViewsTests: String, TestAppTests { private var markdownView: some View { MarkdownViewTestView() } - - @ViewBuilder - private var htmlView: some View { - HTMLViewTestView() - } @ViewBuilder private var lazyText: some View { ScrollView { LazyText( - text: """ + verbatim: """ This is a long text ... And some more lines ... @@ -98,7 +69,7 @@ enum SpeziViewsTests: String, TestAppTests { And a third line ... """ ) - LazyText(text: "LAZY_TEXT") + LazyText("LAZY_TEXT") } } @@ -123,15 +94,10 @@ enum SpeziViewsTests: String, TestAppTests { } - // swiftlint:disable:next cyclomatic_complexity func view(withNavigationPath path: Binding) -> some View { switch self { case .canvas: canvas - case .nameFields: - nameFields - case .userProfile: - userProfile case .geometryReader: geometryReader case .label: @@ -140,8 +106,6 @@ enum SpeziViewsTests: String, TestAppTests { lazyText case .markdownView: markdownView - case .htmlView: - htmlView case .viewState: viewState case .defaultErrorOnly: @@ -154,10 +118,9 @@ enum SpeziViewsTests: String, TestAppTests { } } + #if DEBUG -struct SpeziViewsTests_Previews: PreviewProvider { - static var previews: some View { - TestAppTestsView() - } +#Preview { + TestAppTestsView() } #endif diff --git a/Tests/UITests/TestAppUITests/SpeziPersonalInfo/PersonalInfoViewsTests.swift b/Tests/UITests/TestAppUITests/SpeziPersonalInfo/PersonalInfoViewsTests.swift new file mode 100644 index 0000000..8d09ed3 --- /dev/null +++ b/Tests/UITests/TestAppUITests/SpeziPersonalInfo/PersonalInfoViewsTests.swift @@ -0,0 +1,52 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import XCTest +import XCTestExtensions + + +final class PersonalInfoViewsTests: XCTestCase { + override func setUpWithError() throws { + try super.setUpWithError() + + continueAfterFailure = false + + let app = XCUIApplication() + app.launch() + + app.open(target: "SpeziPersonalInfo") + } + + func testNameFields() throws { + let app = XCUIApplication() + + XCTAssert(app.collectionViews.buttons["Name Fields"].waitForExistence(timeout: 2)) + app.collectionViews.buttons["Name Fields"].tap() + + XCTAssert(app.staticTexts["First Name"].waitForExistence(timeout: 2)) + XCTAssert(app.staticTexts["Last Name"].waitForExistence(timeout: 2)) + + try app.textFields["enter your first name"].enter(value: "Leland") + try app.textFields["enter your last name"].enter(value: "Stanford") + + XCTAssert(app.textFields["Leland"].waitForExistence(timeout: 2)) + XCTAssert(app.textFields["Stanford"].waitForExistence(timeout: 2)) + } + + func testUserProfile() throws { + let app = XCUIApplication() + + XCTAssert(app.collectionViews.buttons["User Profile"].waitForExistence(timeout: 2)) + app.collectionViews.buttons["User Profile"].tap() + + XCTAssertTrue(app.staticTexts["PS"].waitForExistence(timeout: 2)) + XCTAssertTrue(app.staticTexts["LS"].exists) + + XCTAssertTrue(app.images["person.crop.artframe"].waitForExistence(timeout: 5)) + } +} diff --git a/Tests/UITests/TestAppUITests/EnvironmentTests.swift b/Tests/UITests/TestAppUITests/SpeziViews/EnvironmentTests.swift similarity index 84% rename from Tests/UITests/TestAppUITests/EnvironmentTests.swift rename to Tests/UITests/TestAppUITests/SpeziViews/EnvironmentTests.swift index 297a6dc..b9b99aa 100644 --- a/Tests/UITests/TestAppUITests/EnvironmentTests.swift +++ b/Tests/UITests/TestAppUITests/SpeziViews/EnvironmentTests.swift @@ -11,10 +11,20 @@ import XCTestExtensions final class EnvironmentTests: XCTestCase { - func testDefaultErrorDescription() throws { + override func setUpWithError() throws { + try super.setUpWithError() + + continueAfterFailure = false + let app = XCUIApplication() app.launch() + app.open(target: "SpeziViews") + } + + func testDefaultErrorDescription() throws { + let app = XCUIApplication() + XCTAssert(app.collectionViews.buttons["Default Error Description"].waitForExistence(timeout: 2)) app.collectionViews.buttons["Default Error Description"].tap() diff --git a/Tests/UITests/TestAppUITests/ModelTests.swift b/Tests/UITests/TestAppUITests/SpeziViews/ModelTests.swift similarity index 89% rename from Tests/UITests/TestAppUITests/ModelTests.swift rename to Tests/UITests/TestAppUITests/SpeziViews/ModelTests.swift index 8d4c81e..5ef5988 100644 --- a/Tests/UITests/TestAppUITests/ModelTests.swift +++ b/Tests/UITests/TestAppUITests/SpeziViews/ModelTests.swift @@ -11,10 +11,20 @@ import XCTestExtensions final class ModelTests: XCTestCase { - func testViewState() throws { + override func setUpWithError() throws { + try super.setUpWithError() + + continueAfterFailure = false + let app = XCUIApplication() app.launch() + app.open(target: "SpeziViews") + } + + func testViewState() throws { + let app = XCUIApplication() + XCTAssert(app.collectionViews.buttons["View State"].waitForExistence(timeout: 2)) app.collectionViews.buttons["View State"].tap() @@ -33,7 +43,6 @@ final class ModelTests: XCTestCase { func testDefaultErrorDescription() throws { let app = XCUIApplication() - app.launch() XCTAssert(app.collectionViews.buttons["Default Error Only"].waitForExistence(timeout: 2)) app.collectionViews.buttons["Default Error Only"].tap() diff --git a/Tests/UITests/TestAppUITests/ViewsTests.swift b/Tests/UITests/TestAppUITests/SpeziViews/ViewsTests.swift similarity index 69% rename from Tests/UITests/TestAppUITests/ViewsTests.swift rename to Tests/UITests/TestAppUITests/SpeziViews/ViewsTests.swift index b7b6939..94d6aef 100644 --- a/Tests/UITests/TestAppUITests/ViewsTests.swift +++ b/Tests/UITests/TestAppUITests/SpeziViews/ViewsTests.swift @@ -11,14 +11,24 @@ import XCTestExtensions final class ViewsTests: XCTestCase { + override func setUpWithError() throws { + try super.setUpWithError() + + continueAfterFailure = false + + let app = XCUIApplication() + app.launch() + + app.open(target: "SpeziViews") + } + func testCanvas() throws { #if targetEnvironment(simulator) && (arch(i386) || arch(x86_64)) throw XCTSkip("PKCanvas view-related tests are currently skipped on Intel-based iOS simulators due to a metal bug on the simulator.") #endif let app = XCUIApplication() - app.launch() - + XCTAssert(app.collectionViews.buttons["Canvas"].waitForExistence(timeout: 2)) app.collectionViews.buttons["Canvas"].tap() @@ -36,7 +46,8 @@ final class ViewsTests: XCTestCase { XCTAssert(app.scrollViews.otherElements.images["palette_tool_pencil_base"].waitForExistence(timeout: 10)) canvasView.swipeLeft() - + + sleep(1) app.buttons["Show Tool Picker"].tap() sleep(15) // waitForExistence will otherwise return immediately @@ -44,44 +55,8 @@ final class ViewsTests: XCTestCase { canvasView.swipeUp() } - func testNameFields() throws { - let app = XCUIApplication() - app.launch() - - XCTAssert(app.collectionViews.buttons["Name Fields"].waitForExistence(timeout: 2)) - app.collectionViews.buttons["Name Fields"].tap() - - XCTAssert(app.staticTexts["First Title"].waitForExistence(timeout: 2)) - XCTAssert(app.staticTexts["Second Title"].waitForExistence(timeout: 2)) - XCTAssert(app.staticTexts["First Name"].waitForExistence(timeout: 2)) - XCTAssert(app.staticTexts["Last Name"].waitForExistence(timeout: 2)) - - try app.textFields["First Placeholder"].enter(value: "Le") - try app.textFields["Second Placeholder"].enter(value: "Stan") - - try app.textFields["Enter your first name ..."].enter(value: "land") - try app.textFields["Enter your last name ..."].enter(value: "ford") - - XCTAssert(app.textFields["Leland"].waitForExistence(timeout: 2)) - XCTAssert(app.textFields["Stanford"].waitForExistence(timeout: 2)) - } - - func testUserProfile() throws { - let app = XCUIApplication() - app.launch() - - XCTAssert(app.collectionViews.buttons["User Profile"].waitForExistence(timeout: 2)) - app.collectionViews.buttons["User Profile"].tap() - - XCTAssertTrue(app.staticTexts["PS"].waitForExistence(timeout: 2)) - XCTAssertTrue(app.staticTexts["LS"].exists) - - XCTAssertTrue(app.images["person.crop.artframe"].waitForExistence(timeout: 5)) - } - func testGeometryReader() throws { let app = XCUIApplication() - app.launch() XCTAssert(app.collectionViews.buttons["Geometry Reader"].waitForExistence(timeout: 2)) app.collectionViews.buttons["Geometry Reader"].tap() @@ -92,7 +67,6 @@ final class ViewsTests: XCTestCase { func testLabel() throws { let app = XCUIApplication() - app.launch() XCTAssert(app.collectionViews.buttons["Label"].waitForExistence(timeout: 2)) app.collectionViews.buttons["Label"].tap() @@ -107,7 +81,6 @@ final class ViewsTests: XCTestCase { func testLazyText() throws { let app = XCUIApplication() - app.launch() XCTAssert(app.collectionViews.buttons["Lazy Text"].waitForExistence(timeout: 2)) app.collectionViews.buttons["Lazy Text"].tap() @@ -120,7 +93,6 @@ final class ViewsTests: XCTestCase { func testMarkdownView() throws { let app = XCUIApplication() - app.launch() XCTAssert(app.collectionViews.buttons["Markdown View"].waitForExistence(timeout: 2)) app.collectionViews.buttons["Markdown View"].tap() @@ -131,21 +103,9 @@ final class ViewsTests: XCTestCase { XCTAssert(app.staticTexts["This is a markdown example taking 5 seconds to load."].exists) } - - func testHTMLView() throws { - let app = XCUIApplication() - app.launch() - - XCTAssert(app.collectionViews.buttons["HTML View"].waitForExistence(timeout: 2)) - app.collectionViews.buttons["HTML View"].tap() - - XCTAssert(app.webViews.staticTexts["This is an HTML example."].waitForExistence(timeout: 30)) - XCTAssert(app.staticTexts["This is an HTML example taking 5 seconds to load."].waitForExistence(timeout: 20)) - } func testAsyncButtonView() throws { let app = XCUIApplication() - app.launch() XCTAssert(app.collectionViews.buttons["Async Button"].waitForExistence(timeout: 2)) app.collectionViews.buttons["Async Button"].tap() diff --git a/Tests/UITests/TestAppUITests/XCUIApplication+Targets.swift b/Tests/UITests/TestAppUITests/XCUIApplication+Targets.swift new file mode 100644 index 0000000..a637aa2 --- /dev/null +++ b/Tests/UITests/TestAppUITests/XCUIApplication+Targets.swift @@ -0,0 +1,18 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import XCTest + + +extension XCUIApplication { + func open(target: String) { + XCTAssertTrue(navigationBars.staticTexts["Targets"].waitForExistence(timeout: 6.0)) + XCTAssertTrue(buttons[target].waitForExistence(timeout: 0.5)) + buttons[target].tap() + } +} diff --git a/Tests/UITests/UITests.xcodeproj/project.pbxproj b/Tests/UITests/UITests.xcodeproj/project.pbxproj index 4be9990..17f80fe 100644 --- a/Tests/UITests/UITests.xcodeproj/project.pbxproj +++ b/Tests/UITests/UITests.xcodeproj/project.pbxproj @@ -15,16 +15,21 @@ 2FA9486729DE90720081C086 /* CanvasTestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FA9486029DE90720081C086 /* CanvasTestView.swift */; }; 2FA9486929DE90720081C086 /* NameFieldsTestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FA9486229DE90720081C086 /* NameFieldsTestView.swift */; }; 2FA9486A29DE90720081C086 /* GeometryReaderTestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FA9486329DE90720081C086 /* GeometryReaderTestView.swift */; }; - 2FA9486B29DE90720081C086 /* HTMLViewTestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FA9486429DE90720081C086 /* HTMLViewTestView.swift */; }; 2FA9486D29DE91130081C086 /* ViewsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FA9486C29DE91130081C086 /* ViewsTests.swift */; }; 2FA9486F29DE91A30081C086 /* SpeziViews in Frameworks */ = {isa = PBXBuildFile; productRef = 2FA9486E29DE91A30081C086 /* SpeziViews */; }; 2FB099B82A8AD25300B20952 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 2FB099B72A8AD25100B20952 /* Localizable.xcstrings */; }; 977CF55C2AD2B92C006D9B54 /* XCTestApp in Frameworks */ = {isa = PBXBuildFile; productRef = 977CF55B2AD2B92C006D9B54 /* XCTestApp */; }; - 977CF55E2AD2B92C006D9B54 /* XCTestExtensions in Frameworks */ = {isa = PBXBuildFile; productRef = 977CF55D2AD2B92C006D9B54 /* XCTestExtensions */; }; + A95B6E652AF4298500919504 /* SpeziPersonalInfo in Frameworks */ = {isa = PBXBuildFile; productRef = A95B6E642AF4298500919504 /* SpeziPersonalInfo */; }; + A963ACB22AF4709400D745F2 /* XCUIApplication+Targets.swift in Sources */ = {isa = PBXBuildFile; fileRef = A963ACB12AF4709400D745F2 /* XCUIApplication+Targets.swift */; }; 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 */; }; + A9F9C4682AF2B9DD001122DD /* (null) in Frameworks */ = {isa = PBXBuildFile; }; + A9F9C4692AF2B9DD001122DD /* XCTestExtensions in Frameworks */ = {isa = PBXBuildFile; productRef = 977CF55D2AD2B92C006D9B54 /* XCTestExtensions */; }; + A9FBAE952AF445B6001E4AF1 /* SpeziViewsTargetsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9FBAE942AF445B6001E4AF1 /* SpeziViewsTargetsTests.swift */; }; + A9FBAE982AF446F3001E4AF1 /* SpeziPersonalInfoTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9FBAE972AF446F3001E4AF1 /* SpeziPersonalInfoTests.swift */; }; + A9FBAE9C2AF44CCB001E4AF1 /* PersonalInfoViewsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9FBAE9B2AF44CCB001E4AF1 /* PersonalInfoViewsTests.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -49,14 +54,17 @@ 2FA9486029DE90720081C086 /* CanvasTestView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CanvasTestView.swift; sourceTree = ""; }; 2FA9486229DE90720081C086 /* NameFieldsTestView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NameFieldsTestView.swift; sourceTree = ""; }; 2FA9486329DE90720081C086 /* GeometryReaderTestView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeometryReaderTestView.swift; sourceTree = ""; }; - 2FA9486429DE90720081C086 /* HTMLViewTestView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HTMLViewTestView.swift; sourceTree = ""; }; 2FA9486C29DE91130081C086 /* ViewsTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ViewsTests.swift; sourceTree = ""; }; 2FB0758A299DDB9000C0B37F /* TestApp.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = TestApp.xctestplan; sourceTree = ""; }; 2FB099B72A8AD25100B20952 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; + A963ACB12AF4709400D745F2 /* XCUIApplication+Targets.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "XCUIApplication+Targets.swift"; sourceTree = ""; }; 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 = ""; }; + A9FBAE942AF445B6001E4AF1 /* SpeziViewsTargetsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpeziViewsTargetsTests.swift; sourceTree = ""; }; + A9FBAE972AF446F3001E4AF1 /* SpeziPersonalInfoTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpeziPersonalInfoTests.swift; sourceTree = ""; }; + A9FBAE9B2AF44CCB001E4AF1 /* PersonalInfoViewsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersonalInfoViewsTests.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -66,6 +74,7 @@ files = ( 2FA9486F29DE91A30081C086 /* SpeziViews in Frameworks */, 977CF55C2AD2B92C006D9B54 /* XCTestApp in Frameworks */, + A95B6E652AF4298500919504 /* SpeziPersonalInfo in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -73,7 +82,8 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 977CF55C2AD2B92C006D9B54 /* XCTestExtensions in Frameworks */, + A9F9C4692AF2B9DD001122DD /* XCTestExtensions in Frameworks */, + A9F9C4682AF2B9DD001122DD /* (null) in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -105,7 +115,8 @@ isa = PBXGroup; children = ( 2FA7382B290ADFAA007ACEB9 /* TestApp.swift */, - 2F2D338629DE52EA00081B1D /* SpeziViewsTests.swift */, + A9FBAE942AF445B6001E4AF1 /* SpeziViewsTargetsTests.swift */, + A9FBAE962AF446B2001E4AF1 /* PersonalInfoTests */, 2FA9485D29DE90710081C086 /* ViewsTests */, 2F6D139928F5F386007C25D6 /* Assets.xcassets */, 2FB099B72A8AD25100B20952 /* Localizable.xcstrings */, @@ -116,9 +127,9 @@ 2F6D13AF28F5F386007C25D6 /* TestAppUITests */ = { isa = PBXGroup; children = ( - 2FA9486C29DE91130081C086 /* ViewsTests.swift */, - A97880962A4C4E6500150B2F /* ModelTests.swift */, - A978809A2A4C52F100150B2F /* EnvironmentTests.swift */, + A963ACB12AF4709400D745F2 /* XCUIApplication+Targets.swift */, + A9FBAE9A2AF44CB1001E4AF1 /* SpeziPersonalInfo */, + A9FBAE992AF44CAC001E4AF1 /* SpeziViews */, ); path = TestAppUITests; sourceTree = ""; @@ -133,18 +144,44 @@ 2FA9485D29DE90710081C086 /* ViewsTests */ = { isa = PBXGroup; children = ( + 2F2D338629DE52EA00081B1D /* SpeziViewsTests.swift */, 2FA9485E29DE90720081C086 /* ViewStateTestView.swift */, 2FA9485F29DE90720081C086 /* MarkdownViewTestView.swift */, 2FA9486029DE90720081C086 /* CanvasTestView.swift */, - 2FA9486229DE90720081C086 /* NameFieldsTestView.swift */, 2FA9486329DE90720081C086 /* GeometryReaderTestView.swift */, - 2FA9486429DE90720081C086 /* HTMLViewTestView.swift */, A97880982A4C524D00150B2F /* DefaultErrorDescriptionTestView.swift */, A998A94E2A609A9E0030624D /* AsyncButtonTestView.swift */, ); path = ViewsTests; sourceTree = ""; }; + A9FBAE962AF446B2001E4AF1 /* PersonalInfoTests */ = { + isa = PBXGroup; + children = ( + A9FBAE972AF446F3001E4AF1 /* SpeziPersonalInfoTests.swift */, + 2FA9486229DE90720081C086 /* NameFieldsTestView.swift */, + ); + path = PersonalInfoTests; + sourceTree = ""; + }; + A9FBAE992AF44CAC001E4AF1 /* SpeziViews */ = { + isa = PBXGroup; + children = ( + 2FA9486C29DE91130081C086 /* ViewsTests.swift */, + A97880962A4C4E6500150B2F /* ModelTests.swift */, + A978809A2A4C52F100150B2F /* EnvironmentTests.swift */, + ); + path = SpeziViews; + sourceTree = ""; + }; + A9FBAE9A2AF44CB1001E4AF1 /* SpeziPersonalInfo */ = { + isa = PBXGroup; + children = ( + A9FBAE9B2AF44CCB001E4AF1 /* PersonalInfoViewsTests.swift */, + ); + path = SpeziPersonalInfo; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -164,6 +201,7 @@ packageProductDependencies = ( 2FA9486E29DE91A30081C086 /* SpeziViews */, 977CF55B2AD2B92C006D9B54 /* XCTestApp */, + A95B6E642AF4298500919504 /* SpeziPersonalInfo */, ); productName = Example; productReference = 2F6D139228F5F384007C25D6 /* TestApp.app */; @@ -256,12 +294,13 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 2FA9486B29DE90720081C086 /* HTMLViewTestView.swift in Sources */, 2FA9486529DE90720081C086 /* ViewStateTestView.swift in Sources */, + A9FBAE952AF445B6001E4AF1 /* SpeziViewsTargetsTests.swift in Sources */, 2FA9486929DE90720081C086 /* NameFieldsTestView.swift in Sources */, A998A94F2A609A9E0030624D /* AsyncButtonTestView.swift in Sources */, 2FA9486A29DE90720081C086 /* GeometryReaderTestView.swift in Sources */, 2FA9486729DE90720081C086 /* CanvasTestView.swift in Sources */, + A9FBAE982AF446F3001E4AF1 /* SpeziPersonalInfoTests.swift in Sources */, 2FA7382C290ADFAA007ACEB9 /* TestApp.swift in Sources */, 2FA9486629DE90720081C086 /* MarkdownViewTestView.swift in Sources */, A97880992A4C524D00150B2F /* DefaultErrorDescriptionTestView.swift in Sources */, @@ -273,7 +312,9 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + A9FBAE9C2AF44CCB001E4AF1 /* PersonalInfoViewsTests.swift in Sources */, 2FA9486D29DE91130081C086 /* ViewsTests.swift in Sources */, + A963ACB22AF4709400D745F2 /* XCUIApplication+Targets.swift in Sources */, A97880972A4C4E6500150B2F /* ModelTests.swift in Sources */, A978809B2A4C52F100150B2F /* EnvironmentTests.swift in Sources */, ); @@ -340,7 +381,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 16.0; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; @@ -394,7 +435,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 16.0; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; SDKROOT = iphoneos; @@ -557,7 +598,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 16.0; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; @@ -680,6 +721,10 @@ package = 977CF55A2AD2B92C006D9B54 /* XCRemoteSwiftPackageReference "XCTestExtensions" */; productName = XCTestExtensions; }; + A95B6E642AF4298500919504 /* SpeziPersonalInfo */ = { + isa = XCSwiftPackageProductDependency; + productName = SpeziPersonalInfo; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 2F6D138A28F5F384007C25D6 /* Project object */; diff --git a/Tests/UITests/UITests.xcodeproj/xcshareddata/xcschemes/TestApp.xcscheme b/Tests/UITests/UITests.xcodeproj/xcshareddata/xcschemes/TestApp.xcscheme index 7eb3d1a..b07bb64 100644 --- a/Tests/UITests/UITests.xcodeproj/xcshareddata/xcschemes/TestApp.xcscheme +++ b/Tests/UITests/UITests.xcodeproj/xcshareddata/xcschemes/TestApp.xcscheme @@ -9,43 +9,61 @@ + + + + + buildForRunning = "NO" + buildForProfiling = "NO" + buildForArchiving = "NO" + buildForAnalyzing = "NO"> + BlueprintIdentifier = "SpeziViews" + BuildableName = "SpeziViews" + BlueprintName = "SpeziViews" + ReferencedContainer = "container:../.."> + shouldUseLaunchSchemeArgsEnv = "YES"> + + + + + skipped = "NO"> - -