diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index db4eb4c..38933a1 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: SpeziQuestionnaire.xcresult + artifactname: SpeziQuestionnaire-Package.xcresult runsonlabels: '["macOS", "self-hosted"]' - scheme: SpeziQuestionnaire + scheme: SpeziQuestionnaire-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: SpeziQuestionnaire.xcresult TestApp.xcresult + coveragereports: SpeziQuestionnaire-Package.xcresult TestApp.xcresult diff --git a/.spi.yml b/.spi.yml index a691f8c..008b0cc 100644 --- a/.spi.yml +++ b/.spi.yml @@ -12,3 +12,4 @@ builder: - platform: ios documentation_targets: - SpeziQuestionnaire + - SpeziTimedWalkTest diff --git a/Package.swift b/Package.swift index 9fea6e3..cbf718a 100644 --- a/Package.swift +++ b/Package.swift @@ -13,16 +13,19 @@ import PackageDescription let package = Package( name: "SpeziQuestionnaire", + defaultLocalization: "en", platforms: [ .iOS(.v17) ], products: [ - .library(name: "SpeziQuestionnaire", targets: ["SpeziQuestionnaire"]) + .library(name: "SpeziQuestionnaire", targets: ["SpeziQuestionnaire"]), + .library(name: "SpeziTimedWalkTest", targets: ["SpeziTimedWalkTest"]) ], dependencies: [ .package(url: "https://github.com/StanfordSpezi/Spezi", from: "1.0.0"), + .package(url: "https://github.com/StanfordSpezi/SpeziViews", from: "1.0.0"), .package(url: "https://github.com/apple/FHIRModels", .upToNextMinor(from: "0.5.0")), - .package(url: "https://github.com/StanfordBDHG/ResearchKit", from: "2.2.25"), + .package(url: "https://github.com/StanfordBDHG/ResearchKit", from: "2.2.28"), .package(url: "https://github.com/StanfordBDHG/ResearchKitOnFHIR", from: "1.1.0") ], targets: [ @@ -42,6 +45,14 @@ let package = Package( dependencies: [ .target(name: "SpeziQuestionnaire") ] + ), + .target( + name: "SpeziTimedWalkTest", + dependencies: [ + .product(name: "Spezi", package: "Spezi"), + .product(name: "SpeziViews", package: "SpeziViews"), + .product(name: "ModelsR4", package: "FHIRModels") + ] ) ] ) diff --git a/Sources/SpeziQuestionnaire/QuestionnaireView.swift b/Sources/SpeziQuestionnaire/QuestionnaireView.swift index 2c88030..6dff988 100644 --- a/Sources/SpeziQuestionnaire/QuestionnaireView.swift +++ b/Sources/SpeziQuestionnaire/QuestionnaireView.swift @@ -21,7 +21,7 @@ import SwiftUI /// ```swift /// struct ExampleQuestionnaireView: View { /// @State var displayQuestionnaire = false -/// +/// /// /// var body: some View { /// Button("Display Questionnaire") { @@ -42,11 +42,12 @@ public struct QuestionnaireView: View { private let questionnaire: Questionnaire private let questionnaireResult: (QuestionnaireResult) async -> Void private let completionStepMessage: String? + private let cancelBehavior: CancelBehavior public var body: some View { if let task = createTask(questionnaire: questionnaire) { - ORKOrderedTaskView(tasks: task, tintColor: .accentColor, result: handleResult) + ORKOrderedTaskView(tasks: task, tintColor: .accentColor, cancelBehavior: cancelBehavior, result: handleResult) .ignoresSafeArea(.container, edges: .bottom) .ignoresSafeArea(.keyboard, edges: .bottom) .interactiveDismissDisabled() @@ -59,22 +60,25 @@ public struct QuestionnaireView: View { /// - Parameters: /// - questionnaire: The `Questionnaire` that should be displayed. /// - completionStepMessage: Optional completion message that can be appended at the end of the questionnaire. + /// - cancelBehavior: The cancel behavior of view. The default setting allows cancellation and asks for confirmation before the view is dismissed. /// - questionnaireResult: Result closure that processes the ``QuestionnaireResult``. public init( questionnaire: Questionnaire, completionStepMessage: String? = nil, + cancelBehavior: CancelBehavior = .shouldConfirmCancel, questionnaireResult: @escaping @MainActor (QuestionnaireResult) async -> Void ) { self.questionnaire = questionnaire self.completionStepMessage = completionStepMessage + self.cancelBehavior = cancelBehavior self.questionnaireResult = questionnaireResult } - + + private func handleResult(_ result: TaskResult) async { let questionnaireResult: QuestionnaireResult switch result { case let .completed(result): - let fhirResponse = result.fhirResponse questionnaireResult = .completed(result.fhirResponse) case .cancelled: questionnaireResult = .cancelled diff --git a/Sources/SpeziQuestionnaire/Resources/Localizable.xcstrings b/Sources/SpeziQuestionnaire/Resources/Localizable.xcstrings new file mode 100644 index 0000000..55be495 --- /dev/null +++ b/Sources/SpeziQuestionnaire/Resources/Localizable.xcstrings @@ -0,0 +1,17 @@ +{ + "sourceLanguage" : "en", + "strings" : { + "QUESTIONNAIRE_LOADING_ERROR_MESSAGE" : { + "comment" : "This is a string that is balh", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Questionnaire could not be loaded." + } + } + } + } + }, + "version" : "1.0" +} \ No newline at end of file diff --git a/Sources/SpeziQuestionnaire/Resources/Localizable.xcstrings.license b/Sources/SpeziQuestionnaire/Resources/Localizable.xcstrings.license new file mode 100644 index 0000000..a3bb08d --- /dev/null +++ b/Sources/SpeziQuestionnaire/Resources/Localizable.xcstrings.license @@ -0,0 +1,5 @@ +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 \ No newline at end of file diff --git a/Sources/SpeziTimedWalkTest/ObservationExtension.swift b/Sources/SpeziTimedWalkTest/ObservationExtension.swift new file mode 100644 index 0000000..fdac015 --- /dev/null +++ b/Sources/SpeziTimedWalkTest/ObservationExtension.swift @@ -0,0 +1,101 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) +// Based on https://github.com/StanfordBDHG/HealthKitOnFHIR/blob/main/Sources/HealthKitOnFHIR/Observation%20Extensions/Observation%2BCollections.swift +// +// SPDX-License-Identifier: MIT +// + +import Foundation +import HealthKit +import ModelsR4 + + +extension Observation { + private func appendElement(_ element: T, to collection: ReferenceWritableKeyPath) { + // swiftlint:disable:previous discouraged_optional_collection + // Unfortunately we need to use an optional collection here as the ModelsR4 modules uses optional collections in the Observation type. + + guard self[keyPath: collection] != nil else { + self[keyPath: collection] = [element] + return + } + + self[keyPath: collection]?.append(element) + } + + private func appendElements(_ elements: [T], to collection: ReferenceWritableKeyPath) { + // swiftlint:disable:previous discouraged_optional_collection + // Unfortunately we need to use an optional collection here as the ModelsR4 modules uses optional collections in the Observation type. + + if self[keyPath: collection] == nil { + self[keyPath: collection] = [] + self[keyPath: collection]?.reserveCapacity(elements.count) + } else { + self[keyPath: collection]?.reserveCapacity((self[keyPath: collection]?.count ?? 0) + elements.count) + } + + for element in elements { + appendElement(element, to: collection) + } + } + + + func appendIdentifier(_ identifier: Identifier) { + appendElement(identifier, to: \.identifier) + } + + func appendIdentifiers(_ identifiers: [Identifier]) { + appendElements(identifiers, to: \.identifier) + } + + func appendCategory(_ category: CodeableConcept) { + appendElement(category, to: \.category) + } + + func appendCategories(_ categories: [CodeableConcept]) { + appendElements(categories, to: \.category) + } + + func appendCoding(_ coding: Coding) { + appendElement(coding, to: \.code.coding) + } + + func appendCodings(_ codings: [Coding]) { + appendElements(codings, to: \.code.coding) + } + + func appendComponent(_ component: ObservationComponent) { + appendElement(component, to: \.component) + } + + func appendComponents(_ components: [ObservationComponent]) { + appendElements(components, to: \.component) + } + + func setEffective(startDate: Date, endDate: Date) { + if startDate == endDate { + effective = .dateTime(FHIRPrimitive(try? DateTime(date: startDate))) + } else { + effective = .period( + Period( + end: FHIRPrimitive(try? DateTime(date: endDate)), + start: FHIRPrimitive(try? DateTime(date: startDate)) + ) + ) + } + } + + func setIssued(on date: Date) { + issued = FHIRPrimitive(try? Instant(date: date)) + } + + func setValue(_ quantity: Quantity) { + value = .quantity(quantity) + } + + func setValue(_ string: String) { + value = .string(string.asFHIRStringPrimitive()) + } +} diff --git a/Sources/SpeziTimedWalkTest/Resources/Localizable.xcstrings b/Sources/SpeziTimedWalkTest/Resources/Localizable.xcstrings new file mode 100644 index 0000000..5263927 --- /dev/null +++ b/Sources/SpeziTimedWalkTest/Resources/Localizable.xcstrings @@ -0,0 +1,99 @@ +{ + "sourceLanguage" : "en", + "strings" : { + "%lld" : { + + }, + "%lld m" : { + + }, + "Cancel" : { + + }, + "Cancel Timed Walk Test?" : { + + }, + "Cancel Walk Test" : { + + }, + "Distance:" : { + + }, + "Done" : { + + }, + "Invalid Data Error" : { + + }, + "Make yourself ready for the %@ minute walk test" : { + + }, + "Next" : { + + }, + "Pedometer access is not authorized" : { + + }, + "Pedometer data is invalid" : { + + }, + "Please go to the Settings App to authorize pedometer access for this application." : { + + }, + "Restart" : { + + }, + "Return" : { + + }, + "Start" : { + + }, + "Steps:" : { + + }, + "The %@ minute walk test will start in %@" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "The %1$@ minute walk test will start in %2$@" + } + } + } + }, + "Timed Walk Test" : { + + }, + "Unauthorized Error" : { + + }, + "Unknown" : { + + }, + "Unknown Error" : { + + }, + "WALK_TEST_DEFAULT_COMPLETION_MESSAGE" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Great job completing the timed walk test. Please review your results and press \"Done\" to save your results." + } + } + } + }, + "WALK_TEST_DEFAULT_TASK_DESCRIPTION %@" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Welcome to the timed minute walk test!\n\nPlease be sure that you have an enough space and time to walk for %@." + } + } + } + } + }, + "version" : "1.0" +} \ No newline at end of file diff --git a/Sources/SpeziTimedWalkTest/Resources/Localizable.xcstrings.license b/Sources/SpeziTimedWalkTest/Resources/Localizable.xcstrings.license new file mode 100644 index 0000000..a3bb08d --- /dev/null +++ b/Sources/SpeziTimedWalkTest/Resources/Localizable.xcstrings.license @@ -0,0 +1,5 @@ +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 \ No newline at end of file diff --git a/Sources/SpeziTimedWalkTest/SpeziTimedWalkTest.docc/Resources/GetReady.png b/Sources/SpeziTimedWalkTest/SpeziTimedWalkTest.docc/Resources/GetReady.png new file mode 100644 index 0000000..5150555 Binary files /dev/null and b/Sources/SpeziTimedWalkTest/SpeziTimedWalkTest.docc/Resources/GetReady.png differ diff --git a/Sources/SpeziTimedWalkTest/SpeziTimedWalkTest.docc/Resources/GetReady.png.license b/Sources/SpeziTimedWalkTest/SpeziTimedWalkTest.docc/Resources/GetReady.png.license new file mode 100644 index 0000000..9bfad3b --- /dev/null +++ b/Sources/SpeziTimedWalkTest/SpeziTimedWalkTest.docc/Resources/GetReady.png.license @@ -0,0 +1,5 @@ +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/SpeziTimedWalkTest/SpeziTimedWalkTest.docc/Resources/GetReady~dark.png b/Sources/SpeziTimedWalkTest/SpeziTimedWalkTest.docc/Resources/GetReady~dark.png new file mode 100644 index 0000000..7799a18 Binary files /dev/null and b/Sources/SpeziTimedWalkTest/SpeziTimedWalkTest.docc/Resources/GetReady~dark.png differ diff --git a/Sources/SpeziTimedWalkTest/SpeziTimedWalkTest.docc/Resources/GetReady~dark.png.license b/Sources/SpeziTimedWalkTest/SpeziTimedWalkTest.docc/Resources/GetReady~dark.png.license new file mode 100644 index 0000000..9bfad3b --- /dev/null +++ b/Sources/SpeziTimedWalkTest/SpeziTimedWalkTest.docc/Resources/GetReady~dark.png.license @@ -0,0 +1,5 @@ +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/SpeziTimedWalkTest/SpeziTimedWalkTest.docc/Resources/Result.png b/Sources/SpeziTimedWalkTest/SpeziTimedWalkTest.docc/Resources/Result.png new file mode 100644 index 0000000..e8b43ff Binary files /dev/null and b/Sources/SpeziTimedWalkTest/SpeziTimedWalkTest.docc/Resources/Result.png differ diff --git a/Sources/SpeziTimedWalkTest/SpeziTimedWalkTest.docc/Resources/Result.png.license b/Sources/SpeziTimedWalkTest/SpeziTimedWalkTest.docc/Resources/Result.png.license new file mode 100644 index 0000000..9bfad3b --- /dev/null +++ b/Sources/SpeziTimedWalkTest/SpeziTimedWalkTest.docc/Resources/Result.png.license @@ -0,0 +1,5 @@ +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/SpeziTimedWalkTest/SpeziTimedWalkTest.docc/Resources/Result~dark.png b/Sources/SpeziTimedWalkTest/SpeziTimedWalkTest.docc/Resources/Result~dark.png new file mode 100644 index 0000000..deb479c Binary files /dev/null and b/Sources/SpeziTimedWalkTest/SpeziTimedWalkTest.docc/Resources/Result~dark.png differ diff --git a/Sources/SpeziTimedWalkTest/SpeziTimedWalkTest.docc/Resources/Result~dark.png.license b/Sources/SpeziTimedWalkTest/SpeziTimedWalkTest.docc/Resources/Result~dark.png.license new file mode 100644 index 0000000..9bfad3b --- /dev/null +++ b/Sources/SpeziTimedWalkTest/SpeziTimedWalkTest.docc/Resources/Result~dark.png.license @@ -0,0 +1,5 @@ +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/SpeziTimedWalkTest/SpeziTimedWalkTest.docc/Resources/TimedWalkTest.png b/Sources/SpeziTimedWalkTest/SpeziTimedWalkTest.docc/Resources/TimedWalkTest.png new file mode 100644 index 0000000..3bba98d Binary files /dev/null and b/Sources/SpeziTimedWalkTest/SpeziTimedWalkTest.docc/Resources/TimedWalkTest.png differ diff --git a/Sources/SpeziTimedWalkTest/SpeziTimedWalkTest.docc/Resources/TimedWalkTest.png.license b/Sources/SpeziTimedWalkTest/SpeziTimedWalkTest.docc/Resources/TimedWalkTest.png.license new file mode 100644 index 0000000..9bfad3b --- /dev/null +++ b/Sources/SpeziTimedWalkTest/SpeziTimedWalkTest.docc/Resources/TimedWalkTest.png.license @@ -0,0 +1,5 @@ +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/SpeziTimedWalkTest/SpeziTimedWalkTest.docc/Resources/TimedWalkTest~dark.png b/Sources/SpeziTimedWalkTest/SpeziTimedWalkTest.docc/Resources/TimedWalkTest~dark.png new file mode 100644 index 0000000..fdea824 Binary files /dev/null and b/Sources/SpeziTimedWalkTest/SpeziTimedWalkTest.docc/Resources/TimedWalkTest~dark.png differ diff --git a/Sources/SpeziTimedWalkTest/SpeziTimedWalkTest.docc/Resources/TimedWalkTest~dark.png.license b/Sources/SpeziTimedWalkTest/SpeziTimedWalkTest.docc/Resources/TimedWalkTest~dark.png.license new file mode 100644 index 0000000..9bfad3b --- /dev/null +++ b/Sources/SpeziTimedWalkTest/SpeziTimedWalkTest.docc/Resources/TimedWalkTest~dark.png.license @@ -0,0 +1,5 @@ +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/SpeziTimedWalkTest/SpeziTimedWalkTest.docc/SpeziTimedWalkTest.md b/Sources/SpeziTimedWalkTest/SpeziTimedWalkTest.docc/SpeziTimedWalkTest.md new file mode 100644 index 0000000..a0b9144 --- /dev/null +++ b/Sources/SpeziTimedWalkTest/SpeziTimedWalkTest.docc/SpeziTimedWalkTest.md @@ -0,0 +1,93 @@ +# ``SpeziTimedWalkTest`` + + + +Enables apps to conduct an timed walk test. + +## Overview + +The `SpeziTimedWalkTest` target in the Spezi Questinnaire package enables the conduction of timed walk tests in an app, + +@Row { + @Column { + @Image(source: "GetReady", alt: "Get ready screen of the TimedWalkTestView defined by a TimedWalkTest."){ + Get ready screen of the ``TimedWalkTestView`` defined by a ``TimedWalkTest``. + } + } + @Column { + @Image(source: "TimedWalkTest", alt: "The TimedWalkTestView used to conduct a TimedWalkTest."){ + The ``TimedWalkTestView`` used to conduct a ``TimedWalkTest``. + } + } + @Column { + @Image(source: "Result", alt: "Display of the result of the TimedWalkTestView encoded in a TimedWalkTestResult."){ + Display of the result of the ``TimedWalkTestView`` encoded in a ``TimedWalkTestResult`` + } + } +} + +## Setup + +You need to add the Spezi Questionnaire Swift package to +[your app in Xcode](https://developer.apple.com/documentation/xcode/adding-package-dependencies-to-your-app) or +[Swift package](https://developer.apple.com/documentation/xcode/creating-a-standalone-swift-package-with-xcode#Add-a-dependency-on-another-Swift-package). + +> Important: If your application is not yet configured to use Spezi, follow the [Spezi setup article](https://swiftpackageindex.com/stanfordspezi/spezi/documentation/spezi/initial-setup) and set up the core Spezi infrastructure. + +## Example + +In the following example, we create a SwiftUI view with a button that displays a the timed walk test view defined by the ``TimedWalkTest`` using the ``TimedWalkTestView``. + +```swift +import SpeziTimedWalkTest +import SwiftUI + + +struct ExampleView: View { + @State var displayWalkTest = false + + + private var timedWalkTest: TimedWalkTest { + TimedWalkTest(walkTime: 5) + } + + var body: some View { + Button("Display Walk Test") { + displayWalkTest.toggle() + } + .sheet(isPresented: $displayWalkTest) { + NavigationStack { + TimedWalkTestView(timedWalkTest: timedWalkTest) { result in + switch result { + case .completed: + // ... + case .failed: + // ... + case .cancelled: + // ... + } + displayWalkTest = false + } + } + } + } +} +``` + +## Topics + +### Timed Walk Test + +- ``TimedWalkTest`` +- ``TimedWalkTestView`` +- ``TimedWalkTestViewResult`` +- ``TimedWalkTestResult`` +- ``TimedWalkTestError`` diff --git a/Sources/SpeziTimedWalkTest/TimedWalkTest.swift b/Sources/SpeziTimedWalkTest/TimedWalkTest.swift new file mode 100644 index 0000000..5e61a6d --- /dev/null +++ b/Sources/SpeziTimedWalkTest/TimedWalkTest.swift @@ -0,0 +1,63 @@ +// +// 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 + + +/// Defines the configuration of a timed walk test. +public struct TimedWalkTest: Codable, Equatable, Hashable { + /// Default values. + public enum Defaults { + /// Default timed walk test duration. + public static let walkTime: TimeInterval = 6 * 60 + /// Default completion message. + public static let completionMessage = String(localized: "WALK_TEST_DEFAULT_COMPLETION_MESSAGE", bundle: .module) + + + /// Default task description based on a timed walk time. + public static func taskDescription(walkTime: TimeInterval) -> String { + String(localized: "WALK_TEST_DEFAULT_TASK_DESCRIPTION \(DateComponentsFormatter().string(from: walkTime) ?? "")", bundle: .module) + } + } + + /// Task description displayed in the first view before the test. + public var taskDescription: String + /// Duration of the timed walk test. + public var walkTime: TimeInterval + /// Completion message shown at the end of the timed walk test. + public var completionMessage: String + + + /// - Parameters: + /// - walkTime: Duration of the timed walk test. + /// - completionMessage: Completion message shown at the end of the timed walk test. + public init( + walkTime: TimeInterval = Defaults.walkTime, + completionMessage: String = Defaults.completionMessage + ) { + self.init( + taskDescription: Defaults.taskDescription(walkTime: walkTime), + walkTime: walkTime, + completionMessage: completionMessage + ) + } + + /// - Parameters: + /// - taskDescription: Task description displayed in the first view before the test. + /// - walkTime: Duration of the timed walk test. + /// - completionMessage: Completion message shown at the end of the timed walk test. + public init( + taskDescription: String, + walkTime: TimeInterval = Defaults.walkTime, + completionMessage: String = Defaults.completionMessage + ) { + self.taskDescription = taskDescription + self.walkTime = walkTime + self.completionMessage = completionMessage + } +} diff --git a/Sources/SpeziTimedWalkTest/TimedWalkTestCancelModifier.swift b/Sources/SpeziTimedWalkTest/TimedWalkTestCancelModifier.swift new file mode 100644 index 0000000..08b9fd7 --- /dev/null +++ b/Sources/SpeziTimedWalkTest/TimedWalkTestCancelModifier.swift @@ -0,0 +1,34 @@ +// +// 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 TimedWalkTestCancelModifier: ViewModifier { + @Environment(TimedWalkTestViewModel.self) var walkTestViewModel + + @State private var cancel = false + + + func body(content: Content) -> some View { + content + .toolbar { + Button("Cancel") { + cancel = true + } + } + .confirmationDialog("Cancel Timed Walk Test?", isPresented: $cancel) { + Button("Cancel Walk Test", role: .destructive) { + walkTestViewModel.completion(.cancelled) + } + Button("Return", role: .cancel) { + cancel = false + } + } + } +} diff --git a/Sources/SpeziTimedWalkTest/TimedWalkTestCompletedView.swift b/Sources/SpeziTimedWalkTest/TimedWalkTestCompletedView.swift new file mode 100644 index 0000000..ae263b3 --- /dev/null +++ b/Sources/SpeziTimedWalkTest/TimedWalkTestCompletedView.swift @@ -0,0 +1,83 @@ +// +// 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 TimedWalkTestCompletedView: View { + @Environment(TimedWalkTestViewModel.self) var walkTestViewModel + @Environment(\.dismiss) private var dismiss + + + var body: some View { + VStack { + Image(systemName: "checkmark.circle") + .font(.system(size: 120)) + .foregroundStyle(.green) + .accessibilityHidden(true) + .padding(32) + Text(walkTestViewModel.timedWalkTest.completionMessage) + .padding(.horizontal) + .multilineTextAlignment(.center) + switch walkTestViewModel.walkTestResponse { + case let .completed(response): + resultsGrid(timedWalkTestResult: response) + case let .failed(error): + Text(error.localizedDescription) + .padding() + .multilineTextAlignment(.center) + default: + EmptyView() + } + Spacer() + Button("Restart", role: .destructive) { + dismiss() + } + AsyncButton(action: completeAction) { + Text("Done") + .frame(maxWidth: .infinity, minHeight: 38) + } + .buttonStyle(.borderedProminent) + .padding() + } + .navigationBarBackButtonHidden(true) + } + + + private func resultsGrid(timedWalkTestResult: TimedWalkTestResult) -> some View { + Grid { + GridRow { + Text("Steps:") + .gridColumnAlignment(.trailing) + Text("\(Int(timedWalkTestResult.stepCount))") + .gridColumnAlignment(.leading) + } + GridRow { + Text("Distance:") + .gridColumnAlignment(.trailing) + Text("\(Int(timedWalkTestResult.distance)) m") + .gridColumnAlignment(.leading) + } + } + .bold() + .padding() + } + + private func completeAction() async { + walkTestViewModel.completeWalkTest() + } +} + + +#Preview { + NavigationStack { + TimedWalkTestCompletedView() + .environment(TimedWalkTestViewModel()) + } +} diff --git a/Sources/SpeziTimedWalkTest/TimedWalkTestError.swift b/Sources/SpeziTimedWalkTest/TimedWalkTestError.swift new file mode 100644 index 0000000..91a4e39 --- /dev/null +++ b/Sources/SpeziTimedWalkTest/TimedWalkTestError.swift @@ -0,0 +1,67 @@ +// +// 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 CoreMotion +import Foundation + + +/// Error that might occur during a timed walk test. +public enum TimedWalkTestError: LocalizedError, Codable { + /// Could not retrieve pedometer access, please ask the user to provide access in the settings app. + case unauthorized + /// Could not retrieve valid date from the pedometer. + case invalidData + /// Unknown error that occured during the execution. + case unknown + + + private var errorDescriptionValue: String.LocalizationValue { + switch self { + case .unauthorized: + return "Unauthorized Error" + case .invalidData: + return "Invalid Data Error" + case .unknown: + return "Unknown Error" + } + } + + + private var failureReasonDescriptionValue: String.LocalizationValue { + switch self { + case .unauthorized: + return "Pedometer access is not authorized" + case .invalidData: + return "Pedometer data is invalid" + case .unknown: + return "Unknown" + } + } + + + public var errorDescription: String? { + .init(localized: errorDescriptionValue, bundle: .module) + } + + public var failureReason: String? { + .init(localized: failureReasonDescriptionValue, bundle: .module) + } + + + init(errorCode: Int) { + switch errorCode { + case Int(CMErrorNilData.rawValue), Int(CMErrorSize.rawValue), Int(CMErrorDeviceRequiresMovement.rawValue), + Int(CMErrorInvalidAction.rawValue), Int(CMErrorInvalidParameter.rawValue): + self = .invalidData + case Int(CMErrorUnknown.rawValue), Int(CMErrorNULL.rawValue): + self = .unknown + default: + self = .unauthorized + } + } +} diff --git a/Sources/SpeziTimedWalkTest/TimedWalkTestResult.swift b/Sources/SpeziTimedWalkTest/TimedWalkTestResult.swift new file mode 100644 index 0000000..630c2ef --- /dev/null +++ b/Sources/SpeziTimedWalkTest/TimedWalkTestResult.swift @@ -0,0 +1,111 @@ +// +// 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 +import ModelsR4 + + +/// Result of a timed walk test +public struct TimedWalkTestResult: Sendable, Equatable, Hashable, Codable { + /// Steps recorded during the test. + public let stepCount: Double + /// Distance recorded during the test. + public let distance: Double + /// Start date of the timed walk test. + public let startDate: Date + /// End date of the timed walk test. + public let endDate: Date + + + /// Duration of the timed walk test. + public var duration: TimeInterval { + endDate.timeIntervalSince(startDate) + } + + /// FHIR `Observation` representation of the timed walk test result. + /// + /// Uses the `62619-2` six minute walk test LOINC code for a test that is six minutes long, `55430-3` for all other walk tests. + /// + /// Encodes the steps and distance as part of the observation. + public var observation: Observation { + let observation = Observation( + code: CodeableConcept(), + status: FHIRPrimitive(.final) + ) + + // Set basic elements applicable to all observations + observation.id = UUID().uuidString.asFHIRStringPrimitive() + observation.appendIdentifier(Identifier(id: observation.id)) + observation.setEffective(startDate: self.startDate, endDate: self.endDate) + observation.setIssued(on: Date()) + + // Add LOINC code dependent on the walk test duration. + let loincSystem = "http://loinc.org".asFHIRURIPrimitive() + if self.startDate.distance(to: self.endDate).rounded() == 60 * 60 { + observation.appendCoding( + Coding( + code: "62619-2".asFHIRStringPrimitive(), + system: loincSystem + ) + ) + } else { + observation.appendCoding( + Coding( + code: "55430-3".asFHIRStringPrimitive(), + system: loincSystem + ) + ) + } + + observation.appendComponent( + builObservationComponent( + code: "55423-8", + system: "http://loinc.org", + unit: "steps", + value: self.stepCount + ) + ) + + observation.appendComponent( + builObservationComponent( + code: "55430-3", + system: "http://loinc.org", + unit: "m", + value: self.distance + ) + ) + + return observation + } + + + init(stepCount: Double, distance: Double, startDate: Date, endDate: Date) { + self.stepCount = stepCount + self.distance = distance + self.startDate = startDate + self.endDate = endDate + } + + + private func builObservationComponent(code: String, system: String, unit: String, value: Double) -> ObservationComponent { + let coding = Coding(code: code.asFHIRStringPrimitive(), system: system.asFHIRURIPrimitive()) + let codeable = CodeableConcept(coding: [coding]) + let component = ObservationComponent(code: codeable) + + component.value = .quantity( + Quantity( + code: code.asFHIRStringPrimitive(), + system: system.asFHIRURIPrimitive(), + unit: unit.asFHIRStringPrimitive(), + value: value.asFHIRDecimalPrimitive() + ) + ) + + return component + } +} diff --git a/Sources/SpeziTimedWalkTest/TimedWalkTestRunningView.swift b/Sources/SpeziTimedWalkTest/TimedWalkTestRunningView.swift new file mode 100644 index 0000000..231197a --- /dev/null +++ b/Sources/SpeziTimedWalkTest/TimedWalkTestRunningView.swift @@ -0,0 +1,91 @@ +// +// 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 CoreMotion +import SpeziViews +import SwiftUI + + +struct TimedWalkTestRunningView: View { + @Environment(TimedWalkTestViewModel.self) var walkTestViewModel + @State var prepareCountDown: Date? + + + private var walkTime: String { + guard let walkTime = DateComponentsFormatter().string(from: walkTestViewModel.timedWalkTest.walkTime) else { + preconditionFailure("Could not generate string representation of \(walkTestViewModel.timedWalkTest.walkTime)") + } + + return walkTime + } + + var body: some View { + @Bindable var walkTestResponse = walkTestViewModel + VStack { + Image(systemName: "figure.walk.circle") + .symbolEffect(.pulse, isActive: walkTestViewModel.walkTestStartDate != nil) + .foregroundStyle(Color.accentColor) + .font(.system(size: 120)) + .accessibilityHidden(true) + .padding(32) + if let walkTestStartDate = walkTestViewModel.walkTestStartDate, let walkTestEndDate = walkTestViewModel.walkTestEndDate { + Text(timerInterval: walkTestStartDate...walkTestEndDate, countsDown: true) + .font(.system(size: 50, weight: .bold, design: .rounded)) + .bold() + } else { + Text("Make yourself ready for the \(walkTime) minute walk test") + .padding(.horizontal) + .multilineTextAlignment(.center) + } + Spacer() + if let prepareCountDown, Date.now < prepareCountDown { + Text("The \(walkTime) minute walk test will start in \(Text(timerInterval: Date.now...prepareCountDown, countsDown: true))") + .multilineTextAlignment(.center) + .foregroundStyle(.secondary) + .font(.caption) + } + AsyncButton( + action: start, + label: { + Text("Start") + .frame(maxWidth: .infinity, minHeight: 38) + } + ) + .buttonStyle(.borderedProminent) + .disabled(walkTestViewModel.walkTestStartDate != nil) + .padding() + } + .navigationTitle("Timed Walk Test") + .navigationBarBackButtonHidden(walkTestViewModel.walkTestStartDate != nil || prepareCountDown != nil) + .navigationDestination(item: $walkTestResponse.walkTestResponse) { _ in + TimedWalkTestCompletedView() + .environment(walkTestViewModel) + } + .modifier(TimedWalkTestCancelModifier()) + } + + + private func start() async { + withAnimation { + prepareCountDown = Date.now.addingTimeInterval(5) + } + try? await Task.sleep(for: .seconds(5)) + withAnimation { + prepareCountDown = nil + walkTestViewModel.startTimedWalk() + } + } +} + + +#Preview { + NavigationStack { + TimedWalkTestRunningView() + .environment(TimedWalkTestViewModel()) + } +} diff --git a/Sources/SpeziTimedWalkTest/TimedWalkTestView.swift b/Sources/SpeziTimedWalkTest/TimedWalkTestView.swift new file mode 100644 index 0000000..99bf99c --- /dev/null +++ b/Sources/SpeziTimedWalkTest/TimedWalkTestView.swift @@ -0,0 +1,92 @@ +// +// 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 + + +/// Displays a timed walk test. +/// +/// To use the `TimedWalkTestView` you first need to define a ``TimedWalkTest`` that configures the test including its duration and other parameters: +/// ```swift +/// timedWalkTest = TimedWalkTest(walkTime: 6 * 60) +/// ``` +/// +/// The ``TimedWalkTestView`` can subsequentially be displayed in a SwiftUI view: +/// ```swift +/// TimedWalkTestView(timedWalkTest: timedWalkTest) { result in +/// switch result { +/// case .completed: +/// // ... +/// case .failed: +/// // ... +/// case .cancelled: +/// // ... +/// } +/// } +/// ``` +public struct TimedWalkTestView: View { + @State private var walkTestViewModel: TimedWalkTestViewModel + + + public var body: some View { + VStack { + Image(systemName: "figure.walk.circle") + .font(.system(size: 120)) + .accessibilityHidden(true) + .padding(32) + .foregroundStyle(Color.accentColor) + Text(walkTestViewModel.timedWalkTest.taskDescription) + .padding(.horizontal) + .multilineTextAlignment(.center) + Spacer() + NavigationLink { + TimedWalkTestRunningView() + .environment(walkTestViewModel) + } label: { + Text("Next") + .frame(maxWidth: .infinity, minHeight: 38) + } + .buttonStyle(.borderedProminent) + .disabled(walkTestViewModel.authorizationStatus != .authorized) + .padding() + if walkTestViewModel.authorizationStatus != .authorized { + Text("Please go to the Settings App to authorize pedometer access for this application.") + .multilineTextAlignment(.center) + .foregroundStyle(.red) + } + } + .task { + walkTestViewModel.requestPedemoterAccess() + } + .navigationTitle("Timed Walk Test") + .modifier(TimedWalkTestCancelModifier()) + .environment(walkTestViewModel) + } + + + public init( + timedWalkTest: TimedWalkTest = TimedWalkTest(), + completion: @escaping (TimedWalkTestViewResult) -> Void + ) { + self._walkTestViewModel = State( + wrappedValue: TimedWalkTestViewModel( + timedWalkTest: timedWalkTest, + completion: completion + ) + ) + } +} + + +#Preview { + NavigationStack { + TimedWalkTestView { + print("Complete: \($0)") + } + } +} diff --git a/Sources/SpeziTimedWalkTest/TimedWalkTestViewModel.swift b/Sources/SpeziTimedWalkTest/TimedWalkTestViewModel.swift new file mode 100644 index 0000000..de3b12b --- /dev/null +++ b/Sources/SpeziTimedWalkTest/TimedWalkTestViewModel.swift @@ -0,0 +1,116 @@ +// +// 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 CoreMotion +import Foundation +import SwiftUI + + +@Observable +class TimedWalkTestViewModel { + let pedometer = CMPedometer() + + let timedWalkTest: TimedWalkTest + let completion: (TimedWalkTestViewResult) -> Void + + var authorizationStatus: CMAuthorizationStatus = CMPedometer.authorizationStatus() + var walkTestStartDate: Date? + var walkTestResponse: TimedWalkTestViewResult? + + + var walkTestEndDate: Date? { + walkTestStartDate?.addingTimeInterval(timedWalkTest.walkTime) + } + + + init( + timedWalkTest: TimedWalkTest = TimedWalkTest(), + completion: @escaping (TimedWalkTestViewResult) -> Void = { _ in } + ) { + self.timedWalkTest = timedWalkTest + self.completion = completion + } + + + func requestPedemoterAccess() { + #if !targetEnvironment(simulator) + guard CMPedometer.isStepCountingAvailable() else { + walkTestResponse = .failure(.unauthorized) + return + } + + pedometer.queryPedometerData(from: .now, to: .now) { data, error in + if data != nil { + self.authorizationStatus = CMPedometer.authorizationStatus() + } else { + self.walkTestResponse = .failure(TimedWalkTestError(errorCode: (error as? NSError)?.code ?? -1)) + } + } + #else + self.authorizationStatus = CMAuthorizationStatus.authorized + #endif + } + + func startTimedWalk() { + Task { // swiftlint:disable:this closure_body_length + self.walkTestStartDate = .now + defer { + Task { + try await Task.sleep(for: .seconds(0.2)) + self.walkTestStartDate = nil + } + } + + do { + try await Task.sleep(for: .seconds(timedWalkTest.walkTime)) + } catch { + walkTestResponse = .failed(.unknown) + return + } + + guard let walkTestStartDate, let walkTestEndDate else { + walkTestResponse = .failed(.invalidData) + return + } + + #if !targetEnvironment(simulator) + pedometer.queryPedometerData(from: walkTestStartDate, to: walkTestEndDate) { data, error in + if let data, let distance = data.distance?.doubleValue { + self.walkTestResponse = .success( + TimedWalkTestResult( + stepCount: data.numberOfSteps.doubleValue, + distance: distance, + startDate: walkTestStartDate, + endDate: walkTestEndDate + ) + ) + } else { + self.walkTestResponse = .failure(TimedWalkTestError(errorCode: (error as? NSError)?.code ?? -1)) + } + } + #else + self.walkTestResponse = .completed( + TimedWalkTestResult( + stepCount: 42, + distance: 12, + startDate: walkTestStartDate, + endDate: walkTestEndDate + ) + ) + #endif + } + } + + func completeWalkTest() { + guard let walkTestResponse else { + preconditionFailure("Completed Walk Test without a proper response.") + } + + completion(walkTestResponse) + } +} diff --git a/Sources/SpeziTimedWalkTest/TimedWalkTestViewResult.swift b/Sources/SpeziTimedWalkTest/TimedWalkTestViewResult.swift new file mode 100644 index 0000000..50e5203 --- /dev/null +++ b/Sources/SpeziTimedWalkTest/TimedWalkTestViewResult.swift @@ -0,0 +1,20 @@ +// +// 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 ModelsR4 + + +/// The result of a timed walk test view. +public enum TimedWalkTestViewResult: Equatable, Hashable, Codable { + /// The timed walk test was successfully completed with a ``TimedWalkTestResult``. + case completed(TimedWalkTestResult) + /// The timed walk test was cancelled by the user. + case cancelled + /// The timed walk test failed due to an error with a ``TimedWalkTestError``. + case failed(TimedWalkTestError) +} diff --git a/Tests/UITests/TestApp/ContentView.swift b/Tests/UITests/TestApp/ContentView.swift index 1d598a4..77a76a5 100644 --- a/Tests/UITests/TestApp/ContentView.swift +++ b/Tests/UITests/TestApp/ContentView.swift @@ -1,22 +1,37 @@ // // 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-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) // // SPDX-License-Identifier: MIT // -import FHIRQuestionnaires import SpeziQuestionnaire +import SpeziTimedWalkTest import SwiftUI struct ContentView: View { @Environment(ExampleStandard.self) var standard @State var displayQuestionnaire = false + @State var displayWalkTest = false + + private var timedWalkTest: TimedWalkTest { + TimedWalkTest(walkTime: 5) + } var body: some View { + VStack { + Spacer() + questionnaireView + Spacer() + timedWalkTestView + Spacer() + } + } + + @ViewBuilder @MainActor private var questionnaireView: some View { Text("No. of surveys complete: \(standard.surveyResponseCount)") Button("Display Questionnaire") { displayQuestionnaire.toggle() @@ -34,4 +49,27 @@ struct ContentView: View { ) } } + + @ViewBuilder @MainActor private var timedWalkTestView: some View { + Text("No. of walk tests complete: \(standard.timedWalkTestResponseCount)") + Button("Display Walk Test") { + displayWalkTest.toggle() + } + .sheet(isPresented: $displayWalkTest) { + NavigationStack { + TimedWalkTestView(timedWalkTest: timedWalkTest) { result in + switch result { + case .completed: + print("Previous walk test was successful") + standard.timedWalkTestResponseCount += 1 + case .failed: + print("Previous walk test was unsuccessful") + case .cancelled: + print("Previous walk test was cancelled") + } + displayWalkTest = false + } + } + } + } } diff --git a/Tests/UITests/TestApp/ExampleStandard.swift b/Tests/UITests/TestApp/ExampleStandard.swift index 8b6b535..e3e7092 100644 --- a/Tests/UITests/TestApp/ExampleStandard.swift +++ b/Tests/UITests/TestApp/ExampleStandard.swift @@ -6,21 +6,28 @@ // SPDX-License-Identifier: MIT // +import ResearchKit import Spezi import SpeziQuestionnaire +import SpeziTimedWalkTest import SwiftUI @Observable private class ExampleModel { var surveyResponseCount: Int = 0 + var timedWalkTestResponseCount: Int = 0 + + init() {} } + /// An example Standard used for the configuration. actor ExampleStandard: Standard, EnvironmentAccessible { @MainActor private let model = ExampleModel() + @MainActor var surveyResponseCount: Int { get { model.surveyResponseCount @@ -29,4 +36,13 @@ actor ExampleStandard: Standard, EnvironmentAccessible { model.surveyResponseCount = newValue } } + + @MainActor var timedWalkTestResponseCount: Int { + get { + model.timedWalkTestResponseCount + } + set { + model.timedWalkTestResponseCount = newValue + } + } } diff --git a/Tests/UITests/TestApp/Info.plist b/Tests/UITests/TestApp/Info.plist new file mode 100644 index 0000000..775e66c --- /dev/null +++ b/Tests/UITests/TestApp/Info.plist @@ -0,0 +1,12 @@ + + + + + UIBackgroundModes + + location + processing + remote-notification + + + diff --git a/Tests/UITests/TestApp/Info.plist.license b/Tests/UITests/TestApp/Info.plist.license new file mode 100644 index 0000000..a3bb08d --- /dev/null +++ b/Tests/UITests/TestApp/Info.plist.license @@ -0,0 +1,5 @@ +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 \ No newline at end of file diff --git a/Tests/UITests/TestApp/TestApp.entitlements b/Tests/UITests/TestApp/TestApp.entitlements new file mode 100644 index 0000000..2ab14a2 --- /dev/null +++ b/Tests/UITests/TestApp/TestApp.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.developer.healthkit + + com.apple.developer.healthkit.access + + + diff --git a/Tests/UITests/TestApp/TestApp.entitlements.license b/Tests/UITests/TestApp/TestApp.entitlements.license new file mode 100644 index 0000000..a3bb08d --- /dev/null +++ b/Tests/UITests/TestApp/TestApp.entitlements.license @@ -0,0 +1,5 @@ +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 \ No newline at end of file diff --git a/Tests/UITests/TestApp/TestAppDelegate.swift b/Tests/UITests/TestApp/TestAppDelegate.swift index 6ada6f2..c8cca97 100644 --- a/Tests/UITests/TestApp/TestAppDelegate.swift +++ b/Tests/UITests/TestApp/TestAppDelegate.swift @@ -7,7 +7,6 @@ // import Spezi -import SpeziQuestionnaire import SwiftUI diff --git a/Tests/UITests/TestAppUITests/TestAppUITests.swift b/Tests/UITests/TestAppUITests/TestAppUITests.swift index 49b1eb1..0e49252 100644 --- a/Tests/UITests/TestAppUITests/TestAppUITests.swift +++ b/Tests/UITests/TestAppUITests/TestAppUITests.swift @@ -15,7 +15,8 @@ class TestAppUITests: XCTestCase { continueAfterFailure = false } - func testSpezi() throws { + + func testSpeziQuestionnaire() throws { let app = XCUIApplication() app.launch() @@ -51,4 +52,34 @@ class TestAppUITests: XCTestCase { /// Verify that the number of survey responses increases XCTAssert(app.staticTexts["No. of surveys complete: 1"].waitForExistence(timeout: 2)) } + + func testSpeziTimedWalkTest() throws { + let app = XCUIApplication() + app.launch() + + XCTAssert(app.staticTexts["No. of walk tests complete: 0"].waitForExistence(timeout: 2)) + + XCTAssert(app.buttons["Display Walk Test"].waitForExistence(timeout: 2)) + app.buttons["Display Walk Test"].tap() + + /// Tap Next to move to the next screen + XCTAssert(app.buttons["Next"].waitForExistence(timeout: 2)) + app.buttons["Next"].tap() + + /// Tap Start to start the walk test + XCTAssert(app.buttons["Start"].waitForExistence(timeout: 2)) + app.buttons["Start"].tap() + + /// Wait for walk test to complete + sleep(15) + + XCTAssert(app.staticTexts["42"].waitForExistence(timeout: 2)) + XCTAssert(app.staticTexts["12 m"].waitForExistence(timeout: 2)) + + XCTAssert(app.buttons["Done"].waitForExistence(timeout: 5)) + app.buttons["Done"].tap() + + /// Verify that the number of survey responses increases + XCTAssert(app.staticTexts["No. of walk tests complete: 1"].waitForExistence(timeout: 2)) + } } diff --git a/Tests/UITests/UITests.xcodeproj/project.pbxproj b/Tests/UITests/UITests.xcodeproj/project.pbxproj index 3c2359c..067db67 100644 --- a/Tests/UITests/UITests.xcodeproj/project.pbxproj +++ b/Tests/UITests/UITests.xcodeproj/project.pbxproj @@ -12,6 +12,7 @@ 2F7CC6062A79B68D00F42D90 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F7CC6052A79B68D00F42D90 /* ContentView.swift */; }; 2F8A431329130A8C005D2B8F /* TestAppUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F8A431229130A8C005D2B8F /* TestAppUITests.swift */; }; 2FA7382C290ADFAA007ACEB9 /* TestApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FA7382B290ADFAA007ACEB9 /* TestApp.swift */; }; + 2FB059642B95C0A8000E6743 /* SpeziTimedWalkTest in Frameworks */ = {isa = PBXBuildFile; productRef = 2FB059632B95C0A8000E6743 /* SpeziTimedWalkTest */; }; 2FF831E129B5D3750096DFAB /* TestAppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FF831E029B5D3750096DFAB /* TestAppDelegate.swift */; }; 393B47422A747894004DCB08 /* ExampleStandard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 393B47412A747894004DCB08 /* ExampleStandard.swift */; }; /* End PBXBuildFile section */ @@ -37,6 +38,8 @@ 2FA7382B290ADFAA007ACEB9 /* TestApp.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TestApp.swift; sourceTree = ""; }; 2FF831E029B5D3750096DFAB /* TestAppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestAppDelegate.swift; sourceTree = ""; }; 393B47412A747894004DCB08 /* ExampleStandard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExampleStandard.swift; sourceTree = ""; }; + 4A311B612AD137840096C494 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; + 4A7A221E2AD4A57B00B15ABB /* TestApp.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = TestApp.entitlements; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -45,6 +48,7 @@ buildActionMask = 2147483647; files = ( 2F2E8EF329E727AF00D439B7 /* SpeziQuestionnaire in Frameworks */, + 2FB059642B95C0A8000E6743 /* SpeziTimedWalkTest in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -82,6 +86,8 @@ 2F6D139428F5F384007C25D6 /* TestApp */ = { isa = PBXGroup; children = ( + 4A7A221E2AD4A57B00B15ABB /* TestApp.entitlements */, + 4A311B612AD137840096C494 /* Info.plist */, 2FA7382B290ADFAA007ACEB9 /* TestApp.swift */, 2FF831E029B5D3750096DFAB /* TestAppDelegate.swift */, 393B47412A747894004DCB08 /* ExampleStandard.swift */, @@ -121,10 +127,12 @@ buildRules = ( ); dependencies = ( + 4A3D9D772AF310FC00AE3078 /* PBXTargetDependency */, ); name = TestApp; packageProductDependencies = ( 2F2E8EF229E727AF00D439B7 /* SpeziQuestionnaire */, + 2FB059632B95C0A8000E6743 /* SpeziTimedWalkTest */, ); productName = Example; productReference = 2F6D139228F5F384007C25D6 /* TestApp.app */; @@ -156,7 +164,7 @@ attributes = { BuildIndependentTargetsInParallel = 1; LastSwiftUpdateCheck = 1410; - LastUpgradeCheck = 1410; + LastUpgradeCheck = 1520; TargetAttributes = { 2F6D139128F5F384007C25D6 = { CreatedOnToolsVersion = 14.1; @@ -255,6 +263,9 @@ target = 2F6D139128F5F384007C25D6 /* TestApp */; targetProxy = 2F6D13AD28F5F386007C25D6 /* PBXContainerItemProxy */; }; + 4A3D9D772AF310FC00AE3078 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + }; /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ @@ -262,6 +273,7 @@ isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; @@ -322,6 +334,7 @@ isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; @@ -377,16 +390,25 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = TestApp/TestApp.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = ""; - DEVELOPMENT_TEAM = 64FJ2MWNP4; + DEVELOPMENT_TEAM = 637867499T; ENABLE_PREVIEWS = YES; ENABLE_TESTING_SEARCH_PATHS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = TestApp/Info.plist; + INFOPLIST_KEY_LSApplicationCategoryType = ""; + INFOPLIST_KEY_NSHealthShareUsageDescription = "App wants to use Health Data"; + INFOPLIST_KEY_NSHealthUpdateUsageDescription = "App wants to use Health Data"; + INFOPLIST_KEY_NSLocationUsageDescription = "App wants to use location"; + INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "App wants to use location"; + INFOPLIST_KEY_NSMotionUsageDescription = "App wants to use Motion Data"; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UIRequiredDeviceCapabilities = "\"\""; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; LD_RUNPATH_SEARCH_PATHS = ( @@ -408,16 +430,26 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = TestApp/TestApp.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = ""; - DEVELOPMENT_TEAM = 64FJ2MWNP4; + DEVELOPMENT_TEAM = ""; ENABLE_PREVIEWS = YES; ENABLE_TESTING_SEARCH_PATHS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = TestApp/Info.plist; + INFOPLIST_KEY_LSApplicationCategoryType = ""; + INFOPLIST_KEY_NSHealthShareUsageDescription = "App wants to use Health Data"; + INFOPLIST_KEY_NSHealthUpdateUsageDescription = "App wants to use Health Data"; + INFOPLIST_KEY_NSLocationUsageDescription = "App wants to use location"; + INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "App wants to use location"; + INFOPLIST_KEY_NSMotionUsageDescription = "App wants to use Motion Data"; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UIRequiredDeviceCapabilities = "\"\""; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; LD_RUNPATH_SEARCH_PATHS = ( @@ -427,6 +459,7 @@ MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = edu.stanford.spezi.questionnaire.testapp; PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_STRICT_CONCURRENCY = complete; SWIFT_VERSION = 5.0; @@ -440,7 +473,7 @@ ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = 637867499T; + DEVELOPMENT_TEAM = 64FJ2MWNP4; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = edu.stanford.spezi.questionnaire.testappuitests; @@ -459,7 +492,7 @@ ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = 637867499T; + DEVELOPMENT_TEAM = 64FJ2MWNP4; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = edu.stanford.spezi.questionnaire.testappuitests; @@ -476,6 +509,7 @@ isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; @@ -537,16 +571,25 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = TestApp/TestApp.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = ""; - DEVELOPMENT_TEAM = 64FJ2MWNP4; + DEVELOPMENT_TEAM = 637867499T; ENABLE_PREVIEWS = YES; ENABLE_TESTING_SEARCH_PATHS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = TestApp/Info.plist; + INFOPLIST_KEY_LSApplicationCategoryType = ""; + INFOPLIST_KEY_NSHealthShareUsageDescription = "App wants to use Health Data"; + INFOPLIST_KEY_NSHealthUpdateUsageDescription = "App wants to use Health Data"; + INFOPLIST_KEY_NSLocationUsageDescription = "App wants to use location"; + INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "App wants to use location"; + INFOPLIST_KEY_NSMotionUsageDescription = "App wants to use Motion Data"; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UIRequiredDeviceCapabilities = "\"\""; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; LD_RUNPATH_SEARCH_PATHS = ( @@ -569,7 +612,7 @@ ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = 637867499T; + DEVELOPMENT_TEAM = 64FJ2MWNP4; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = edu.stanford.spezi.questionnaire.testappuitests; @@ -622,6 +665,10 @@ isa = XCSwiftPackageProductDependency; productName = SpeziQuestionnaire; }; + 2FB059632B95C0A8000E6743 /* SpeziTimedWalkTest */ = { + isa = XCSwiftPackageProductDependency; + productName = SpeziTimedWalkTest; + }; /* 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 39b6189..19a15c8 100644 --- a/Tests/UITests/UITests.xcodeproj/xcshareddata/xcschemes/TestApp.xcscheme +++ b/Tests/UITests/UITests.xcodeproj/xcshareddata/xcschemes/TestApp.xcscheme @@ -1,6 +1,6 @@