diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..60651ce --- /dev/null +++ b/.gitignore @@ -0,0 +1,126 @@ +### macOS ### +# General +.DS_Store +.AppleDouble +.LSOverride +/.build +/Packages +.swiftpm/configuration/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc + +# Icon must end with two \r +Icon + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +# Xcode +# +# gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore + +## User settings +xcuserdata/ + +## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) +*.xcscmblueprint +*.xccheckout + +## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) +build/ +DerivedData/ +*.moved-aside +*.pbxuser +!default.pbxuser +*.mode1v3 +!default.mode1v3 +*.mode2v3 +!default.mode2v3 +*.perspectivev3 +!default.perspectivev3 + +## Obj-C/Swift specific +*.hmap + +## App packaging +*.ipa +*.dSYM.zip +*.dSYM + +## Playgrounds +timeline.xctimeline +playground.xcworkspace + +# Swift Package Manager +# +# Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. +Packages/ +Package.pins +Package.resolved +# *.xcodeproj +# +# Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata +# hence it is not needed unless you have added a package configuration file to your project +# .swiftpm + +.build/ + +# CocoaPods +# +# We recommend against adding the Pods directory to your .gitignore. However +# you should judge for yourself, the pros and cons are mentioned at: +# https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control +# +# Pods/ +# +# Add this line if you want to avoid checking in source code from the Xcode workspace +# *.xcworkspace + +# Carthage +# +# Add this line if you want to avoid checking in source code from Carthage dependencies. +# Carthage/Checkouts + +Carthage/Build/ + +# Accio dependency management +Dependencies/ +.accio/ + +# fastlane +# +# It is recommended to not store the screenshots in the git repo. +# Instead, use fastlane to re-generate the screenshots whenever they are needed. +# For more information about the recommended setup visit: +# https://docs.fastlane.tools/best-practices/source-control/#source-control + +fastlane/report.xml +fastlane/Preview.html +fastlane/screenshots/**/*.png +fastlane/test_output + +# Code Injection +# +# After new code Injection tools there's a generated folder /iOSInjectionProject +# https://github.com/johnno1962/injectionforxcode + +iOSInjectionProject/ + +# Localization +dips/framework/Localizable.xcstrings diff --git a/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..db1876f --- /dev/null +++ b/Package.swift @@ -0,0 +1,35 @@ +// swift-tools-version: 5.9 + +import PackageDescription + +let package = Package( + name: "FhirQuestionnairesOnSwiftUI", + defaultLocalization: "en", + platforms: [ + .iOS(.v17) + ], + products: [ + .library( + name: "FhirQuestionnairesOnSwiftUI", + targets: ["FhirQuestionnairesOnSwiftUI"]), + ], + dependencies: [ + .package(url: "https://github.com/apple/FHIRModels", .upToNextMajor(from: "0.5.0")), + .package(url: "https://github.com/kishikawakatsumi/KeychainAccess", .upToNextMajor(from: "4.2.2")), + ], + targets: [ + .target( + name: "FhirQuestionnairesOnSwiftUI", + dependencies: [ + .product(name: "ModelsR5", package: "FHIRModels"), + .product(name: "KeychainAccess", package: "KeychainAccess") + ], + resources: [ + .process("resources") + ] + ), + .testTarget( + name: "FhirQuestionnairesOnSwiftUITests", + dependencies: ["FhirQuestionnairesOnSwiftUI"]), + ] +) diff --git a/README.md b/README.md new file mode 100644 index 0000000..512aa89 --- /dev/null +++ b/README.md @@ -0,0 +1,206 @@ +# FhirQuestionnairesOnSwiftUI + +## Getting started: minimal SwiftUI example + +```swift +import SwiftUI +import ModelsR5 +import FhirQuestionnairesOnSwiftUI + +struct ContentView: View { + let task = Questionnaire.from(json: PreviewJson.get())!.toTask() + @State var isPresented = false + + var body: some View { + VStack { + Spacer() + Button + { + isPresented = true + } + label: + { + Text("Show Questionnaire") + .frame(maxWidth: .infinity) + .padding(.vertical, 5) + } + .buttonStyle(.borderedProminent) + } + .padding() + .sheet(isPresented: $isPresented) + { + PROTaskView(for: task, isPresented: $isPresented, completeWith: + { task in + // handle results + }) + .edgesIgnoringSafeArea(.bottom) + .interactiveDismissDisabled() + } + } +} + +struct PreviewJson { + static func get() -> String { + return """ + { + "resourceType": "Questionnaire", + "id": "overview", + "meta": { + "profile": [ + "http://retwet.eu/fhir/StructureDefinition/questionnaire|1.0.0" + ] + }, + "url": "http://retwet.eu/fhir/Questionnaire/task-preview", + "version": "1.1", + "title": "Übersicht", + "status": "draft", + "date": "2024-01-01", + "extension": [ + { + "url": "http://retwet.eu/fhir/StructureDefinition/questionnaire/extension-cancel-group", + "extension": [ + { + "url": "http://retwet.eu/fhir/StructureDefinition/questionnaire/extension-cancel-group/cancel-when", + "extension": [ + { + "url": "http://retwet.eu/fhir/StructureDefinition/questionnaire/extension-cancel-group/cancel-when/question", + "valueString": "preview-4" + }, + { + "url": "http://retwet.eu/fhir/StructureDefinition/questionnaire/extension-cancel-group/cancel-when/operator", + "valueString": ">=" + }, + { + "url": "http://retwet.eu/fhir/StructureDefinition/questionnaire/extension-cancel-group/cancel-when/answer", + "valueInteger": 18 + } + ] + } + ] + } + ], + "item": [ + { + "linkId": "preview-1", + "extension": [ + { + "url": "http://retwet.eu/fhir/StructureDefinition/questionnaire/item/extension-title", + "valueString": "Welcome" + } + ], + "text": "Do you have allergies?", + "type": "boolean", + "required": true + }, + { + "linkId": "preview-2", + "extension": [ + { + "url": "http://retwet.eu/fhir/StructureDefinition/questionnaire/item/extension-title", + "valueString": "General Questions" + } + ], + "text": "Here are some general questions for you to answer in the beginning.", + "repeats": true, + "type": "group", + "required": false, + "item": [ + { + "linkId": "2.1", + "text": "What is your gender?", + "type": "string" + }, + { + "linkId": "2.2", + "text": "What is your date of birth?", + "type": "date" + }, + { + "linkId": "2.3", + "text": "What is your country of birth?", + "type": "string" + }, + { + "linkId": "2.4", + "text": "What is your marital status?", + "type": "string" + } + ], + "enableWhen": [ + { + "question": "preview-1", + "operator": "=", + "answerBoolean": true + } + ] + }, + { + "linkId": "preview-3", + "extension": [ + { + "url": "http://retwet.eu/fhir/StructureDefinition/questionnaire/item/extension-title", + "valueString": "Intoxications" + } + ], + "type": "group", + "item": [ + { + "linkId": "3.1", + "text": "Do you smoke?", + "type": "boolean" + }, + { + "linkId": "3.2", + "text": "Do you drink alchohol?", + "type": "boolean" + } + ] + }, + { + "linkId": "preview-4", + "text": "How old are you?", + "type": "integer", + "extension": [ + { + "url": "http://retwet.eu/fhir/StructureDefinition/questionnaire/item/extension-integer-decimal-value-min", + "valueInteger": 0 + }, + { + "url": "http://retwet.eu/fhir/StructureDefinition/questionnaire/item/extension-integer-decimal-value-max", + "valueInteger": 120 + }, + { + "url": "http://retwet.eu/fhir/StructureDefinition/questionnaire/item/extension-integer-decimal-value-step-size", + "valueInteger": 1 + } + ] + }, + { + "linkId": "preview-5", + "text": "How tall are you in meters?", + "type": "decimal", + "extension": [ + { + "url": "http://retwet.eu/fhir/StructureDefinition/questionnaire/item/extension-integer-decimal-value-min", + "valueDecimal": 0.8 + }, + { + "url": "http://retwet.eu/fhir/StructureDefinition/questionnaire/item/extension-integer-decimal-value-max", + "valueDecimal": 2.5 + }, + { + "url": "http://retwet.eu/fhir/StructureDefinition/questionnaire/item/extension-integer-decimal-value-step-size", + "valueDecimal": 0.01 + } + ] + } + ] + } + """ + } +} + +#Preview { + ContentView() +} +``` \ No newline at end of file diff --git a/Sources/FhirQuestionnairesOnSwiftUI/conditions/Comperator.swift b/Sources/FhirQuestionnairesOnSwiftUI/conditions/Comperator.swift new file mode 100644 index 0000000..8b93d1b --- /dev/null +++ b/Sources/FhirQuestionnairesOnSwiftUI/conditions/Comperator.swift @@ -0,0 +1,12 @@ +import Foundation +import ModelsR5 + +public enum Comperator: String +{ + case equals = "==" + case notEquals = "!=" + case greaterThan = ">" + case greaterOrEqualsThan = ">=" + case lessThan = "<" + case lessOrEqualsThan = "<=" +} diff --git a/Sources/FhirQuestionnairesOnSwiftUI/conditions/PROBooleanCondition.swift b/Sources/FhirQuestionnairesOnSwiftUI/conditions/PROBooleanCondition.swift new file mode 100644 index 0000000..382ca3e --- /dev/null +++ b/Sources/FhirQuestionnairesOnSwiftUI/conditions/PROBooleanCondition.swift @@ -0,0 +1,14 @@ +import Foundation + +public struct PROBooleanCondition: PROSingleCondition +{ + public var stepId: String + + public typealias ValueType = Bool + public var toCompare: ValueType + + public func getPredicate() -> NSPredicate + { + return NSPredicate(format: "result == %@", NSNumber(value: toCompare)) + } +} diff --git a/Sources/FhirQuestionnairesOnSwiftUI/conditions/PROCondition.swift b/Sources/FhirQuestionnairesOnSwiftUI/conditions/PROCondition.swift new file mode 100644 index 0000000..23dd482 --- /dev/null +++ b/Sources/FhirQuestionnairesOnSwiftUI/conditions/PROCondition.swift @@ -0,0 +1,189 @@ +import Foundation + +public protocol PROCondition +{ + func evaluate(steps: [any PROStep]) -> Bool +} + +public struct PROAndCondition: PROCondition +{ + private var conditions: [any PROCondition] = [] + + public init(_ conditions: (any PROCondition)...) + { + self.init(conditions) + } + + public init(_ conditions: [any PROCondition]) + { + self.conditions.append(contentsOf: conditions) + } + + public func evaluate(steps: [any PROStep]) -> Bool + { + return !conditions.compactMap{ $0.evaluate(steps: steps)}.contains(false) + } +} + +public struct PROOrCondition: PROCondition +{ + private var conditions: [any PROCondition] = [] + private var minTrueCount: Int + + public init(with minTrueCount: Int? = nil, _ conditions: (any PROCondition)...) + { + self.init(with: minTrueCount, conditions) + } + + public init(with minTrueCount: Int? = nil, _ conditions: [any PROCondition]) + { + self.minTrueCount = minTrueCount ?? 1 + self.conditions.append(contentsOf: conditions) + } + + public func evaluate(steps: [any PROStep]) -> Bool + { + return conditions.compactMap{ $0.evaluate(steps: steps)}.filter{ $0 }.count >= minTrueCount + } +} + +public protocol PROSingleCondition: PROCondition +{ + var stepId: String { get } + + associatedtype ValueType + var toCompare: ValueType { get } + + func getPredicate() -> NSPredicate +} + +extension PROSingleCondition +{ + public func evaluate(steps: [any PROStep]) -> Bool + { + return steps.flatMap { toStepArray(step: $0) } + .filter { $0.id == stepId } + .compactMap { checkSameValueType($0) } + // needed for predicate to work + .compactMap { toResultWrapper(step: $0) } + .compactMap { getPredicate().evaluate(with: $0) } + .filter { $0 } + .first ?? false + } + + private func checkSameValueType(_ step: any PROStep) -> (any PROStepWithResult)? + { + if let step = step as? (any PROStepWithResult) + { + // TODO check type of toCompare equals step.result.value + return step + } + + return nil + } + + private func toStepArray(step: any PROStep) -> [any PROStep] + { + if let step = step as? PROGroupStep + { + return step.sections.flatMap { toStepArray(step: $0) } + } + + if let step = step as? PRORepeatStep + { + return step.result.value ?? [] + } + + return [step] + } + + private func toResultWrapper(step: any PROStep) -> (any ResultWrapper)? + { + if let step = step as? PROTextInputStep, let value = step.result.value + { + return TextResultWrapper(with: value) + } + + if let step = step as? PRONumberInputStep, let value = step.result.value + { + return NumberResultWrapper(with: value) + } + + if let step = step as? PROBooleanInputStep, let value = step.result.value + { + return BooleanResultWrapper(with: value) + } + + if let step = step as? PRODateTimeInputStep, let value = step.result.value + { + return DateResultWrapper(with: value) + } + + if let step = step as? PRODateInputStep, let value = step.result.value + { + return DateResultWrapper(with: value) + } + + if let step = step as? PRODateInputStep, let value = step.result.value + { + return DateResultWrapper(with: value) + } + + if let step = step as? PROSelectionInputStep, let value = step.result.value + { + return TextResultWrapper(with: value) + } + + return nil + } +} + +fileprivate protocol ResultWrapper: NSObject +{ + associatedtype ValueType + var result: ValueType { get } +} + +fileprivate class TextResultWrapper: NSObject, ResultWrapper +{ + typealias ValueType = String + @objc var result: ValueType + + init(with result: String) + { + self.result = result + } +} + +fileprivate class NumberResultWrapper: NSObject, ResultWrapper +{ + typealias ValueType = Double + @objc var result: ValueType + + init(with result: Double) + { + self.result = result + } +} + +fileprivate class BooleanResultWrapper: NSObject, ResultWrapper +{ + typealias ValueType = Bool + @objc var result: ValueType + + init(with result: Bool) + { + self.result = result + } +} + +fileprivate class DateResultWrapper: NSObject, ResultWrapper +{ + typealias ValueType = Date + @objc var result: ValueType + + init(with result: Date) + { + self.result = result + } +} diff --git a/Sources/FhirQuestionnairesOnSwiftUI/conditions/PRODateCondition.swift b/Sources/FhirQuestionnairesOnSwiftUI/conditions/PRODateCondition.swift new file mode 100644 index 0000000..03697d3 --- /dev/null +++ b/Sources/FhirQuestionnairesOnSwiftUI/conditions/PRODateCondition.swift @@ -0,0 +1,15 @@ +import Foundation + +public struct PRODateCondition: PROSingleCondition +{ + public var stepId: String + public var comperator: Comperator + + public typealias ValueType = Date + public var toCompare: ValueType + + public func getPredicate() -> NSPredicate + { + NSPredicate(format: "result \(comperator.rawValue) %@", toCompare as CVarArg) + } +} diff --git a/Sources/FhirQuestionnairesOnSwiftUI/conditions/PRONumberCondition.swift b/Sources/FhirQuestionnairesOnSwiftUI/conditions/PRONumberCondition.swift new file mode 100644 index 0000000..1cabe3b --- /dev/null +++ b/Sources/FhirQuestionnairesOnSwiftUI/conditions/PRONumberCondition.swift @@ -0,0 +1,15 @@ +import Foundation + +public struct PRONumberCondition: PROSingleCondition +{ + public var stepId: String + public var comperator: Comperator + + public typealias ValueType = Double + public var toCompare: ValueType + + public func getPredicate() -> NSPredicate + { + return NSPredicate(format: "result \(comperator.rawValue) %f", toCompare) + } +} diff --git a/Sources/FhirQuestionnairesOnSwiftUI/conditions/PROTextCondition.swift b/Sources/FhirQuestionnairesOnSwiftUI/conditions/PROTextCondition.swift new file mode 100644 index 0000000..d190259 --- /dev/null +++ b/Sources/FhirQuestionnairesOnSwiftUI/conditions/PROTextCondition.swift @@ -0,0 +1,15 @@ +import Foundation + +public struct PROTextCondition: PROSingleCondition +{ + public var stepId: String + public var comperator: Comperator + + public typealias ValueType = String + public var toCompare: ValueType + + public func getPredicate() -> NSPredicate + { + return NSPredicate(format: "result \(comperator.rawValue) %@", toCompare) + } +} diff --git a/Sources/FhirQuestionnairesOnSwiftUI/db/PROPersistanceController.swift b/Sources/FhirQuestionnairesOnSwiftUI/db/PROPersistanceController.swift new file mode 100644 index 0000000..1b1a3e8 --- /dev/null +++ b/Sources/FhirQuestionnairesOnSwiftUI/db/PROPersistanceController.swift @@ -0,0 +1,108 @@ +import Foundation +import CoreData +import CryptoKit +import KeychainAccess + +internal struct PROPersistenceController +{ + static let shared = PROPersistenceController() + + let container: NSPersistentContainer + + private init() + { + guard let url = Bundle.module.url(forResource: "PROModel", withExtension: "momd") else + { + fatalError("Could not get URL for model: PROModel") + } + + guard let model = NSManagedObjectModel(contentsOf: url) else + { + fatalError("Could not get MOM for model: PROModel") + } + + container = NSPersistentContainer(name: "FhirQuestionnairesOnSwiftUI.PROModel", managedObjectModel: model) + container.loadPersistentStores + { description, error in + if let error = error + { + fatalError("Error loading persistent stores: \(error.localizedDescription)") + } + } + } + + private func save() + { + let context = container.viewContext + + if (context.hasChanges) + { + try? context.save() + } + } + + internal func save(result: PROResultModelWrapper) + { + if let owner = result.owner, let task = result.task + { + let toSave = doLoad(owner: owner, task: task) ?? PROResultModel(context: container.viewContext) + + toSave.owner = result.owner + toSave.task = result.task + toSave.results = result.results?.encrypt(using: encryptionKey()) + toSave.completed = result.completed + + save() + } + } + + internal func load(for owner: String, and task: String) -> PROResultModelWrapper? + { + if let loaded = doLoad(owner: owner, task: task) + { + let decryptedResults = loaded.results?.decrypt(using: encryptionKey()) + return PROResultModelWrapper(owner: loaded.owner, task: loaded.task, results: decryptedResults, completed: loaded.completed) + } + + return nil + } + + private func doLoad(owner: String, task: String) -> PROResultModel? + { + let fetchRequest = PROResultModel.fetchRequest() + fetchRequest.fetchLimit = 1 + + let ownerPredicate = NSPredicate(format: "owner == %@", owner) + let taskPredicate = NSPredicate(format: "task == %@", task) + + fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [ownerPredicate, taskPredicate]) + let loaded = try? container.viewContext.fetch(fetchRequest).first + + return loaded + } + + internal func delete(for owner: String, and task: String) + { + if let task = doLoad(owner: owner, task: task) + { + container.viewContext.delete(task) + save() + } + } + + private static let KEYCHAIN_ENCRYPTION_KEY_IDENTIFIER = "encryption-key" + + private func encryptionKey() -> SymmetricKey + { + let keychain = Keychain(service: Bundle.main.bundleIdentifier!) + let keyString = keychain[string: PROPersistenceController.KEYCHAIN_ENCRYPTION_KEY_IDENTIFIER] ?? initializeKey(keychain: keychain) + return SymmetricKey(data: SHA256.hash(data: keyString.data(using: .utf8)!)) + } + + private func initializeKey(keychain: Keychain) -> String + { + let keyString = UUID().uuidString + keychain[string: PROPersistenceController.KEYCHAIN_ENCRYPTION_KEY_IDENTIFIER] = keyString + return keyString + } +} diff --git a/Sources/FhirQuestionnairesOnSwiftUI/db/PROResultModelWrapper.swift b/Sources/FhirQuestionnairesOnSwiftUI/db/PROResultModelWrapper.swift new file mode 100644 index 0000000..31328ed --- /dev/null +++ b/Sources/FhirQuestionnairesOnSwiftUI/db/PROResultModelWrapper.swift @@ -0,0 +1,9 @@ +import Foundation + +internal struct PROResultModelWrapper +{ + internal let owner: String? + internal let task: String? + internal let results: String? + internal let completed: Bool +} diff --git a/Sources/FhirQuestionnairesOnSwiftUI/db/StringEncryption.swift b/Sources/FhirQuestionnairesOnSwiftUI/db/StringEncryption.swift new file mode 100644 index 0000000..d268fcb --- /dev/null +++ b/Sources/FhirQuestionnairesOnSwiftUI/db/StringEncryption.swift @@ -0,0 +1,29 @@ +import Foundation +import CryptoKit + +internal extension String +{ + func encrypt(using symmetricKey: SymmetricKey) -> String? + { + if let data = self.data(using: .utf8) + { + let encryptedData = try? AES.GCM.seal(data, using: symmetricKey).combined + return encryptedData?.base64EncodedString() + } + + return nil + } + + func decrypt(using symmetricKey: SymmetricKey) -> String? + { + if let encryptedData = self.data(using: .utf8), let base64EncraptedData = Data(base64Encoded: encryptedData) + { + if let sealedBox = try? AES.GCM.SealedBox(combined: base64EncraptedData), let decryptedData = try? AES.GCM.open(sealedBox, using: symmetricKey) + { + return String(data: decryptedData, encoding: .utf8) + } + } + + return nil + } +} diff --git a/Sources/FhirQuestionnairesOnSwiftUI/fhir/PROTaskExtensions.swift b/Sources/FhirQuestionnairesOnSwiftUI/fhir/PROTaskExtensions.swift new file mode 100644 index 0000000..cb5c42a --- /dev/null +++ b/Sources/FhirQuestionnairesOnSwiftUI/fhir/PROTaskExtensions.swift @@ -0,0 +1,225 @@ +import Foundation +import ModelsR5 + +public extension PROTask +{ + func toQuestionnaireResponse() -> QuestionnaireResponse? + { + let url = getUrl() + let status = getStatus() + let items = getItems() + + return QuestionnaireResponse(item: items, questionnaire: url, status: status) + } + + internal func parseStepResults(from questionnaireResponse: QuestionnaireResponse) + { + let steps = getSteps(expandGroupSteps: true) + let items = questionnaireResponse.expandItems() + + setStatus(from: questionnaireResponse) + setResults(steps: steps, results: items) + } + + private func getUrl() -> FHIRPrimitive + { + return Canonical(URL(string: self.url)!, version: self.version).asPrimitive() + } + + private func getStatus() -> FHIRPrimitive + { + if (self.completed) + { + return QuestionnaireResponseStatus.completed.asPrimitive() + } + + return QuestionnaireResponseStatus.inProgress.asPrimitive() + } + + private func getItems() -> [QuestionnaireResponseItem] + { + var items: [QuestionnaireResponseItem] = [] + self.getSteps().forEach({ items.append(contentsOf: getItems(step: $0)) }) + return items + } + + private func getItems(step: any PROStep) -> [QuestionnaireResponseItem] + { + let id = FHIRString(step.id).asPrimitive() + + var items: [QuestionnaireResponseItem]? = nil + var answers: [QuestionnaireResponseItemAnswer]? = nil + + // display steps do not have items nor answers + + if let stepWithType = step as? PRORepeatStep + { + return stepWithType.result.value?.flatMap({ getItems(step: $0) }) ?? [] + } + + if let stepWithType = step as? PROTextInputStep + { + if let result = stepWithType.result.value + { + let answer = FHIRString(result).asPrimitive() + let valueX = QuestionnaireResponseItemAnswer.ValueX.string(answer) + answers = [QuestionnaireResponseItemAnswer(value: valueX)] + } + } + + if let stepWithType = step as? PRONumberInputStep + { + if let result = stepWithType.result.value + { + // TODO distinguish between integer and decimal + let answer = FHIRDecimal(floatLiteral: result).asPrimitive() + let valueX = QuestionnaireResponseItemAnswer.ValueX.decimal(answer) + answers = [QuestionnaireResponseItemAnswer(value: valueX)] + } + } + + if let stepWithType = step as? PROBooleanInputStep + { + if let result = stepWithType.result.value + { + let answer = FHIRBool(result).asPrimitive() + let valueX = QuestionnaireResponseItemAnswer.ValueX.boolean(answer) + answers = [QuestionnaireResponseItemAnswer(value: valueX)] + } + } + + if let stepWithType = step as? PRODateTimeInputStep + { + if let result = stepWithType.result.value, let answer = try? DateTime(result.toIsoDateTimeString()).asPrimitive() + { + let valueX = QuestionnaireResponseItemAnswer.ValueX.dateTime(answer) + answers = [QuestionnaireResponseItemAnswer(value: valueX)] + } + } + + if let stepWithType = step as? PRODateInputStep + { + if let result = stepWithType.result.value, let answer = try? FHIRDate(result.toIsoDateString()).asPrimitive() + { + let valueX = QuestionnaireResponseItemAnswer.ValueX.date(answer) + answers = [QuestionnaireResponseItemAnswer(value: valueX)] + } + } + + if let stepWithType = step as? PROTimeInputStep + { + if let result = stepWithType.result.value, let answer = try? FHIRTime(result.toIsoTimeString()).asPrimitive() + { + let valueX = QuestionnaireResponseItemAnswer.ValueX.time(answer) + answers = [QuestionnaireResponseItemAnswer(value: valueX)] + } + } + + if let stepWithType = step as? PROSelectionInputStep + { + if let result = stepWithType.result.value + { + let answer = FHIRString(result).asPrimitive() + let valueX = QuestionnaireResponseItemAnswer.ValueX.string(answer) + answers = [QuestionnaireResponseItemAnswer(value: valueX)] + } + } + + if let stepWithType = step as? PROGroupStep + { + items = stepWithType.sections.flatMap({ getItems(step: $0) }) + } + + return [QuestionnaireResponseItem(answer: answers, item: items, linkId: id)] + } + + private func setStatus(from questionnaireResponse: QuestionnaireResponse) + { + if (questionnaireResponse.status.value == .completed) + { + self.completed = true + } + } + + private func setResults(steps: [any PROStep], results: [QuestionnaireResponseItem]) + { + steps.compactMap({ $0 as? PRORepeatStep }).forEach({ $0.setResult(value: []) }) + steps.compactMap({ toPROStepWithResult(step: $0) }).forEach({ setResultFromItems(step: $0, results: results) }) + } + + private func toPROStepWithResult(step: any PROStep) -> (any PROStepWithResult)? + { + return step as? (any PROStepWithResult) + } + + private func setResultFromItems(step: any PROStepWithResult, results: [QuestionnaireResponseItem]) + { + results.filter({ idsMatch(step: step, result: $0) }).forEach({ setResultFromItem(step: step, result: $0) }) + } + + private func idsMatch(step: any PROStep, result: QuestionnaireResponseItem) -> Bool + { + return result.linkId.value?.string == step.id + } + + private func setResultFromItem(step: any PROStepWithResult, result: QuestionnaireResponseItem) + { + if let stepTyped = step as? PRORepeatStep + { + let copy = stepTyped.repeatable.copy() as! any PROStep + stepTyped.addResult(value: [copy]) + + let subresults = result.expandItems() + let substeps = copy.expand(expandGroupStep: true, expandRepeatStep: true) + setResults(steps: substeps, results: subresults) + } + else + { + setResultFromAnswer(result: step.result, answer: result.answer?.first?.value) + } + } + + private func setResultFromAnswer(result: any PROResult, answer: QuestionnaireResponseItemAnswer.ValueX?) + { + switch(answer) + { + case .string(let value): // includes coding results + if let typedResult = result as? PROTextResult + { + typedResult.value = value.value?.string + } + case .integer(let value): + if let typedResult = result as? PRONumberResult + { + typedResult.value = Double(value.value!.integer) + } + case .decimal(let value): + if let typedResult = result as? PRONumberResult + { + typedResult.value = NSDecimalNumber(decimal: value.value!.decimal).doubleValue + } + case .boolean(let value): + if let typedResult = result as? PROBooleanResult + { + typedResult.value = value.value?.bool + } + case .dateTime(let value): + if let typedResult = result as? PRODateResult, let date = value.value + { + typedResult.value = try? date.asNSDate() + } + case .date(let value): + if let typedResult = result as? PRODateResult, let date = value.value + { + typedResult.value = try? date.asNSDate() + } + case .time(let value): + if let typedResult = result as? PRODateResult, let time = value.value + { + typedResult.value = Date.fromIsoDateString("\(time.hour):\(time.minute):\(time.second)")! + } + default: + break + } + } +} diff --git a/Sources/FhirQuestionnairesOnSwiftUI/fhir/QuestionnaireExtensions.swift b/Sources/FhirQuestionnairesOnSwiftUI/fhir/QuestionnaireExtensions.swift new file mode 100644 index 0000000..566245d --- /dev/null +++ b/Sources/FhirQuestionnairesOnSwiftUI/fhir/QuestionnaireExtensions.swift @@ -0,0 +1,400 @@ +import Foundation +import ModelsR5 + +public extension Questionnaire +{ + static func from(json: String) -> Questionnaire? + { + if let data = json.data(using: .utf8) + { + return try? JSONDecoder().decode(Questionnaire.self, from: data) + } + + return nil + } + + func toTask() -> PROTask + { + let urlVersion = getTaskId() + let title = getTaskTitle() + let steps = getSteps(depth: 0) + let cancelConditions = getCancelConditions() + + let urlVersionSplit = urlVersion.components(separatedBy: "|") + let url = urlVersionSplit[0] + let version = (urlVersionSplit.count > 1) ? urlVersionSplit[1] : nil + + return PROTask(id: urlVersion, url: url, version: version, title: title, steps: steps, cancelConditions: cancelConditions) + } + + private func getTaskId() -> String + { + var id = "\(self.url!.value!.url)" + + if let version = self.version + { + id = "\(id)|\(version.value!.string)" + } + + return id + } + + private func getTaskTitle() -> String + { + return self.title!.value!.string + } + + private func getSteps(depth: Int) -> [any PROStep] + { + return self.item?.compactMap({ getStep(from: $0, depth: depth) }) ?? [] + } + + private func getStep(from item: QuestionnaireItem, depth: Int) -> (any PROStep)? + { + let id = getStepId(from: item) + + let title = getExtensionStringValue(from: item, with: "http://retwet.eu/fhir/StructureDefinition/questionnaire/item/extension-title") + let text = getStepText(from: item) + + let required = getRequired(from: item) + + let enableConditions = getEnableConditions(from: item) + let enabled = enableConditions.isEmpty + + return getStep(item: item, id: id, title: title, text: text, required: required, enabled: enabled, enableConditions: enableConditions, depth: depth) + } + + private func getStepId(from item: QuestionnaireItem) -> String + { + return item.linkId.value!.string + } + + private func getStepText(from item: QuestionnaireItem) -> String? + { + return item.text?.value?.string + } + + private func getRequired(from item: QuestionnaireItem) -> Bool + { + return item.required?.value?.bool ?? false + } + + private func getEnableConditions(from item: QuestionnaireItem) -> [any PROCondition] + { + let conditions = item.enableWhen?.compactMap({ getEnableCondition(from: $0) }) ?? [] + + if (conditions.count > 1) + { + if (item.enableBehavior! == EnableWhenBehavior.all) + { + return [PROAndCondition(conditions)] + } + + if (item.enableBehavior! == EnableWhenBehavior.any) + { + let minTrueCount = getExtensionIntValue(from: item.enableBehavior!, with: "http://retwet.eu/fhir/StructureDefinition/questionnaire/item/enableBehaviour/extension-any-count") + return [PROOrCondition(with: minTrueCount, conditions)] + } + } + + return conditions + } + + private func getEnableCondition(from enableWhen: QuestionnaireItemEnableWhen) -> (any PROCondition)? + { + let id = enableWhen.question.value!.string + let comperator = enableWhen.operator.value!.toComerator() + + switch(enableWhen.answer) + { + case .string(let value): + return PROTextCondition(stepId: id, comperator: comperator, toCompare: value.value!.string) + case .integer(let value): + return PRONumberCondition(stepId: id, comperator: comperator, toCompare: Double(value.value!.integer)) + case .decimal(let value): + return PRONumberCondition(stepId: id, comperator: comperator, toCompare: NSDecimalNumber(decimal: value.value!.decimal).doubleValue) + case .boolean(let value): + return PROBooleanCondition(stepId: id, toCompare: value.value!.bool) + case .dateTime(let value): + let toCompare = (try? value.value!.asNSDate())! + return PRODateCondition(stepId: id, comperator: comperator, toCompare: toCompare) + case .date(let value): + let toCompare = (try? value.value!.asNSDate())! + return PRODateCondition(stepId: id, comperator: comperator, toCompare: toCompare) + case .time(let value): + let time = value.value! + let toCompare = Date.fromIsoDateString("\(time.hour):\(time.minute):\(time.second)")! + return PRODateCondition(stepId: id, comperator: comperator, toCompare: toCompare) + case .coding(let value): + return PROTextCondition(stepId: id, comperator: comperator, toCompare: value.code!.value!.string) + default: + return nil + } + } + + private func getStep(item: QuestionnaireItem, id: String, title: String?, text: String?, required: Bool, enabled: Bool, enableConditions: [any PROCondition], depth: Int) -> (any PROStep)? + { + var step: (any PROStep)? = nil + let font = (depth > 0) ? PRODisplayStep.COMPACT_VIEW_TITLE_FONT : PRODisplayStep.DEFAULT_TITLE_FONT + + let type = item.type.value + switch (type) + { + case .display: + step = PRODisplayStep(id: id, title: title, text: text, titleFont: font, enabled: enabled, enableConditions: enableConditions) + case .string, .text: + let placeholder = getExtensionStringValue(from: item, with: "http://retwet.eu/fhir/StructureDefinition/questionnaire/item/extension-string-text-placeholder") ?? PROTextInputStep.DEFAULT_PLACEHOLDER + let minLineLimit = getExtensionIntValue(from: item, with: "http://retwet.eu/fhir/StructureDefinition/questionnaire/item/extension-string-text-linelimit-min") ?? ((type == .string) ? PROTextInputStep.DEFAULT_MIN_LINE_LIMIT_STRING : PROTextInputStep.DEFAULT_MIN_LINE_LIMIT_TEXT) + let maxLineLimit = getExtensionIntValue(from: item, with: "http://retwet.eu/fhir/StructureDefinition/questionnaire/item/extension-string-text-linelimit-max") ?? PROTextInputStep.DEFAULT_MAX_LINE_LIMIT + step = PROTextInputStep(id: id, title: title, text: text, placeholder: placeholder, minLineLimit: minLineLimit, maxLineLimit: maxLineLimit, titleFont: font, required: required, enabled: enabled, enableConditions: enableConditions) + case .integer, .decimal: + let minValue = getExtensionDoubleValue(from: item, with: "http://retwet.eu/fhir/StructureDefinition/questionnaire/item/extension-integer-decimal-value-min")! + let maxValue = getExtensionDoubleValue(from: item, with: "http://retwet.eu/fhir/StructureDefinition/questionnaire/item/extension-integer-decimal-value-max")! + let stepSize = getExtensionDoubleValue(from: item, with: "http://retwet.eu/fhir/StructureDefinition/questionnaire/item/extension-integer-decimal-value-step-size")! + let valueText = getExtensionStringValue(from: item, with: "http://retwet.eu/fhir/StructureDefinition/questionnaire/item/extension-integer-decimal-value-text") + step = PRONumberInputStep(id: id, title: title, text: text, valueText: valueText, minValue: minValue, maxValue: maxValue, stepSize: stepSize, titleFont: font, required: required, enabled: enabled, enableConditions: enableConditions) + case .boolean: + step = PROBooleanInputStep(id: id, title: title, text: text, titleFont: font, required: required, enabled: enabled, enableConditions: enableConditions) + case .dateTime: + step = PRODateTimeInputStep(id: id, title: title, text: text, titleFont: font, required: required, enbaled: enabled, enableConditions: enableConditions) + case .date: + step = PRODateInputStep(id: id, title: title, text: text, titleFont: font, required: required, enabled: enabled, enableConditions: enableConditions) + case .time: + step = PROTimeInputStep(id: id, title: title, text: text, titleFont: font, required: required, enabled: enabled, enableConditions: enableConditions) + case .coding: + let options = getAnswerOptions(from: item) + step = PROSelectionInputStep(id: id, title: title, text: text, options: options, titleFont: font, required: required, enabled: enabled, enableConditions: enableConditions) + case .group: + let compactPresentation = (item.repeats?.value?.bool ?? false) || depth > 0 + let compactPresentationWithDisplayView = depth > 0 + let sections = item.item?.compactMap({ getStep(from: $0, depth: depth + 1) }) ?? [] + step = PROGroupStep(id: id, title: title, text: text, sections: sections, titleFont: font, compactPresentation: compactPresentation, compactPresentationWithDisplayView: compactPresentationWithDisplayView, required: required, enabled: enabled, enableConditions: enableConditions) + default: + return nil + } + + if let repeats = item.repeats?.value?.bool, repeats == true + { + return PRORepeatStep(id: id, title: title, text: text, repeatable: step!, titleFont: font, compactPresentation: depth > 0, required: required, enabled: enabled, enableConditions: enableConditions) + } + else + { + return step + } + } + + private func getAnswerOptions(from item: QuestionnaireItem) -> [String] + { + return item.answerOption?.compactMap({ answerOptionToString(from: $0.value) }) ?? [] + } + + private func answerOptionToString(from value: QuestionnaireItemAnswerOption.ValueX) -> String? + { + switch(value) + { + case .string(let value): + if let string = value.value + { + return string.string + } + case .integer(let value): + if let integer = value.value + { + return "\(integer.integer)" + } + case .date(let value): + if let dateValue = value.value, let date = try? dateValue.asNSDate() + { + return date.toIsoDateString() + } + case .time(let value): + if let time = value.value + { + return "\(time.hour):\(time.minute):\(time.second)" + } + case .coding(let value): + if let code = value.code, let string = code.value + { + return string.string + } + default: + return nil + } + + return nil + } + + private func getCancelConditions() -> [any PROCondition] + { + return self.extensions(for: "http://retwet.eu/fhir/StructureDefinition/questionnaire/extension-cancel-group").compactMap({ getCancelCondition(from: $0) }) + } + + private func getCancelCondition(from cancelGroup: Extension) -> (any PROCondition)? + { + let cancelWhens = cancelGroup.extensions(for: "http://retwet.eu/fhir/StructureDefinition/questionnaire/extension-cancel-group/cancel-when").compactMap({ doGetCancelCondition(from: $0) }) + + if let cancelBehavior = cancelGroup.extensions(for: "http://retwet.eu/fhir/StructureDefinition/questionnaire/extension-cancel-group/cancel-behavior").first, let type = getExtensionStringValue(from: cancelBehavior, with: "http://retwet.eu/fhir/StructureDefinition/questionnaire/extension-cancel-group/cancel-behavior/type") + { + if (type == "all") + { + return PROAndCondition(cancelWhens) + } + + if (type == "any") + { + if let anyCount = getExtensionIntValue(from: cancelBehavior, with: "http://retwet.eu/fhir/StructureDefinition/questionnaire/extension-cancel-group/cancel-behavior/any-count") + { + return PROOrCondition(with: anyCount, cancelWhens) + } + } + } + + return PROOrCondition(cancelWhens) + } + + private func doGetCancelCondition(from cancelWhen: Extension) -> (any PROCondition)? + { + let id = getExtensionStringValue(from: cancelWhen, with: "http://retwet.eu/fhir/StructureDefinition/questionnaire/extension-cancel-group/cancel-when/question")! + let comperator = QuestionnaireItemOperator(rawValue: getExtensionStringValue(from: cancelWhen, with: "http://retwet.eu/fhir/StructureDefinition/questionnaire/extension-cancel-group/cancel-when/operator")!)!.toComerator() + let extensionValue = getExtension(from: cancelWhen, with: "http://retwet.eu/fhir/StructureDefinition/questionnaire/extension-cancel-group/cancel-when/answer")!.value + + switch(extensionValue) + { + case .string(let value): + return PROTextCondition(stepId: id, comperator: comperator, toCompare: value.value!.string) + case .integer(let value): + return PRONumberCondition(stepId: id, comperator: comperator, toCompare: Double(value.value!.integer)) + case .decimal(let value): + return PRONumberCondition(stepId: id, comperator: comperator, toCompare: NSDecimalNumber(decimal: value.value!.decimal).doubleValue) + case .boolean(let value): + return PROBooleanCondition(stepId: id, toCompare: value.value!.bool) + case .dateTime(let value): + let toCompare = (try? value.value!.asNSDate())! + return PRODateCondition(stepId: id, comperator: comperator, toCompare: toCompare) + case .date(let value): + let toCompare = (try? value.value!.asNSDate())! + return PRODateCondition(stepId: id, comperator: comperator, toCompare: toCompare) + case .time(let value): + let time = value.value! + let toCompare = Date.fromIsoDateString("\(time.hour):\(time.minute):\(time.second)")! + return PRODateCondition(stepId: id, comperator: comperator, toCompare: toCompare) + case .coding(let value): + return PROTextCondition(stepId: id, comperator: comperator, toCompare: value.code!.value!.string) + default: + return nil + } + } + + private func getExtension(from element: Element, with url: String) -> Extension? + { + return element.extensions(for: url).first + } + + private func getExtensionStringValue(from element: Element, with url: String) -> String? + { + return element.extensions(for: url).compactMap + { + if case .string(let value) = $0.value + { + return value.value?.string + } + + return nil + }.first + } + + private func getExtensionIntValue(from element: Element, with url: String) -> Int? + { + return getExtensionDoubleValue(from: element, with: url).map({ Int($0) }) + } + + private func getExtensionDoubleValue(from element: Element, with url: String) -> Double? + { + return element.extensions(for: url).compactMap + { + if case .integer(let value) = $0.value + { + if let integer = value.value?.integer + { + return Double(integer) + } + } + + if case .decimal(let value) = $0.value + { + if let decimal = value.value?.decimal + { + return NSDecimalNumber(decimal: decimal).doubleValue + } + } + + return nil + }.first + } + + private func getExtensionStringValue(from element: any FHIRPrimitiveProtocol, with url: String) -> String? + { + return element.extensions(for: url).compactMap + { + if case .string(let value) = $0.value + { + return value.value?.string + } + + return nil + }.first + } + + private func getExtensionIntValue(from element: any FHIRPrimitiveProtocol, with url: String) -> Int? + { + return getExtensionDoubleValue(from: element, with: url).map({ Int($0) }) + } + + private func getExtensionDoubleValue(from element: any FHIRPrimitiveProtocol, with url: String) -> Double? + { + return element.extensions(for: url).compactMap + { + if case .integer(let value) = $0.value + { + if let integer = value.value?.integer + { + return Double(integer) + } + } + + if case .decimal(let value) = $0.value + { + if let decimal = value.value?.decimal + { + return NSDecimalNumber(decimal: decimal).doubleValue + } + } + + return nil + }.first + } +} + +internal extension QuestionnaireItemOperator +{ + func toComerator() -> Comperator + { + switch (self) + { + case .equal, .exists: + // QuestionnaireItemOperator.exists must be boolean + // therefore return Comperator.equals + return Comperator.equals + case .notEqual: + return Comperator.notEquals + case .greaterThan: + return Comperator.greaterThan + case .greaterThanOrEqual: + return Comperator.greaterOrEqualsThan + case .lessThan: + return Comperator.lessThan + case .lessThanOrEqual: + return Comperator.lessOrEqualsThan + } + } +} diff --git a/Sources/FhirQuestionnairesOnSwiftUI/fhir/QuestionnaireResponseExtensions.swift b/Sources/FhirQuestionnairesOnSwiftUI/fhir/QuestionnaireResponseExtensions.swift new file mode 100644 index 0000000..8b1295d --- /dev/null +++ b/Sources/FhirQuestionnairesOnSwiftUI/fhir/QuestionnaireResponseExtensions.swift @@ -0,0 +1,42 @@ +import Foundation +import ModelsR5 + +internal extension QuestionnaireResponse +{ + static func from(json: String) -> QuestionnaireResponse? + { + if let data = json.data(using: .utf8) + { + return try? JSONDecoder().decode(QuestionnaireResponse.self, from: data) + } + + return nil + } + + func toJson() -> String? + { + if let json = try? JSONEncoder().encode(self) + { + return String(data: json, encoding: .utf8) + } + + return nil + } + + func expandItems() -> [QuestionnaireResponseItem] + { + return item?.flatMap({ $0.expandItems() }) ?? [] + } +} + +internal extension QuestionnaireResponseItem +{ + func expandItems() -> [QuestionnaireResponseItem] + { + var expandedItem = [self] + let subitems = self.item?.flatMap({ $0.expandItems() }) ?? [] + expandedItem.append(contentsOf: subitems) + + return expandedItem + } +} diff --git a/Sources/FhirQuestionnairesOnSwiftUI/fhir/profiles/README.md b/Sources/FhirQuestionnairesOnSwiftUI/fhir/profiles/README.md new file mode 100644 index 0000000..079fae6 --- /dev/null +++ b/Sources/FhirQuestionnairesOnSwiftUI/fhir/profiles/README.md @@ -0,0 +1,12 @@ +# Resource Validation against Profiles + +It is possible to validate Questionnaire and QuestionnaireResponse resources against their specified profiles including code systems and value set resources: + +1. Download the [HL7 FHIR validator](https://github.com/hapifhir/org.hl7.fhir.core/releases/latest/download/validator_cli.jar). +2. Execute the validator using the following command: + + `java -jar validator_cli.jar -ig -version 5.0` + + The profiles should not be located in the same folder as the resource to be tested. + +A complete guide on using the validator can be found on the [HL7 Confluence](https://confluence.hl7.org/display/FHIR/Using+the+FHIR+Validator#UsingtheFHIRValidator-Downloadingthevalidator). \ No newline at end of file diff --git a/Sources/FhirQuestionnairesOnSwiftUI/fhir/profiles/questionnaire-1.0.0.xml b/Sources/FhirQuestionnairesOnSwiftUI/fhir/profiles/questionnaire-1.0.0.xml new file mode 100644 index 0000000..77919ae --- /dev/null +++ b/Sources/FhirQuestionnairesOnSwiftUI/fhir/profiles/questionnaire-1.0.0.xml @@ -0,0 +1,610 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Sources/FhirQuestionnairesOnSwiftUI/fhir/profiles/questionnaire-extension-cancel-group-1.0.0.xml b/Sources/FhirQuestionnairesOnSwiftUI/fhir/profiles/questionnaire-extension-cancel-group-1.0.0.xml new file mode 100644 index 0000000..ec89c47 --- /dev/null +++ b/Sources/FhirQuestionnairesOnSwiftUI/fhir/profiles/questionnaire-extension-cancel-group-1.0.0.xml @@ -0,0 +1,62 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Sources/FhirQuestionnairesOnSwiftUI/fhir/profiles/questionnaire-extension-cancel-group-extension-cancel-behavior-1.0.0.xml b/Sources/FhirQuestionnairesOnSwiftUI/fhir/profiles/questionnaire-extension-cancel-group-extension-cancel-behavior-1.0.0.xml new file mode 100644 index 0000000..6835ad7 --- /dev/null +++ b/Sources/FhirQuestionnairesOnSwiftUI/fhir/profiles/questionnaire-extension-cancel-group-extension-cancel-behavior-1.0.0.xml @@ -0,0 +1,63 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Sources/FhirQuestionnairesOnSwiftUI/fhir/profiles/questionnaire-extension-cancel-group-extension-cancel-behavior-extension-any-count-1.0.0.xml b/Sources/FhirQuestionnairesOnSwiftUI/fhir/profiles/questionnaire-extension-cancel-group-extension-cancel-behavior-extension-any-count-1.0.0.xml new file mode 100644 index 0000000..f422c27 --- /dev/null +++ b/Sources/FhirQuestionnairesOnSwiftUI/fhir/profiles/questionnaire-extension-cancel-group-extension-cancel-behavior-extension-any-count-1.0.0.xml @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Sources/FhirQuestionnairesOnSwiftUI/fhir/profiles/questionnaire-extension-cancel-group-extension-cancel-behavior-extension-type-1.0.0.xml b/Sources/FhirQuestionnairesOnSwiftUI/fhir/profiles/questionnaire-extension-cancel-group-extension-cancel-behavior-extension-type-1.0.0.xml new file mode 100644 index 0000000..4af311d --- /dev/null +++ b/Sources/FhirQuestionnairesOnSwiftUI/fhir/profiles/questionnaire-extension-cancel-group-extension-cancel-behavior-extension-type-1.0.0.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Sources/FhirQuestionnairesOnSwiftUI/fhir/profiles/questionnaire-extension-cancel-group-extension-cancel-when-1.0.0.xml b/Sources/FhirQuestionnairesOnSwiftUI/fhir/profiles/questionnaire-extension-cancel-group-extension-cancel-when-1.0.0.xml new file mode 100644 index 0000000..59c9bd2 --- /dev/null +++ b/Sources/FhirQuestionnairesOnSwiftUI/fhir/profiles/questionnaire-extension-cancel-group-extension-cancel-when-1.0.0.xml @@ -0,0 +1,72 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Sources/FhirQuestionnairesOnSwiftUI/fhir/profiles/questionnaire-extension-cancel-group-extension-cancel-when-extension-answer-1.0.0.xml b/Sources/FhirQuestionnairesOnSwiftUI/fhir/profiles/questionnaire-extension-cancel-group-extension-cancel-when-extension-answer-1.0.0.xml new file mode 100644 index 0000000..d25d824 --- /dev/null +++ b/Sources/FhirQuestionnairesOnSwiftUI/fhir/profiles/questionnaire-extension-cancel-group-extension-cancel-when-extension-answer-1.0.0.xml @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Sources/FhirQuestionnairesOnSwiftUI/fhir/profiles/questionnaire-extension-cancel-group-extension-cancel-when-extension-operator-1.0.0.xml b/Sources/FhirQuestionnairesOnSwiftUI/fhir/profiles/questionnaire-extension-cancel-group-extension-cancel-when-extension-operator-1.0.0.xml new file mode 100644 index 0000000..0c32a4c --- /dev/null +++ b/Sources/FhirQuestionnairesOnSwiftUI/fhir/profiles/questionnaire-extension-cancel-group-extension-cancel-when-extension-operator-1.0.0.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Sources/FhirQuestionnairesOnSwiftUI/fhir/profiles/questionnaire-extension-cancel-group-extension-cancel-when-extension-question-1.0.0.xml b/Sources/FhirQuestionnairesOnSwiftUI/fhir/profiles/questionnaire-extension-cancel-group-extension-cancel-when-extension-question-1.0.0.xml new file mode 100644 index 0000000..1629e4f --- /dev/null +++ b/Sources/FhirQuestionnairesOnSwiftUI/fhir/profiles/questionnaire-extension-cancel-group-extension-cancel-when-extension-question-1.0.0.xml @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Sources/FhirQuestionnairesOnSwiftUI/fhir/profiles/questionnaire-item-enable-behavior-extension-any-count-1.0.0.xml b/Sources/FhirQuestionnairesOnSwiftUI/fhir/profiles/questionnaire-item-enable-behavior-extension-any-count-1.0.0.xml new file mode 100644 index 0000000..b1832b7 --- /dev/null +++ b/Sources/FhirQuestionnairesOnSwiftUI/fhir/profiles/questionnaire-item-enable-behavior-extension-any-count-1.0.0.xml @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Sources/FhirQuestionnairesOnSwiftUI/fhir/profiles/questionnaire-item-extension-integer-decimal-extension-value-max-1.0.0.xml b/Sources/FhirQuestionnairesOnSwiftUI/fhir/profiles/questionnaire-item-extension-integer-decimal-extension-value-max-1.0.0.xml new file mode 100644 index 0000000..0b62e7b --- /dev/null +++ b/Sources/FhirQuestionnairesOnSwiftUI/fhir/profiles/questionnaire-item-extension-integer-decimal-extension-value-max-1.0.0.xml @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Sources/FhirQuestionnairesOnSwiftUI/fhir/profiles/questionnaire-item-extension-integer-decimal-extension-value-min-1.0.0.xml b/Sources/FhirQuestionnairesOnSwiftUI/fhir/profiles/questionnaire-item-extension-integer-decimal-extension-value-min-1.0.0.xml new file mode 100644 index 0000000..4d69db8 --- /dev/null +++ b/Sources/FhirQuestionnairesOnSwiftUI/fhir/profiles/questionnaire-item-extension-integer-decimal-extension-value-min-1.0.0.xml @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Sources/FhirQuestionnairesOnSwiftUI/fhir/profiles/questionnaire-item-extension-integer-decimal-extension-value-step-size-1.0.0.xml b/Sources/FhirQuestionnairesOnSwiftUI/fhir/profiles/questionnaire-item-extension-integer-decimal-extension-value-step-size-1.0.0.xml new file mode 100644 index 0000000..a994586 --- /dev/null +++ b/Sources/FhirQuestionnairesOnSwiftUI/fhir/profiles/questionnaire-item-extension-integer-decimal-extension-value-step-size-1.0.0.xml @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Sources/FhirQuestionnairesOnSwiftUI/fhir/profiles/questionnaire-item-extension-integer-decimal-extension-value-text-1.0.0.xml b/Sources/FhirQuestionnairesOnSwiftUI/fhir/profiles/questionnaire-item-extension-integer-decimal-extension-value-text-1.0.0.xml new file mode 100644 index 0000000..4625376 --- /dev/null +++ b/Sources/FhirQuestionnairesOnSwiftUI/fhir/profiles/questionnaire-item-extension-integer-decimal-extension-value-text-1.0.0.xml @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Sources/FhirQuestionnairesOnSwiftUI/fhir/profiles/questionnaire-item-extension-string-text-extension-linelimit-max-1.0.0.xml b/Sources/FhirQuestionnairesOnSwiftUI/fhir/profiles/questionnaire-item-extension-string-text-extension-linelimit-max-1.0.0.xml new file mode 100644 index 0000000..113ba0c --- /dev/null +++ b/Sources/FhirQuestionnairesOnSwiftUI/fhir/profiles/questionnaire-item-extension-string-text-extension-linelimit-max-1.0.0.xml @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Sources/FhirQuestionnairesOnSwiftUI/fhir/profiles/questionnaire-item-extension-string-text-extension-linelimit-min-1.0.0.xml b/Sources/FhirQuestionnairesOnSwiftUI/fhir/profiles/questionnaire-item-extension-string-text-extension-linelimit-min-1.0.0.xml new file mode 100644 index 0000000..576f9ee --- /dev/null +++ b/Sources/FhirQuestionnairesOnSwiftUI/fhir/profiles/questionnaire-item-extension-string-text-extension-linelimit-min-1.0.0.xml @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Sources/FhirQuestionnairesOnSwiftUI/fhir/profiles/questionnaire-item-extension-string-text-extension-placeholder-1.0.0.xml b/Sources/FhirQuestionnairesOnSwiftUI/fhir/profiles/questionnaire-item-extension-string-text-extension-placeholder-1.0.0.xml new file mode 100644 index 0000000..ec3084c --- /dev/null +++ b/Sources/FhirQuestionnairesOnSwiftUI/fhir/profiles/questionnaire-item-extension-string-text-extension-placeholder-1.0.0.xml @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Sources/FhirQuestionnairesOnSwiftUI/fhir/profiles/questionnaire-item-extension-title-1.0.0.xml b/Sources/FhirQuestionnairesOnSwiftUI/fhir/profiles/questionnaire-item-extension-title-1.0.0.xml new file mode 100644 index 0000000..ac56476 --- /dev/null +++ b/Sources/FhirQuestionnairesOnSwiftUI/fhir/profiles/questionnaire-item-extension-title-1.0.0.xml @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Sources/FhirQuestionnairesOnSwiftUI/fhir/profiles/questionnaire-response-1.0.0.xml b/Sources/FhirQuestionnairesOnSwiftUI/fhir/profiles/questionnaire-response-1.0.0.xml new file mode 100644 index 0000000..c6bbe84 --- /dev/null +++ b/Sources/FhirQuestionnairesOnSwiftUI/fhir/profiles/questionnaire-response-1.0.0.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Sources/FhirQuestionnairesOnSwiftUI/resources/Localizable.xcstrings b/Sources/FhirQuestionnairesOnSwiftUI/resources/Localizable.xcstrings new file mode 100644 index 0000000..fb6d670 --- /dev/null +++ b/Sources/FhirQuestionnairesOnSwiftUI/resources/Localizable.xcstrings @@ -0,0 +1,189 @@ +{ + "sourceLanguage" : "en", + "strings" : { + "" : { + + }, + "%@ %lld/%lld" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$@ %2$lld/%3$lld" + } + } + } + }, + "add" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hinzufügen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Add" + } + } + } + }, + "answer" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Antwort" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Answer" + } + } + } + }, + "back" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zurück" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Back" + } + } + } + }, + "cancel" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Abbrechen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cancel" + } + } + } + }, + "complete" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Abschließen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Complete" + } + } + } + }, + "next" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Weiter" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Next" + } + } + } + }, + "no" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nein" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No" + } + } + } + }, + "placeholder" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Geben Sie hier Ihre Antwort ein.." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enter your answer here.." + } + } + } + }, + "step" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Schritt" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Step" + } + } + } + }, + "yes" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ja" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Yes" + } + } + } + } + }, + "version" : "1.0" +} \ No newline at end of file diff --git a/Sources/FhirQuestionnairesOnSwiftUI/resources/PROModel.xcdatamodeld/PROModel.xcdatamodel/contents b/Sources/FhirQuestionnairesOnSwiftUI/resources/PROModel.xcdatamodeld/PROModel.xcdatamodel/contents new file mode 100644 index 0000000..6afb9a5 --- /dev/null +++ b/Sources/FhirQuestionnairesOnSwiftUI/resources/PROModel.xcdatamodeld/PROModel.xcdatamodel/contents @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/Sources/FhirQuestionnairesOnSwiftUI/results/PROBooleanResult.swift b/Sources/FhirQuestionnairesOnSwiftUI/results/PROBooleanResult.swift new file mode 100644 index 0000000..cb4164e --- /dev/null +++ b/Sources/FhirQuestionnairesOnSwiftUI/results/PROBooleanResult.swift @@ -0,0 +1,20 @@ +import Foundation + +public class PROBooleanResult: PROResult +{ + public var id: String + + public typealias ValueType = Bool + public var value: ValueType? + + public init(id: String, value: ValueType? = nil) + { + self.id = id + self.value = value + } + + public func copy(with zone: NSZone? = nil) -> Any + { + return PROBooleanResult(id: id, value: value) + } +} diff --git a/Sources/FhirQuestionnairesOnSwiftUI/results/PRODateResult.swift b/Sources/FhirQuestionnairesOnSwiftUI/results/PRODateResult.swift new file mode 100644 index 0000000..4bb96c8 --- /dev/null +++ b/Sources/FhirQuestionnairesOnSwiftUI/results/PRODateResult.swift @@ -0,0 +1,25 @@ +import Foundation + +public class PRODateResult: PROResult +{ + public var id: String + + public typealias ValueType = Date + public var value: ValueType? + + public init(id: String, value: ValueType? = nil) + { + self.id = id + self.value = value + } + + public func copy(with zone: NSZone? = nil) -> Any + { + return PRODateResult(id: id, value: value) + } +} + +public extension PRODateResult +{ + static var DEFAULT_DATE: ValueType { return Date.now } +} diff --git a/Sources/FhirQuestionnairesOnSwiftUI/results/PRONumberResult.swift b/Sources/FhirQuestionnairesOnSwiftUI/results/PRONumberResult.swift new file mode 100644 index 0000000..f698c72 --- /dev/null +++ b/Sources/FhirQuestionnairesOnSwiftUI/results/PRONumberResult.swift @@ -0,0 +1,20 @@ +import Foundation + +public class PRONumberResult: PROResult +{ + public var id: String + + public typealias ValueType = Double + public var value: ValueType? + + public init(id: String, value: ValueType? = nil) + { + self.id = id + self.value = value + } + + public func copy(with zone: NSZone? = nil) -> Any + { + return PRONumberResult(id: id, value: value) + } +} diff --git a/Sources/FhirQuestionnairesOnSwiftUI/results/PRORepeatResult.swift b/Sources/FhirQuestionnairesOnSwiftUI/results/PRORepeatResult.swift new file mode 100644 index 0000000..67b93ec --- /dev/null +++ b/Sources/FhirQuestionnairesOnSwiftUI/results/PRORepeatResult.swift @@ -0,0 +1,20 @@ +import Foundation + +public class PRORepeatResult: PROResult +{ + public var id: String + + public typealias ValueType = [any PROStep] + public var value: ValueType? + + public init(id: String, value: ValueType? = nil) + { + self.id = id + self.value = value + } + + public func copy(with zone: NSZone? = nil) -> Any + { + return PRORepeatResult(id: id, value: value) + } +} diff --git a/Sources/FhirQuestionnairesOnSwiftUI/results/PROResult.swift b/Sources/FhirQuestionnairesOnSwiftUI/results/PROResult.swift new file mode 100644 index 0000000..e468b57 --- /dev/null +++ b/Sources/FhirQuestionnairesOnSwiftUI/results/PROResult.swift @@ -0,0 +1,9 @@ +import SwiftUI + +public protocol PROResult: Identifiable, NSCopying +{ + var id: String { get } + + associatedtype ValueType + var value: ValueType? { get set } +} diff --git a/Sources/FhirQuestionnairesOnSwiftUI/results/PROTextResult.swift b/Sources/FhirQuestionnairesOnSwiftUI/results/PROTextResult.swift new file mode 100644 index 0000000..e5eb3e8 --- /dev/null +++ b/Sources/FhirQuestionnairesOnSwiftUI/results/PROTextResult.swift @@ -0,0 +1,20 @@ +import Foundation + +public class PROTextResult: PROResult +{ + public var id: String + + public typealias ValueType = String + public var value: ValueType? + + public init(id: String, value: ValueType? = nil) + { + self.id = id + self.value = value + } + + public func copy(with zone: NSZone? = nil) -> Any + { + return PROTextResult(id: id, value: value) + } +} diff --git a/Sources/FhirQuestionnairesOnSwiftUI/steps/PROStep.swift b/Sources/FhirQuestionnairesOnSwiftUI/steps/PROStep.swift new file mode 100644 index 0000000..7dbb118 --- /dev/null +++ b/Sources/FhirQuestionnairesOnSwiftUI/steps/PROStep.swift @@ -0,0 +1,75 @@ +import SwiftUI + +public protocol PROStep: Identifiable, NSCopying +{ + var id: String { get } + var title: String? { get } + var text: String? { get } + + var required: Bool { get } + + var enabled: Bool { get set } + var enableConditions: [any PROCondition] { get } + + var titleFont: Font { get set } + + func createView(enableStateRecalculation: (() -> ())?, nextCompleteButtonStateRecalculation: (() -> ())?) -> AnyView +} + +public extension PROStep +{ + func createView(enableStateRecalculation: (() -> ())? = nil, nextCompleteButtonStateRecalculation: (() -> ())? = nil) -> AnyView + { + return createView(enableStateRecalculation: nil, nextCompleteButtonStateRecalculation: nextCompleteButtonStateRecalculation) + } + + internal func expand(expandGroupStep: Bool = false, expandRepeatStep: Bool = false) -> [any PROStep] + { + if let step = self as? PROGroupStep, expandGroupStep == true + { + return step.sections.flatMap({ $0.expand(expandGroupStep: expandGroupStep, expandRepeatStep: expandRepeatStep) }) + } + + if let step = self as? PRORepeatStep, expandRepeatStep == true + { + return step.result.value ?? [] + } + + return [self] + } +} + +public extension PROStep +{ + static var DEFAULT_TITLE_FONT: Font { return .title2 } + static var COMPACT_VIEW_TITLE_FONT: Font { return .headline } +} + +public protocol PROStepWithResult: PROStep +{ + associatedtype ResultType: PROResult + var result: ResultType { get } + + associatedtype ValueType + func addResult(value: ValueType) + + func hasResult() -> Bool +} + +public extension PROStepWithResult +{ + func addResult(value: ResultType.ValueType) + { + result.value = value + } + + func hasResult() -> Bool + { + if let repeatStep = self as? PRORepeatStep + { + return !(repeatStep.result.value?.isEmpty ?? true) + } + + return result.value != nil + } +} diff --git a/Sources/FhirQuestionnairesOnSwiftUI/steps/boolean/PROBooleanInputStep.swift b/Sources/FhirQuestionnairesOnSwiftUI/steps/boolean/PROBooleanInputStep.swift new file mode 100644 index 0000000..ceb6bf2 --- /dev/null +++ b/Sources/FhirQuestionnairesOnSwiftUI/steps/boolean/PROBooleanInputStep.swift @@ -0,0 +1,89 @@ +import SwiftUI + +public class PROBooleanInputStep: PROStepWithResult +{ + public var id: String + public var title: String? + public var text: String? + + public let required: Bool + + public typealias ResultType = PROBooleanResult + public var result: ResultType + + public var titleFont: Font + + public var enabled: Bool + public var enableConditions: [any PROCondition] + + public init(id: String, title: String? = nil, text: String? = nil, titleFont: Font = DEFAULT_TITLE_FONT, required: Bool = false, enabled: Bool = true, enableConditions: [any PROCondition] = []) + { + self.id = id + self.title = title + self.text = text + + self.required = required + self.result = ResultType(id: id, value: nil) + + self.titleFont = titleFont + + self.enabled = enabled + self.enableConditions = enableConditions + } + + public func createView(enableStateRecalculation: (() -> ())? = nil, nextCompleteButtonStateRecalculation: (() -> ())? = nil) -> AnyView + { + return AnyView(PROBooleanInputStepView(step: self, enableStateRecalculation: enableStateRecalculation, nextCompleteButtonStepRecalculation: nextCompleteButtonStateRecalculation)) + } + + public func copy(with zone: NSZone? = nil) -> Any + { + let copy = PROBooleanInputStep(id: id, title: title, text: text, titleFont: titleFont, enableConditions: enableConditions) + copy.result = result.copy(with: zone) as! ResultType + return copy + } +} + +public extension PROBooleanInputStep +{ + private static func getTitle() -> String + { + return "Lorem Ipsum" + } + + private static func getShortText() -> String + { + return "Lorem ipsum dolor sit amet." + } + + private static func getLongText() -> String + { + return "Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet." + } + + static func getPreviewObject() -> PROBooleanInputStep + { + return PROBooleanInputStep(id: "boolean.preview", title: getTitle(), text: getLongText()) + } + + static func getPreviewObjectWithTitle() -> PROBooleanInputStep + { + return PROBooleanInputStep(id: "boolean.preview-with-title", title: getTitle()) + } + + static func getPreviewObjectWithShortText() -> PROBooleanInputStep + { + return PROBooleanInputStep(id: "boolean.preview-with-short-text", text: getShortText()) + } + + static func getPreviewObjectWithLongText() -> PROBooleanInputStep + { + return PROBooleanInputStep(id: "boolean.preview-with-long-text", text: getLongText()) + } + + static func getPreviewObjectCancelCondition() -> PROBooleanCondition + { + return PROBooleanCondition(stepId: "boolean.preview", toCompare: false) + } +} + diff --git a/Sources/FhirQuestionnairesOnSwiftUI/steps/boolean/PROBooleanInputStepView.swift b/Sources/FhirQuestionnairesOnSwiftUI/steps/boolean/PROBooleanInputStepView.swift new file mode 100644 index 0000000..d1fff7d --- /dev/null +++ b/Sources/FhirQuestionnairesOnSwiftUI/steps/boolean/PROBooleanInputStepView.swift @@ -0,0 +1,75 @@ +import SwiftUI + +internal struct PROBooleanInputStepView: View +{ + private let step: PROBooleanInputStep + private let options = [Bool.YES, Bool.NO] + private let enableStateRecalculation: (() -> ())? + private let nextCompleteButtonStateRecalculation: (() -> ())? + + @State private var answerValue: String + private var viewInitialized: Bool = false + + internal init(step: PROBooleanInputStep, enableStateRecalculation: (() -> ())? = nil, nextCompleteButtonStepRecalculation: (() -> ())? = nil) + { + UISegmentedControl.appearance().selectedSegmentTintColor = .tintColor + UISegmentedControl.appearance().setTitleTextAttributes([.foregroundColor : UIColor.white], for: .selected) + + self.step = step + self.enableStateRecalculation = enableStateRecalculation + self.nextCompleteButtonStateRecalculation = nextCompleteButtonStepRecalculation + + self._answerValue = State(wrappedValue: self.step.result.value?.asString ?? "") + self.viewInitialized = true + } + + internal var body: some View + { + VStack(alignment: .leading) + { + PRODisplayStepView(step: step) + + Picker("", selection: $answerValue) + { + ForEach(options, id: \.self) + { option in + Text(option) + } + } + .pickerStyle(.segmented) + .frame(maxWidth: 250) + + } + .onChange(of: answerValue) + { + if (Bool.YES == answerValue) + { + step.addResult(value: true) + } + + if (Bool.NO == answerValue) + { + step.addResult(value: false) + } + + // Do not change result value when init sets default view value + if (viewInitialized) + { + if let recalculate = enableStateRecalculation + { + recalculate() + } + + if let recalculate = nextCompleteButtonStateRecalculation + { + recalculate() + } + } + } + } +} + +#Preview +{ + PROBooleanInputStepView(step: PROBooleanInputStep.getPreviewObject()) +} diff --git a/Sources/FhirQuestionnairesOnSwiftUI/steps/dateTime/PRODateInputStep.swift b/Sources/FhirQuestionnairesOnSwiftUI/steps/dateTime/PRODateInputStep.swift new file mode 100644 index 0000000..3fc6a40 --- /dev/null +++ b/Sources/FhirQuestionnairesOnSwiftUI/steps/dateTime/PRODateInputStep.swift @@ -0,0 +1,79 @@ +import SwiftUI + +public class PRODateInputStep: PRODateTimeStep +{ + public var id: String + public var title: String? + public var text: String? + + public var required: Bool + + public typealias ResultType = PRODateResult + public var result: ResultType + + public var titleFont: Font + + public var enabled: Bool + public var enableConditions: [any PROCondition] + + public init(id: String, title: String? = nil, text: String? = nil, titleFont: Font = DEFAULT_TITLE_FONT, required: Bool = false, enabled: Bool = true, enableConditions: [any PROCondition] = []) + { + self.id = id + self.title = title + self.text = text + + self.required = required + self.result = ResultType(id: id, value: nil) + + self.titleFont = titleFont + + self.enabled = enabled + self.enableConditions = enableConditions + } + + public func createView(enableStateRecalculation: (() -> ())? = nil, nextCompleteButtonStateRecalculation: (() -> ())? = nil) -> AnyView + { + return AnyView(PRODateTimeInputStepView(step: self, displayedComponents: .date, enableStateRecalculation: enableStateRecalculation, nextCompleteButtonStateRecalculation: nextCompleteButtonStateRecalculation)) + } + + public func copy(with zone: NSZone? = nil) -> Any + { + let copy = PRODateInputStep(id: id, title: title, text: text, titleFont: titleFont, enableConditions: enableConditions) + copy.result = result.copy(with: zone) as! ResultType + return copy + } +} + +public extension PRODateInputStep +{ + private static func getTitle() -> String + { + return "Lorem Ipsum" + } + + private static func getText() -> String + { + return "Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet." + } + + static func getPreviewObject() -> PRODateInputStep + { + return PRODateInputStep(id: "date.preview", title: getTitle(), text: getText()) + } + + static func getPreviewObjectWithTitle() -> PRODateInputStep + { + return PRODateInputStep(id: "date.preview-with-title", title: getTitle()) + } + + static func getPreviewObjectWithText() -> PRODateInputStep + { + return PRODateInputStep(id: "date.preview.preview-with-text", text: getText()) + } + + static func getPreviewObjectCancelCondition() -> PRODateCondition + { + return PRODateCondition(stepId: "date.preview", comperator: .greaterThan, toCompare: Date.now) + } +} + diff --git a/Sources/FhirQuestionnairesOnSwiftUI/steps/dateTime/PRODateTimeInputStep.swift b/Sources/FhirQuestionnairesOnSwiftUI/steps/dateTime/PRODateTimeInputStep.swift new file mode 100644 index 0000000..0b1aa44 --- /dev/null +++ b/Sources/FhirQuestionnairesOnSwiftUI/steps/dateTime/PRODateTimeInputStep.swift @@ -0,0 +1,99 @@ +import SwiftUI + +public class PRODateTimeInputStep: PRODateTimeStep +{ + public var id: String + public var title: String? + public var text: String? + + public var required: Bool + + public typealias ResultType = PRODateResult + public var result: ResultType + + public var titleFont: Font + + public var enabled: Bool + public var enableConditions: [any PROCondition] + + public init(id: String, title: String? = nil, text: String? = nil, titleFont: Font = DEFAULT_TITLE_FONT, required: Bool = false, enbaled: Bool = true, enableConditions: [any PROCondition] = []) + { + self.id = id + self.title = title + self.text = text + + self.required = required + self.result = ResultType(id: id, value: nil) + + self.titleFont = titleFont + + self.enabled = enbaled + self.enableConditions = enableConditions + } + + public func createView(enableStateRecalculation: (() -> ())? = nil, nextCompleteButtonStateRecalculation: (() -> ())? = nil) -> AnyView + { + return AnyView(PRODateTimeInputStepView(step: self, enableStateRecalculation: enableStateRecalculation, nextCompleteButtonStateRecalculation: nextCompleteButtonStateRecalculation)) + } + + public func copy(with zone: NSZone? = nil) -> Any + { + let copy = PRODateTimeInputStep(id: id, title: title, text: text, titleFont: titleFont, enableConditions: enableConditions) + copy.result = result.copy(with: zone) as! ResultType + return copy + } +} + +public extension PRODateTimeInputStep +{ + private static func getTitle() -> String + { + return "Lorem Ipsum" + } + + private static func getShortText() -> String + { + return "Lorem ipsum dolor sit amet." + } + + private static func getLongText() -> String + { + return "Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet." + } + + static func getPreviewObject() -> PRODateTimeInputStep + { + return PRODateTimeInputStep(id: "dateTime.preview", title: getTitle(), text: getLongText(), enableConditions: [getPreviewObjectEnableOrCondition()]) + } + + static func getPreviewObjectWithTitle() -> PRODateTimeInputStep + { + return PRODateTimeInputStep(id: "dateTime.preview-with-title", title: getTitle()) + } + + static func getPreviewObjectWithShortText() -> PRODateTimeInputStep + { + return PRODateTimeInputStep(id: "dateTime.preview.preview-with-short-text", text: getShortText()) + } + + static func getPreviewObjectWithLongText() -> PRODateTimeInputStep + { + return PRODateTimeInputStep(id: "dateTime.preview.preview-with-long-text", text: getLongText()) + } + + static func getPreviewObjectEnableOrCondition() -> PROOrCondition + { + PROOrCondition(getPreviewObjectEnableOr1Condition(), getPreviewObjectEnableOr2Condition()) + } + + private static func getPreviewObjectEnableOr1Condition() -> PROTextCondition + { + return PROTextCondition(stepId: "text.preview", comperator: .equals, toCompare: "Enable3") + } + + private static func getPreviewObjectEnableOr2Condition() -> PRONumberCondition + { + return PRONumberCondition(stepId: "question.preview-int", comperator: .lessThan, toCompare: 5) + } +} + diff --git a/Sources/FhirQuestionnairesOnSwiftUI/steps/dateTime/PRODateTimeInputStepView.swift b/Sources/FhirQuestionnairesOnSwiftUI/steps/dateTime/PRODateTimeInputStepView.swift new file mode 100644 index 0000000..86a38cd --- /dev/null +++ b/Sources/FhirQuestionnairesOnSwiftUI/steps/dateTime/PRODateTimeInputStepView.swift @@ -0,0 +1,71 @@ +import SwiftUI + +internal struct PRODateTimeInputStepView: View +{ + private let step: any PRODateTimeStep + private let displayedComponents: DatePickerComponents? + private let enableStateRecalculation: (() -> ())? + private let nextCompleteButtonStateRecalculation: (() -> ())? + + @State private var answerValue: Date + private var viewInitialized: Bool = false + + internal init(step: some PRODateTimeStep, displayedComponents: DatePickerComponents? = nil, enableStateRecalculation: (() -> ())? = nil, nextCompleteButtonStateRecalculation: (() -> ())? = nil) + { + self.step = step + self.displayedComponents = displayedComponents + self.enableStateRecalculation = enableStateRecalculation + self.nextCompleteButtonStateRecalculation = nextCompleteButtonStateRecalculation + + self._answerValue = State(wrappedValue: self.step.result.value ?? PRODateResult.DEFAULT_DATE) + self.viewInitialized = true + } + + internal var body: some View + { + VStack(alignment: .leading) + { + PRODisplayStepView(step: step) + + DateTimeContent + } + .onChange(of: answerValue) + { + step.addResult(value: answerValue) + + // Do not change result value when init sets default view value + if(viewInitialized) + { + if let recalculate = enableStateRecalculation + { + recalculate() + } + + if let recalculate = nextCompleteButtonStateRecalculation + { + recalculate() + } + } + } + } + + @ViewBuilder + private var DateTimeContent: some View + { + if let components = displayedComponents + { + DatePicker("", selection: $answerValue, displayedComponents: components) + .labelsHidden() + } + else + { + DatePicker("", selection: $answerValue) + .labelsHidden() + } + } +} + +#Preview +{ + PRODateTimeInputStepView(step: PRODateTimeInputStep.getPreviewObject()) +} diff --git a/Sources/FhirQuestionnairesOnSwiftUI/steps/dateTime/PRODateTimeStep.swift b/Sources/FhirQuestionnairesOnSwiftUI/steps/dateTime/PRODateTimeStep.swift new file mode 100644 index 0000000..76e6f90 --- /dev/null +++ b/Sources/FhirQuestionnairesOnSwiftUI/steps/dateTime/PRODateTimeStep.swift @@ -0,0 +1,6 @@ +import Foundation + +public protocol PRODateTimeStep: PROStepWithResult where ResultType == PRODateResult +{ + var result: ResultType { get } +} diff --git a/Sources/FhirQuestionnairesOnSwiftUI/steps/dateTime/PROTimeInputStep.swift b/Sources/FhirQuestionnairesOnSwiftUI/steps/dateTime/PROTimeInputStep.swift new file mode 100644 index 0000000..808c3f6 --- /dev/null +++ b/Sources/FhirQuestionnairesOnSwiftUI/steps/dateTime/PROTimeInputStep.swift @@ -0,0 +1,77 @@ +import SwiftUI + +public class PROTimeInputStep: PRODateTimeStep +{ + public var id: String + public var title: String? + public var text: String? + + public var pickerText: String? + + public var required: Bool + + public typealias ResultType = PRODateResult + public var result: ResultType + + public var titleFont: Font + + public var enabled: Bool + public var enableConditions: [any PROCondition] + + public init(id: String, title: String? = nil, text: String? = nil, titleFont: Font = DEFAULT_TITLE_FONT, required: Bool = false, enabled: Bool = true, + enableConditions: [any PROCondition] = []) + { + self.id = id + self.title = title + self.text = text + + self.required = required + self.result = ResultType(id: id, value: nil) + + self.titleFont = titleFont + + self.enabled = enabled + self.enableConditions = enableConditions + } + + public func createView(enableStateRecalculation: (() -> ())? = nil, nextCompleteButtonStateRecalculation: (() -> ())? = nil) -> AnyView + { + return AnyView(PRODateTimeInputStepView(step: self, displayedComponents: .hourAndMinute, enableStateRecalculation: enableStateRecalculation, nextCompleteButtonStateRecalculation: nextCompleteButtonStateRecalculation)) + } + + public func copy(with zone: NSZone? = nil) -> Any + { + let copy = PROTimeInputStep(id: id, title: title, text: text, titleFont: titleFont, enableConditions: enableConditions) + copy.result = result.copy(with: zone) as! ResultType + return copy + } +} + +public extension PROTimeInputStep +{ + private static func getTitle() -> String + { + return "Lorem Ipsum" + } + + private static func getText() -> String + { + return "Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet." + } + + static func getPreviewObject() -> PROTimeInputStep + { + return PROTimeInputStep(id: "time.preview", title: getTitle(), text: getText()) + } + + static func getPreviewObjectWithTitle() -> PROTimeInputStep + { + return PROTimeInputStep(id: "time.preview-with-title", title: getTitle()) + } + + static func getPreviewObjectWithText() -> PROTimeInputStep + { + return PROTimeInputStep(id: "time.preview.preview-with-text", text: getText()) + } +} + diff --git a/Sources/FhirQuestionnairesOnSwiftUI/steps/display/PRODisplayStep.swift b/Sources/FhirQuestionnairesOnSwiftUI/steps/display/PRODisplayStep.swift new file mode 100644 index 0000000..b6863f7 --- /dev/null +++ b/Sources/FhirQuestionnairesOnSwiftUI/steps/display/PRODisplayStep.swift @@ -0,0 +1,77 @@ +import SwiftUI + +public class PRODisplayStep: PROStep +{ + public var id: String + public var title: String? + public var text: String? + + public var titleFont: Font + + public var required: Bool + + public var enabled: Bool + public var enableConditions: [any PROCondition] + + public init(id: String, title: String? = nil, text: String? = nil, titleFont: Font = DEFAULT_TITLE_FONT, enabled: Bool = true, enableConditions: [any PROCondition] = []) + { + self.id = id + self.title = title + self.text = text + + self.titleFont = titleFont + + self.required = true + + self.enabled = enabled + self.enableConditions = enableConditions + } + + public func createView(enableStateRecalculation: (() -> ())? = nil, nextCompleteButtonStateRecalculation: (() -> ())? = nil) -> AnyView + { + return AnyView(PRODisplayStepView(step: self)) + } + + public func copy(with zone: NSZone? = nil) -> Any + { + return PRODisplayStep(id: id, title: title, text: text, titleFont: titleFont, enableConditions: enableConditions) + } +} + +public extension PRODisplayStep +{ + private static func getTitle() -> String + { + return "Lorem Ipsum" + } + + private static func getShortText() -> String + { + return "Lorem ipsum dolor sit amet, consetetur sadipscing elitr." + } + + private static func getLongText() -> String + { + return "Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet." + } + + static func getPreviewObject() -> PRODisplayStep + { + return PRODisplayStep(id: "instruction.preview", title: getTitle(), text: getShortText()) + } + + static func getPreviewObject2() -> PRODisplayStep + { + return PRODisplayStep(id: "instruction.preview", title: getTitle(), text: getLongText()) + } + + static func getPreviewObjectWithTitle() -> PRODisplayStep + { + return PRODisplayStep(id: "instruction.preview-with-title", title: getTitle()) + } + + static func getPreviewObjectWithText() -> PRODisplayStep + { + return PRODisplayStep(id: "instruction.preview-with-text", text: getLongText()) + } +} diff --git a/Sources/FhirQuestionnairesOnSwiftUI/steps/display/PRODisplayStepView.swift b/Sources/FhirQuestionnairesOnSwiftUI/steps/display/PRODisplayStepView.swift new file mode 100644 index 0000000..7c76517 --- /dev/null +++ b/Sources/FhirQuestionnairesOnSwiftUI/steps/display/PRODisplayStepView.swift @@ -0,0 +1,45 @@ +import SwiftUI + +internal struct PRODisplayStepView: View +{ + private let step: any PROStep + + internal init(step: any PROStep) + { + self.step = step + } + + internal var body: some View + { + VStack(alignment: .leading) + { + if let title = step.title + { + Text(title) + .font(step.titleFont) + .fontWeight(.bold) + .padding(.bottom, getPadding()) + } + + if let text = step.text + { + Text(text) + } + } + } + + private func getPadding() -> CGFloat + { + if let _ = step.text + { + return 1 + } + + return 0 + } +} + +#Preview +{ + return PRODisplayStepView(step: PRODisplayStep.getPreviewObject2()) +} diff --git a/Sources/FhirQuestionnairesOnSwiftUI/steps/group/PROGroupStep.swift b/Sources/FhirQuestionnairesOnSwiftUI/steps/group/PROGroupStep.swift new file mode 100644 index 0000000..010eaa5 --- /dev/null +++ b/Sources/FhirQuestionnairesOnSwiftUI/steps/group/PROGroupStep.swift @@ -0,0 +1,109 @@ +import SwiftUI + +public class PROGroupStep: PROStep +{ + public var id: String + public var title: String? + public var text: String? + + public var sections: [any PROStep] + + public var titleFont: Font + public var compactPresentation: Bool + public var compactPresentationWithDisplayView: Bool + + public var required: Bool + + public var enabled: Bool + public var enableConditions: [any PROCondition] + + public init(id: String, title: String? = nil, text: String? = nil, sections: [any PROStep], titleFont: Font = DEFAULT_TITLE_FONT, compactPresentation: Bool = false, compactPresentationWithDisplayView: Bool = false, required: Bool = false, enabled: Bool = true, enableConditions: [any PROCondition] = []) + { + self.id = id + self.title = title + self.text = text + + self.sections = sections + + self.titleFont = titleFont + self.compactPresentation = compactPresentation + self.compactPresentationWithDisplayView = compactPresentationWithDisplayView + + self.required = required + + self.enabled = enabled + self.enableConditions = enableConditions + } + + public func createView(enableStateRecalculation: (() -> ())? = nil, nextCompleteButtonStateRecalculation: (() -> ())? = nil) -> AnyView + { + return AnyView(PROGroupStepView(step: self, enableStateRecalculation: enableStateRecalculation, nextCompleteButtonStateRecalculation: nextCompleteButtonStateRecalculation)) + } + + public func copy(with zone: NSZone? = nil) -> Any + { + var sectionsCopy: [any PROStep] = [] + for section in sections + { + sectionsCopy.append(section.copy(with: zone) as! (any PROStep)) + } + + return PROGroupStep(id: id, title: title, text: text, sections: sectionsCopy, titleFont: titleFont, compactPresentation: compactPresentation, enableConditions: enableConditions) + } + + public func recalculateSectionEnableStates() -> [Bool] + { + return sections.compactMap + { + let enabled = $0.enableConditions.isEmpty || $0.enableConditions.compactMap{ $0.evaluate(steps: sections) }.filter{ $0 }.first ?? false + + $0.enabled = enabled + return enabled + } + } +} + +public extension PROGroupStep +{ + static func getPreviewObject() -> PROGroupStep + { + return PROGroupStep(id: "form.preview", title: nil, text: nil, sections: [ + PROTextInputStep.getPreviewObject(), + PROTextInputStep.getPreviewObjectWithTitle(), + PROTextInputStep.getPreviewObjectWithLongText(), + PRODateTimeInputStep.getPreviewObject(), + PROSelectionInputStep.getPreviewObject() + ]) + } + + static func getPreviewObject2() -> PROGroupStep + { + return PROGroupStep(id: "form.preview-with-display-heading", title: PRODisplayStep.getPreviewObject2().title, text: PRODisplayStep.getPreviewObject2().text, sections: [ + PROTextInputStep.getPreviewObject(), + PROTextInputStep.getPreviewObjectWithTitle(), + PROTextInputStep.getPreviewObjectWithLongText(), + PRODateTimeInputStep.getPreviewObject(), + PRODisplayStep.getPreviewObjectWithText(), + PROSelectionInputStep.getPreviewObject() + ]) + } + + static func getPreviewObject3() -> PROGroupStep + { + return PROGroupStep(id: "form.preview-with-repeat", sections: [ + PROTextInputStep.getPreviewObjectWithShortText(), + PRODateTimeInputStep.getPreviewObjectWithShortText(), + PROSelectionInputStep.getPreviewObjectWithShortText() + ], compactPresentation: true) + } + + static func getPreviewObject4() -> PROGroupStep + { + return PROGroupStep(id: "form.preview-with-repeat", title: "Lorem Ipsum Dolor", sections: [ + PROTextInputStep.getPreviewObjectWithShortText(), + PRODateTimeInputStep.getPreviewObjectWithShortText(), + PROSelectionInputStep.getPreviewObjectWithShortText() + ]) + } +} + diff --git a/Sources/FhirQuestionnairesOnSwiftUI/steps/group/PROGroupStepView.swift b/Sources/FhirQuestionnairesOnSwiftUI/steps/group/PROGroupStepView.swift new file mode 100644 index 0000000..e76157b --- /dev/null +++ b/Sources/FhirQuestionnairesOnSwiftUI/steps/group/PROGroupStepView.swift @@ -0,0 +1,191 @@ +import SwiftUI + +internal struct PROGroupStepView: View +{ + private let step: PROGroupStep + private let enableStateRecalculation: (() -> ())? + private let nextCompleteButtonStateRecalculation: (() -> ())? + + @State private var sectionsEnableState: [Bool] + @State private var enableStateRecalculationToggle = false + + internal init(step: PROGroupStep, enableStateRecalculation: (() -> ())? = nil, nextCompleteButtonStateRecalculation: (() -> ())? = nil) + { + self.step = step + self.enableStateRecalculation = enableStateRecalculation + self.nextCompleteButtonStateRecalculation = nextCompleteButtonStateRecalculation + + self.sectionsEnableState = step.recalculateSectionEnableStates() + } + + internal var body: some View + { + VStack(alignment: .leading) + { + if (step.compactPresentation) + { + CompactContent + } + else + { + ExtendedContent + } + } + .onChange(of: enableStateRecalculationToggle) + { + if let recalculate = enableStateRecalculation + { + recalculate() + } + + if let recalculate = nextCompleteButtonStateRecalculation + { + recalculate() + } + } + } + + @ViewBuilder + private var CompactContent: some View + { + Section + { + if (hasDisplayHeader()) + { + PRODisplayStepView(step: step) + .padding(.bottom, 5) + + Divider() + } + + ForEach(0 ..< step.sections.count, id: \.self) + { index in + let section = step.sections[index] + + if(index < sectionsEnableState.count && sectionsEnableState[index]) + { + section.createView( + enableStateRecalculation:{ recalculateEnableStates() }, + nextCompleteButtonStateRecalculation: nextCompleteButtonStateRecalculation + ) + .padding(.top, ((!hasDisplayHeader() && index == 0) ? 0 : 5)) + .padding(.bottom, 5) + + if (index < step.sections.count - 1 && index+1 < sectionsEnableState.count && sectionsEnableState[index+1]) + { + Divider() + } + } + } + } + .labelsHidden() + } + + private func hasDisplayHeader() -> Bool + { + return step.compactPresentationWithDisplayView && (step.title != nil || step.text != nil) + } + + @ViewBuilder + private var ExtendedContent: some View + { + List + { + if (step.title != nil || step.text != nil) + { + Section + { + PRODisplayStepView(step: step) + .listRowBackground(Color(.systemGray6)) + .listRowInsets(.init(top: 0, leading: 0, bottom: 0, trailing:0)) + } + .listSectionSpacing(getListSectionSpacing()) + .padding(.bottom, getPadding()) + } + + ForEach(0 ..< step.sections.count, id: \.self) + { index in + let section = step.sections[index] + + if ((index < sectionsEnableState.count) ? sectionsEnableState[index] : true) + { + Section + { + section.createView( + enableStateRecalculation: { recalculateEnableStates() }, + nextCompleteButtonStateRecalculation: nextCompleteButtonStateRecalculation + ) + .padding(.vertical, 5) + .padding(.horizontal, -5) + } + .labelsHidden() + } + } + } + .listSectionSpacing(.compact) + .padding(.top, getListPadding()) + .padding(.horizontal, -17) + } + + private func recalculateEnableStates() + { + sectionsEnableState = step.recalculateSectionEnableStates() + // forces re-evaluation of parent group step enable states + enableStateRecalculationToggle.toggle() + } + + private func getPadding() -> CGFloat + { + if let _ = step.text + { + return 10 + } + + return 5 + } + + private func getListSectionSpacing() -> CGFloat + { + if let title = step.title + { + if let _ = step.text + { + return 0 + } + + let font = UIFont.boldSystemFont(ofSize: 24) + let size = title.widthWithConstrainedHeight(16, font: font) + 10 + + if (size > UIScreen.SCREEN_WIDTH) + { + return 10 + } + + return -5 + + } + + return 0 + } + + private func getListPadding() -> CGFloat + { + if let title = step.title, step.text == nil + { + let font = UIFont.boldSystemFont(ofSize: 24) + let size = title.widthWithConstrainedHeight(16, font: font) + 10 + + if (size < UIScreen.SCREEN_WIDTH) + { + return -42 + } + } + + return -35 + } +} + +#Preview +{ + PROGroupStepView(step: PROGroupStep.getPreviewObject2()) +} diff --git a/Sources/FhirQuestionnairesOnSwiftUI/steps/number/PRONumberInputStep.swift b/Sources/FhirQuestionnairesOnSwiftUI/steps/number/PRONumberInputStep.swift new file mode 100644 index 0000000..5cc67da --- /dev/null +++ b/Sources/FhirQuestionnairesOnSwiftUI/steps/number/PRONumberInputStep.swift @@ -0,0 +1,117 @@ +import SwiftUI + +public class PRONumberInputStep: PROStepWithResult +{ + public var id: String + public var title: String? + public var text: String? + + public let valueText: String? + + public let minValue: Double + public let maxValue: Double + public let stepSize: Double + + public let required: Bool + + public typealias ResultType = PRONumberResult + public var result: ResultType + + public var titleFont: Font + + public var enabled: Bool + public var enableConditions: [any PROCondition] + + public init(id: String, title: String? = nil, text: String? = nil, valueText: String? = nil, minValue: Double, maxValue: Double, stepSize: Double = 1.0, titleFont: Font = DEFAULT_TITLE_FONT, required: Bool = false, enabled: Bool = true, enableConditions: [any PROCondition] = []) + { + self.id = id + self.title = title + self.text = text + + self.valueText = valueText + + self.minValue = minValue + self.maxValue = maxValue + self.stepSize = stepSize + + self.required = required + self.result = ResultType(id: id, value: nil) + + self.titleFont = titleFont + + self.enabled = enabled + self.enableConditions = enableConditions + } + + public func createView(enableStateRecalculation: (() -> ())? = nil, nextCompleteButtonStateRecalculation: (() -> ())?) -> AnyView + { + return AnyView(PRONumberInputStepView(step: self, enableStateRecalculation: enableStateRecalculation, nextCompleteButtonStateRecalculation: nextCompleteButtonStateRecalculation)) + } + + public func copy(with zone: NSZone? = nil) -> Any + { + let copy = PRONumberInputStep(id: id, title: title, text: text, valueText: valueText, minValue: minValue, maxValue: maxValue, stepSize: stepSize, titleFont: titleFont, enableConditions: enableConditions) + copy.result = result.copy(with: zone) as! ResultType + return copy + } +} + +public extension PRONumberInputStep +{ + private static func getTitle() -> String + { + return "Lorem Ipsum" + } + + private static func getShortText() -> String + { + return "Lorem ipsum dolor sit amet." + } + + private static func getLongText() -> String + { + return "Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet." + } + + static func getPreviewObject() -> PRONumberInputStep + { + return PRONumberInputStep(id: "question.preview-int", title: getTitle(), text: getLongText(), minValue: 0, maxValue: 100, stepSize: 1, enableConditions: [ + PRONumberInputStep.getPreviewObjectEnableCondition(), PRONumberInputStep.getPreviewObject2EnableCondition() + ]) + } + + static func getPreviewObject2() -> PRONumberInputStep + { + return PRONumberInputStep(id: "question.preview-double", title: getTitle(), text: getLongText(), minValue: 0, maxValue: 10, stepSize: 0.01) + } + + static func getPreviewObjectWithTitle() -> PRONumberInputStep + { + return PRONumberInputStep(id: "question.preview-with-title", title: getTitle(), minValue: 0, maxValue: 100, stepSize: 1) + } + + static func getPreviewObjectWithShortText() -> PRONumberInputStep + { + return PRONumberInputStep(id: "question.preview-with-short-text", text: getShortText(), minValue: 0, maxValue: 100, stepSize: 0.1) + } + + static func getPreviewObjectWithLongText() -> PRONumberInputStep + { + return PRONumberInputStep(id: "question.preview-with-long-text", text: getLongText(), minValue: 0, maxValue: 100, stepSize: 0.1) + } + + static func getPreviewObjectCancelCondition() -> PRONumberCondition + { + return PRONumberCondition(stepId: "question.preview-double", comperator: .lessThan, toCompare: 5) + } + + static func getPreviewObjectEnableCondition() -> PROTextCondition + { + return PROTextCondition(stepId: "text.preview", comperator: .equals, toCompare: "Enable") + } + + static func getPreviewObject2EnableCondition() -> PROTextCondition + { + return PROTextCondition(stepId: "text.preview", comperator: .equals, toCompare: "Enable2") + } +} diff --git a/Sources/FhirQuestionnairesOnSwiftUI/steps/number/PRONumberInputStepView.swift b/Sources/FhirQuestionnairesOnSwiftUI/steps/number/PRONumberInputStepView.swift new file mode 100644 index 0000000..b9a98dc --- /dev/null +++ b/Sources/FhirQuestionnairesOnSwiftUI/steps/number/PRONumberInputStepView.swift @@ -0,0 +1,90 @@ +import SwiftUI + +internal struct PRONumberInputStepView: View +{ + private let step: PRONumberInputStep + private let enableStateRecalculation: (() -> ())? + private let nextCompleteButtonStateRecalculation: (() -> ())? + + @State private var answerValue: Double + private var viewInitialized: Bool = false + + internal init(step: PRONumberInputStep, enableStateRecalculation: (() -> ())? = nil, nextCompleteButtonStateRecalculation: (() -> ())? = nil) + { + self.step = step + self.enableStateRecalculation = enableStateRecalculation + self.nextCompleteButtonStateRecalculation = nextCompleteButtonStateRecalculation + + self._answerValue = State(wrappedValue: self.step.result.value ?? step.minValue) + self.viewInitialized = true + } + + internal var body: some View + { + VStack(alignment: .leading) + { + PRODisplayStepView(step: step) + + HStack + { + Text(step.valueText ?? "") + + Text(getValueText(answerValue)) + .fontWeight(.bold) + .foregroundColor(.blue) + } + .padding(.top, 1) + + HStack + { + Slider(value: $answerValue, in: step.minValue...step.maxValue) + + Stepper("", value: $answerValue, in: step.minValue...step.maxValue, step: step.stepSize) + .labelsHidden() + } + } + .onChange(of: answerValue) + { + step.addResult(value: answerValue) + + // Do not change result value when init sets default view value + if (viewInitialized) + { + if let recalculate = enableStateRecalculation + { + recalculate() + } + + if let recalculate = nextCompleteButtonStateRecalculation + { + recalculate() + } + } + } + } + + private func getValueText(_ value: Double) -> String + { + let stepSize = String(step.stepSize) + + if let decimalPointIndex = stepSize.firstIndex(of: ".") + { + let decimalString = stepSize[stepSize.index(after: decimalPointIndex).. ())? = nil, nextCompleteButtonStateRecalculation: (() -> ())? = nil) -> AnyView + { + return AnyView(PRORepeatStepView(step: self, enableStateRecalculation: enableStateRecalculation, nextCompleteButtonStateRecalculation: nextCompleteButtonStateRecalculation)) + } + + public func copy(with zone: NSZone? = nil) -> Any + { + let resultCopy = result.copy(with: zone) as! ResultType + let repeatableCopy = repeatable.copy(with: zone) as! (any PROStep) + + let copy = PRORepeatStep(id: id, title: title, text: text, repeatable: repeatableCopy, titleFont: titleFont, enableConditions: enableConditions) + copy.result = resultCopy + return copy + } + + internal func isButtonDisabled() -> Bool + { + if let groupStep = repeatable as? PROGroupStep + { + var groupDisabled = false + var sectionsDisabled = false + + let resultSteps = groupStep.expand(expandGroupStep: true).compactMap({ $0 as? (any PROStepWithResult) }) + + if (groupStep.required) + { + let hasResultsArray = resultSteps.map({ $0.hasResult() }) + groupDisabled = groupStep.enabled && hasResultsArray.allSatisfy({ $0 == false }) + } + + let requiredSections = resultSteps.filter({ $0.required && $0.enabled }) + if (requiredSections.isEmpty) + { + sectionsDisabled = false + } + else + { + sectionsDisabled = requiredSections.map({ $0.hasResult() }).contains(false) + } + + return (groupDisabled || sectionsDisabled) + } + + if let resultStep = repeatable as? (any PROStepWithResult) + { + return enabled && resultStep.required && !resultStep.hasResult() + } + + return false + } +} + +public extension PRORepeatStep +{ + private static func getTitle() -> String + { + return "Lorem Ipsum" + } + + private static func getShortText() -> String + { + return "Lorem ipsum dolor sit amet, consetetur sadipscing elitr." + } + + private static func getLongText() -> String + { + return "Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet." + } + + static func getPreviewObject() -> PRORepeatStep + { + return PRORepeatStep(id: "repeat.preview-text-input", title: nil, text: nil, repeatable: PROTextInputStep.getPreviewObject()) + } + + static func getPreviewObject2() -> PRORepeatStep + { + return PRORepeatStep(id: "repeat.preview-number-input", title: nil, text: nil, repeatable: PRONumberInputStep.getPreviewObject()) + } + + static func getPreviewObject3() -> PRORepeatStep + { + return PRORepeatStep(id: "repeat.preview-group-input", title: getTitle(), text: getLongText(), repeatable: PROGroupStep.getPreviewObject3(), enableConditions: [getPreviewObjectEnableOrCondition()]) + } + + static func getPreviewObject4() -> PRORepeatStep + { + return PRORepeatStep(id: "repeat.preview-group-input-no-text", title: getTitle(), text: nil, repeatable: PROGroupStep.getPreviewObject3(), enableConditions: [getPreviewObjectEnableOrCondition()]) + } + + static func getPreviewObjectEnableOrCondition() -> PROOrCondition + { + return PROOrCondition(getPreviewObjectEnableOr1Condition(), getPreviewObjectEnableOr2Condition()) + } + + static func getPreviewObjectEnableOr1Condition() -> PROBooleanCondition + { + return PROBooleanCondition(stepId: "boolean.preview", toCompare: true) + } + + static func getPreviewObjectEnableOr2Condition() -> PROAndCondition + { + return PROAndCondition(getPreviewObjectEnableAnd1Condition(), getPreviewObjectEnableAnd2Condition()) + } + + private static func getPreviewObjectEnableAnd1Condition() -> PRODateCondition + { + return PRODateCondition(stepId: "date.preview", comperator: .greaterOrEqualsThan, toCompare: Date.fromIsoDateString("2023-12-01")!) + } + + private static func getPreviewObjectEnableAnd2Condition() -> PRODateCondition + { + return PRODateCondition(stepId: "time.preview", comperator: .greaterThan, toCompare: Date.fromIsoTimeString("12:00:00.000Z")!) + } +} + diff --git a/Sources/FhirQuestionnairesOnSwiftUI/steps/repeat/PRORepeatStepView.swift b/Sources/FhirQuestionnairesOnSwiftUI/steps/repeat/PRORepeatStepView.swift new file mode 100644 index 0000000..6004fd2 --- /dev/null +++ b/Sources/FhirQuestionnairesOnSwiftUI/steps/repeat/PRORepeatStepView.swift @@ -0,0 +1,237 @@ +import SwiftUI + +internal struct PRORepeatStepView: View +{ + private let step: PRORepeatStep + private let enableStateRecalculation: (() -> ())? + private let nextCompleteButtonStateRecalculation: (() -> ())? + + @State private var answerSteps: [any PROStep] = [] + @State private var addButtonDisbaled: Bool = true + + internal init(step: PRORepeatStep, enableStateRecalculation: (() -> ())? = nil, nextCompleteButtonStateRecalculation: (() -> ())? = nil) + { + self.step = step + self._answerSteps = State(wrappedValue: self.step.result.value ?? []) + + self.enableStateRecalculation = enableStateRecalculation + self.nextCompleteButtonStateRecalculation = nextCompleteButtonStateRecalculation + } + + internal var body: some View + { + if (step.compactPresentation) + { + CompactContent + } + else + { + ExtendedContent + } + } + + @ViewBuilder + private var CompactContent: some View + { + Section + { + Section + { + RepeatInputContent + } + + ForEach(0.. some View + { + Section + { + NavigationLink + { + VStack(alignment: .leading) + { + List + { + if (step.title != nil || step.text != nil) + { + PRODisplayStepView(step: getStepWithResetTitle(answerSteps[index])) + .listRowBackground(Color(.systemGray6)) + .listRowInsets(.init(top: 0, leading: 0, bottom: 0, trailing:0)) + } + + Section + { + answerSteps[index].createView() + } + .listSectionSpacing(getListSectionSpacing()) + } + .listSectionSpacing(.compact) + .padding(.top, getListPadding() - 3) + } + } + label: + { + Text(String(localized: "answer", bundle: Bundle.module) + " \(index+1)") + } + } + } + + private func deleteResult(at offsets: IndexSet) + { + answerSteps.remove(atOffsets: offsets) + step.result.value?.remove(atOffsets: offsets) + + addButtonDisbaled = step.isButtonDisabled() + + if let recalculate = nextCompleteButtonStateRecalculation + { + recalculate() + } + } + + private func getStepWithResetTitle(_ step: any PROStep) -> any PROStep + { + step.titleFont = PRODisplayStep.DEFAULT_TITLE_FONT + return step + } + + private func getPadding() -> CGFloat + { + if let _ = step.text + { + return 10 + } + + return 5 + } + + private func getListSectionSpacing() -> CGFloat + { + if let title = step.title + { + if let _ = step.text + { + return 0 + } + + let font = UIFont.boldSystemFont(ofSize: 24) + let size = title.widthWithConstrainedHeight(16, font: font) + 10 + + if (size > UIScreen.SCREEN_WIDTH) + { + return 10 + } + + return -5 + + } + + return 0 + } + + private func getListPadding() -> CGFloat + { + if let title = step.title, step.text == nil + { + let font = UIFont.boldSystemFont(ofSize: 24) + let size = title.widthWithConstrainedHeight(16, font: font) + 10 + + if (size < UIScreen.SCREEN_WIDTH) + { + return -42 + } + } + + return -35 + } +} + +#Preview +{ + PRORepeatStepView(step: PRORepeatStep.getPreviewObject4()) +} diff --git a/Sources/FhirQuestionnairesOnSwiftUI/steps/selection/PROSelectionInputStep.swift b/Sources/FhirQuestionnairesOnSwiftUI/steps/selection/PROSelectionInputStep.swift new file mode 100644 index 0000000..6a1ad45 --- /dev/null +++ b/Sources/FhirQuestionnairesOnSwiftUI/steps/selection/PROSelectionInputStep.swift @@ -0,0 +1,113 @@ +import SwiftUI + +public class PROSelectionInputStep: PROStepWithResult +{ + public var id: String + public var title: String? + public var text: String? + + public var options: [String] + + public var required: Bool + + public typealias ResultType = PROTextResult + public var result: ResultType + + public var titleFont: Font + + public var enabled: Bool + public var enableConditions: [any PROCondition] + + public init(id: String, title: String? = nil, text: String? = nil, options: [String], titleFont: Font = DEFAULT_TITLE_FONT, required: Bool = false, enabled: Bool = true, enableConditions: [any PROCondition] = []) + { + self.id = id + self.title = title + self.text = text + + self.options = options + + self.required = required + self.result = ResultType(id: id, value: nil) + + self.titleFont = titleFont + + self.enabled = enabled + self.enableConditions = enableConditions + } + + public func createView(enableStateRecalculation: (() -> ())? = nil, nextCompleteButtonStateRecalculation: (() -> ())? = nil) -> AnyView + { + return AnyView(PROSelectionInputStepView(step: self, enableStateRecalculation: enableStateRecalculation, nextCompleteButtonStateRecalculation: nextCompleteButtonStateRecalculation)) + } + + public func copy(with zone: NSZone? = nil) -> Any + { + let copy = PROSelectionInputStep(id: id, title: title, text: text, options: options, titleFont: titleFont, enableConditions: enableConditions) + copy.result = result.copy(with: zone) as! ResultType + return copy + } +} + +public extension PROSelectionInputStep +{ + private static func getTitle() -> String + { + return "Lorem Ipsum" + } + + private static func getShortTextText() -> String + { + return "Lorem ipsum dolor sit amet." + } + + private static func getLongTextText() -> String + { + return "Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet." + } + + private static func getOptions() -> [String] + { + return ["Red", "Green", "Blue", "Tartan"] + } + + static func getPreviewObject() -> PROSelectionInputStep + { + return PROSelectionInputStep(id: "selection.preview", title: getTitle(), text: getLongTextText(), options: getOptions(), enableConditions: [getPreviewObjectEnableAndCondition()]) + } + + static func getPreviewObjectWithTitle() -> PROSelectionInputStep + { + return PROSelectionInputStep(id: "selection.preview-with-title", title: getTitle(), options: getOptions()) + } + + static func getPreviewObjectWithShortText() -> PROSelectionInputStep + { + return PROSelectionInputStep(id: "selection.preview.preview-with-short-text", text: getShortTextText(), options: getOptions()) + } + + static func getPreviewObjectWithLongText() -> PROSelectionInputStep + { + return PROSelectionInputStep(id: "selection.preview.preview-with-long-text", text: getLongTextText(), options: getOptions()) + } + + static func getPreviewObjectCancelCondition() -> PROTextCondition + { + return PROTextCondition(stepId: "selection.preview", comperator: .equals, toCompare: "Green") + } + + static func getPreviewObjectEnableAndCondition() -> PROAndCondition + { + return PROAndCondition(getPreviewObjectEnableAnd1Condition(), getPreviewObjectEnableAnd2Condition()) + } + + private static func getPreviewObjectEnableAnd1Condition() -> PROTextCondition + { + return PROTextCondition(stepId: "text.preview", comperator: .equals, toCompare: "Enable") + } + + private static func getPreviewObjectEnableAnd2Condition() -> PRONumberCondition + { + return PRONumberCondition(stepId: "question.preview-int", comperator: .lessThan, toCompare: 5) + } +} + diff --git a/Sources/FhirQuestionnairesOnSwiftUI/steps/selection/PROSelectionInputStepView.swift b/Sources/FhirQuestionnairesOnSwiftUI/steps/selection/PROSelectionInputStepView.swift new file mode 100644 index 0000000..fe0d64d --- /dev/null +++ b/Sources/FhirQuestionnairesOnSwiftUI/steps/selection/PROSelectionInputStepView.swift @@ -0,0 +1,68 @@ +import SwiftUI + +internal struct PROSelectionInputStepView: View +{ + private let step: PROSelectionInputStep + private let enableStateRecalculation: (() -> ())? + private let nextCompleteButtonStateRecalculation: (() -> ())? + + @State private var answerValue: String + private var viewInitialized: Bool = false + + internal init(step: PROSelectionInputStep, enableStateRecalculation: (() -> ())? = nil, nextCompleteButtonStateRecalculation: (() -> ())? = nil) + { + self.step = step + self.enableStateRecalculation = enableStateRecalculation + self.nextCompleteButtonStateRecalculation = nextCompleteButtonStateRecalculation + + self._answerValue = State(wrappedValue: (self.step.result.value ?? step.options.first!)) + self.viewInitialized = true + } + + internal var body: some View + { + VStack(alignment: .leading) + { + PRODisplayStepView(step: step) + + HStack + { + Picker("", selection: $answerValue) + { + ForEach(step.options, id: \.self) + { option in + Text(option) + } + } + .pickerStyle(.menu) + .padding(.leading, -10) + .padding(.top, -8) + + Spacer() + } + } + .onChange(of: answerValue) + { + step.addResult(value: answerValue) + + // Do not change result value when init sets default view value + if(viewInitialized) + { + if let recalculate = enableStateRecalculation + { + recalculate() + } + + if let recalculate = nextCompleteButtonStateRecalculation + { + recalculate() + } + } + } + } +} + +#Preview +{ + PROSelectionInputStepView(step: PROSelectionInputStep.getPreviewObject()) +} diff --git a/Sources/FhirQuestionnairesOnSwiftUI/steps/text/PROTextInputStep.swift b/Sources/FhirQuestionnairesOnSwiftUI/steps/text/PROTextInputStep.swift new file mode 100644 index 0000000..e69c9e3 --- /dev/null +++ b/Sources/FhirQuestionnairesOnSwiftUI/steps/text/PROTextInputStep.swift @@ -0,0 +1,103 @@ +import SwiftUI + +public class PROTextInputStep: PROStepWithResult +{ + public static let DEFAULT_PLACEHOLDER = String(localized: "placeholder", bundle: Bundle.module) + public static let DEFAULT_MIN_LINE_LIMIT_STRING = 1 + public static let DEFAULT_MIN_LINE_LIMIT_TEXT = 2 + public static let DEFAULT_MAX_LINE_LIMIT = 5 + + public var id: String + public var title: String? + public var text: String? + + public var placeholder: String + + public let minLineLimit: Int + public let maxLineLimit: Int + + public let required: Bool + + public typealias ResultType = PROTextResult + public var result: ResultType + + public var titleFont: Font + + public var enabled: Bool + public var enableConditions: [any PROCondition] + + public init(id: String, title: String? = nil, text: String? = nil, placeholder: String = DEFAULT_PLACEHOLDER, minLineLimit: Int = DEFAULT_MIN_LINE_LIMIT_STRING, maxLineLimit: Int = DEFAULT_MAX_LINE_LIMIT, titleFont: Font = DEFAULT_TITLE_FONT, required: Bool = false, enabled: Bool = true, enableConditions: [any PROCondition] = []) + { + self.id = id + self.title = title + self.text = text + + self.placeholder = placeholder + + self.minLineLimit = minLineLimit + self.maxLineLimit = maxLineLimit + + self.required = required + self.result = ResultType(id: id, value: nil) + + self.titleFont = titleFont + + self.enabled = enabled + self.enableConditions = enableConditions + } + + public func createView(enableStateRecalculation: (() -> ())? = nil, nextCompleteButtonStateRecalculation: (() -> ())? = nil) -> AnyView + { + return AnyView(PROTextInputStepView(step: self, enableStateRecalculation: enableStateRecalculation, nextCompleteButtonStateRecalucation: nextCompleteButtonStateRecalculation)) + } + + public func copy(with zone: NSZone? = nil) -> Any + { + let copy = PROTextInputStep(id: id, title: title, text: text, placeholder: placeholder, minLineLimit: minLineLimit, maxLineLimit: maxLineLimit, titleFont: titleFont, enableConditions: enableConditions) + copy.result = result.copy(with: zone) as! PROTextResult + return copy + } +} + +public extension PROTextInputStep +{ + private static func getTitle() -> String + { + return "Lorem Ipsum" + } + + private static func getShortText() -> String + { + return "Lorem ipsum dolor sit amet." + } + + private static func getLongText() -> String + { + return "Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet." + } + + static func getPreviewObject() -> PROTextInputStep + { + return PROTextInputStep(id: "text.preview", title: getTitle(), text: getLongText()) + } + + static func getPreviewObjectWithTitle() -> PROTextInputStep + { + return PROTextInputStep(id: "text.preview-with-title", title: getTitle()) + } + + static func getPreviewObjectWithShortText() -> PROTextInputStep + { + return PROTextInputStep(id: "text.preview-with-short-text", text: getShortText()) + } + + static func getPreviewObjectWithLongText() -> PROTextInputStep + { + return PROTextInputStep(id: "text.preview-with-text", text: getLongText()) + } + + static func getPreviewCancelCondition() -> PROTextCondition + { + return PROTextCondition(stepId: "text.preview", comperator: .equals, toCompare: "Terminate") + } +} diff --git a/Sources/FhirQuestionnairesOnSwiftUI/steps/text/PROTextInputStepView.swift b/Sources/FhirQuestionnairesOnSwiftUI/steps/text/PROTextInputStepView.swift new file mode 100644 index 0000000..d95a51e --- /dev/null +++ b/Sources/FhirQuestionnairesOnSwiftUI/steps/text/PROTextInputStepView.swift @@ -0,0 +1,67 @@ +import SwiftUI + +internal struct PROTextInputStepView: View +{ + private let step: PROTextInputStep + private let enableStateRecalculation: (() -> ())? + private let nextCompleteButtonStateRecalculation: (() -> ())? + + @State private var answerValue: String + private var viewInitialized: Bool = false + + internal init(step: PROTextInputStep, enableStateRecalculation: (() -> ())? = nil, nextCompleteButtonStateRecalucation: (() -> ())? = nil) + { + self.step = step + self.enableStateRecalculation = enableStateRecalculation + self.nextCompleteButtonStateRecalculation = nextCompleteButtonStateRecalucation + + self._answerValue = State(wrappedValue: self.step.result.value ?? "") + self.viewInitialized = true + } + + internal var body: some View + { + VStack(alignment: .leading) + { + PRODisplayStepView(step: step) + + TextField(step.placeholder, text: $answerValue, axis: .vertical) + .foregroundColor(.blue) + .lineLimit(getLineLimit()) + .disableAutocorrection(true) + } + .onChange(of: answerValue) + { + step.addResult(value: answerValue) + + // Do not change result value when init sets default view value + if(viewInitialized) + { + if let recalculate = enableStateRecalculation + { + recalculate() + } + + if let recalculate = nextCompleteButtonStateRecalculation + { + recalculate() + } + } + } + .onTapGesture + { + // remove focus from textfield if tapped on other area + UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) + } + } + + private func getLineLimit() -> ClosedRange + { + return step.minLineLimit...step.maxLineLimit + } +} + +#Preview +{ + return PROTextInputStepView(step: PROTextInputStep.getPreviewObject()) +} diff --git a/Sources/FhirQuestionnairesOnSwiftUI/tasks/PROTask.swift b/Sources/FhirQuestionnairesOnSwiftUI/tasks/PROTask.swift new file mode 100644 index 0000000..0b3d285 --- /dev/null +++ b/Sources/FhirQuestionnairesOnSwiftUI/tasks/PROTask.swift @@ -0,0 +1,400 @@ +import Foundation +import SwiftUI +import ModelsR5 + +public class PROTask: ObservableObject, Identifiable, Hashable +{ + public let id: String + public let url: String + public let version: String? + public let title: String + + @Published public var completed: Bool + + private let steps: [any PROStep] + private let cancelConditions: [any PROCondition] + + private var index: Int + + public init(id: String, url: String, version: String? = nil, title: String, steps: [any PROStep], cancelConditions: [any PROCondition] = [], completed: Bool = false) + { + self.id = id + self.url = url + self.version = version + self.title = title + + self.completed = completed + + self.steps = steps + self.cancelConditions = cancelConditions + + self.index = 0 + } + + internal func currentStep() -> any PROStep + { + return steps[index] + } + + internal func isFirstStep() -> Bool + { + return index == 0 + } + + internal func isLastStep() -> Bool + { + return displayIndex() >= maxIndex() + } + + internal func currentIndex() -> Int + { + return index + } + + internal func displayIndex() -> Int + { + return currentIndex() + 1 + } + + internal func maxIndex() -> Int + { + return steps.count + } + + internal func isButtonDisabled() -> Bool + { + let step = currentStep() + + if let groupStep = step as? PROGroupStep + { + var groupDisabled = false + var sectionsDisabled = false + + let resultSteps = groupStep.sections.filter({ $0.enabled }).flatMap({ $0.expand(expandGroupStep: true).compactMap({ $0 as? (any PROStepWithResult) }) }) + + if (groupStep.required) + { + let hasResultsArray = resultSteps.map({ $0.hasResult() }) + groupDisabled = groupStep.enabled && hasResultsArray.allSatisfy({ $0 == false }) + } + + let requiredSections = resultSteps.filter({ $0.required && $0.enabled }) + if (requiredSections.isEmpty) + { + sectionsDisabled = false + } + else + { + sectionsDisabled = requiredSections.map({ $0.hasResult() }).contains(false) + } + + return (groupDisabled || sectionsDisabled) + } + + if let resultStep = step as? (any PROStepWithResult) + { + return resultStep.enabled && resultStep.required && !resultStep.hasResult() + } + + return false + } + + internal func calculateNextIndex() + { + repeat + { + index = index + 1 + } + while (!isEnabled(step: steps[index]) || index == maxIndex()) + } + + internal func calculatePreviousIndex() + { + repeat + { + index = index - 1 + } + while (index > 0 && !isEnabled(step: steps[index])) + } + + internal func resetIndex() + { + index = 0 + } + + private func isEnabled(step: any PROStep) -> Bool + { + if step.enableConditions.isEmpty + { + step.enabled = true + return true + } + + let enabled = step.enableConditions.compactMap({ $0.evaluate(steps: steps) }).filter({ $0 }).first ?? false + step.enabled = enabled + + return enabled + } + + internal func cancel() -> Bool + { + let stepUntilCurrentStep = Array(steps[0...currentIndex()]) + return cancelConditions.compactMap({ $0.evaluate(steps: stepUntilCurrentStep) }).filter({ $0 }).first ?? false + } + + internal func getSteps(expandGroupSteps: Bool = false, expandRepeatSteps: Bool = false) -> [any PROStep] + { + if (expandGroupSteps || expandRepeatSteps) + { + return steps.flatMap({ $0.expand(expandGroupStep: expandGroupSteps, expandRepeatStep: expandRepeatSteps) }) + } + + return steps + } + + public func getResults() -> [any PROResult] + { + return steps.flatMap({ $0.expand(expandGroupStep: true, expandRepeatStep: true) }).compactMap({ $0 as? (any PROStepWithResult) }).compactMap({ $0.result }) + } + + public func saveResults(for owner: String) + { + guard let questionnaireResponse = self.toQuestionnaireResponse()?.toJson() else + { + // TODO handle unparsable task + return + } + + let result = PROResultModelWrapper(owner: owner, task: id, results: questionnaireResponse, completed: completed) + PROPersistenceController.shared.save(result: result) + } + + public func loadResults(for owner: String) + { + guard let proResultModel = PROPersistenceController.shared.load(for: owner, and: id) else + { + // TODO no response found + return + } + + guard let json = proResultModel.results, let questionnaireResponse = QuestionnaireResponse.from(json: json) else + { + // TODO empty result + return + } + + parseStepResults(from: questionnaireResponse) + } + + public func deleteResults(for owner: String) + { + PROPersistenceController.shared.delete(for: owner, and: id) + } + + public static func == (lhs: PROTask, rhs: PROTask) -> Bool + { + return lhs.id == rhs.id + } + + public func hash(into hasher: inout Hasher) + { + return hasher.combine(id) + } +} + +public extension PROTask +{ + static func getPreviewObject() -> PROTask + { + return PROTask( + id: "task-preview", + url: "http://retwet.eu/fhir/Questionnaire/task-preview", + version: "1.0", + title: "Preview Task", + steps: [ + PRODisplayStep.getPreviewObject2(), + PROTextInputStep.getPreviewObject(), + PRONumberInputStep.getPreviewObject(), + PROSelectionInputStep.getPreviewObject(), + PRODateTimeInputStep.getPreviewObject(), + PRODateInputStep.getPreviewObject(), + PROTimeInputStep.getPreviewObject(), + PROBooleanInputStep.getPreviewObject(), + PRORepeatStep.getPreviewObject3(), + PROGroupStep.getPreviewObject(), + PROGroupStep.getPreviewObject2(), + PROTextInputStep.getPreviewObject() + ], + cancelConditions: [ + PROTextInputStep.getPreviewCancelCondition(), + //PRONumberInputStep.getPreviewObjectCancelCondition(), + PRODateInputStep.getPreviewObjectCancelCondition(), + PROBooleanInputStep.getPreviewObjectCancelCondition(), + PROSelectionInputStep.getPreviewObjectCancelCondition() + ] + ) + } + + static func getPreviewObject2() -> PROTask + { + let questionnaire = """ + { + "resourceType": "Questionnaire", + "id": "overview", + "meta": { + "profile": [ + "http://retwet.eu/fhir/StructureDefinition/questionnaire|1.0.0" + ] + }, + "url": "http://retwet.eu/fhir/Questionnaire/task-preview", + "version": "1.1", + "title": "Übersicht", + "status": "draft", + "date": "2024-01-01", + "extension": [ + { + "url": "http://retwet.eu/fhir/StructureDefinition/questionnaire/extension-cancel-group", + "extension": [ + { + "url": "http://retwet.eu/fhir/StructureDefinition/questionnaire/extension-cancel-group/cancel-when", + "extension": [ + { + "url": "http://retwet.eu/fhir/StructureDefinition/questionnaire/extension-cancel-group/cancel-when/question", + "valueString": "preview-4" + }, + { + "url": "http://retwet.eu/fhir/StructureDefinition/questionnaire/extension-cancel-group/cancel-when/operator", + "valueString": ">=" + }, + { + "url": "http://retwet.eu/fhir/StructureDefinition/questionnaire/extension-cancel-group/cancel-when/answer", + "valueInteger": 18 + } + ] + } + ] + } + ], + "item": [ + { + "linkId": "preview-1", + "extension": [ + { + "url": "http://retwet.eu/fhir/StructureDefinition/questionnaire/item/extension-title", + "valueString": "Welcome" + } + ], + "text": "Do you have allergies?", + "type": "boolean", + "required": true + }, + { + "linkId": "preview-2", + "extension": [ + { + "url": "http://retwet.eu/fhir/StructureDefinition/questionnaire/item/extension-title", + "valueString": "General Questions" + } + ], + "text": "Here are some general questions for you to answer in the beginning.", + "repeats": true, + "type": "group", + "required": false, + "item": [ + { + "linkId": "2.1", + "text": "What is your gender?", + "type": "string" + }, + { + "linkId": "2.2", + "text": "What is your date of birth?", + "type": "date" + }, + { + "linkId": "2.3", + "text": "What is your country of birth?", + "type": "string" + }, + { + "linkId": "2.4", + "text": "What is your marital status?", + "type": "string" + } + ], + "enableWhen": [ + { + "question": "preview-1", + "operator": "=", + "answerBoolean": true + } + ] + }, + { + "linkId": "preview-3", + "extension": [ + { + "url": "http://retwet.eu/fhir/StructureDefinition/questionnaire/item/extension-title", + "valueString": "Intoxications" + } + ], + "type": "group", + "item": [ + { + "linkId": "3.1", + "text": "Do you smoke?", + "type": "boolean" + }, + { + "linkId": "3.2", + "text": "Do you drink alchohol?", + "type": "boolean" + } + ] + }, + { + "linkId": "preview-4", + "text": "How old are you?", + "type": "integer", + "extension": [ + { + "url": "http://retwet.eu/fhir/StructureDefinition/questionnaire/item/extension-integer-decimal-value-min", + "valueInteger": 0 + }, + { + "url": "http://retwet.eu/fhir/StructureDefinition/questionnaire/item/extension-integer-decimal-value-max", + "valueInteger": 120 + }, + { + "url": "http://retwet.eu/fhir/StructureDefinition/questionnaire/item/extension-integer-decimal-value-step-size", + "valueInteger": 1 + } + ] + }, + { + "linkId": "preview-5", + "text": "How tall are you in meters?", + "type": "decimal", + "extension": [ + { + "url": "http://retwet.eu/fhir/StructureDefinition/questionnaire/item/extension-integer-decimal-value-min", + "valueDecimal": 0.8 + }, + { + "url": "http://retwet.eu/fhir/StructureDefinition/questionnaire/item/extension-integer-decimal-value-max", + "valueDecimal": 2.5 + }, + { + "url": "http://retwet.eu/fhir/StructureDefinition/questionnaire/item/extension-integer-decimal-value-step-size", + "valueDecimal": 0.01 + } + ] + } + ] + } + """ + + return Questionnaire.from(json: questionnaire)!.toTask() + } +} + diff --git a/Sources/FhirQuestionnairesOnSwiftUI/tasks/PROTaskView.swift b/Sources/FhirQuestionnairesOnSwiftUI/tasks/PROTaskView.swift new file mode 100644 index 0000000..93a0830 --- /dev/null +++ b/Sources/FhirQuestionnairesOnSwiftUI/tasks/PROTaskView.swift @@ -0,0 +1,175 @@ +import SwiftUI + +public struct PROTaskView: View +{ + @Binding private var task: PROTask + @Binding private var isPresented: Bool + + @State private var navigationPath = NavigationPath() + + private let completeWith: (PROTask) -> () + private let cancelWith: (PROTask) -> () + + public init(for task: PROTask, isPresented: Binding, completeWith: @escaping (PROTask) -> () = { _ in return }, cancelWith: @escaping (PROTask) -> () = { _ in return }) + { + self.init(for: .constant(task), isPresented: isPresented, completeWith: completeWith, cancelWith: cancelWith) + } + + public init(for task: Binding, isPresented: Binding, completeWith: @escaping (PROTask) -> () = { _ in return }, cancelWith: @escaping (PROTask) -> () = { _ in return }) + { + self._task = task + self._isPresented = isPresented + self.completeWith = completeWith + self.cancelWith = cancelWith + } + + public var body: some View + { + NavigationStack(path: $navigationPath) + { + _PROTaskView(for: task, isPresented: $isPresented, completeWith: completeWith, cancelWith: cancelWith) + } + } +} + +fileprivate struct _PROTaskView: View +{ + @Environment(\.dismiss) private var dismiss + + private let task: PROTask + @Binding private var isPresented: Bool + @State private var showNextStep: Bool = false + + @State private var buttonDisabled: Bool = true + + private let completeWith: (PROTask) -> () + private let cancelWith: (PROTask) -> () + + fileprivate init(for task: PROTask, isPresented: Binding, completeWith: @escaping (PROTask) -> (), cancelWith: @escaping (PROTask) -> ()) + { + self.task = task + self._isPresented = isPresented + + self.completeWith = completeWith + self.cancelWith = cancelWith + } + + fileprivate var body: some View + { + VStack(alignment: .leading) + { + task.currentStep().createView(nextCompleteButtonStateRecalculation: { + buttonDisabled = task.isButtonDisabled() + }) + + Spacer() + + if(!task.isLastStep()) + { + Button + { + if (!task.cancel()) + { + task.calculateNextIndex() + showNextStep = true + } + else + { + cancelWith(task) + dismissTask() + } + } + label: + { + Text(String(localized: "next", bundle: Bundle.module)) + .frame(maxWidth: .infinity) + .padding(.vertical, 7) + } + .buttonStyle(.borderedProminent) + .padding(.bottom) + .disabled(buttonDisabled) + } + else + { + Button + { + task.completed = true + completeWith(task) + + dismissTask() + } + label: + { + Text("complete", bundle: .module) + .frame(maxWidth: .infinity) + .padding(.vertical, 7) + + } + .buttonStyle(.borderedProminent) + .padding(.bottom) + .disabled(buttonDisabled) + } + } + .padding(.horizontal) + .background(getBackground(for: task.currentStep())) + .navigationBarTitleDisplayMode(.inline) + .navigationBarBackButtonHidden(true) + .toolbar + { + if(!task.isFirstStep()) + { + ToolbarItem(placement: .topBarLeading) + { + Button + { + task.calculatePreviousIndex() + dismiss() + } + label: + { + HStack + { + Image(systemName: "chevron.left") + Text(String(localized: "back", bundle: Bundle.module)) + } + } + } + } + + ToolbarItem(placement: .principal) + { + Text("\(String(localized: "step", bundle: Bundle.module)) \(task.displayIndex())/\(task.maxIndex())") + .foregroundColor(Color(.systemGray)) + } + + ToolbarItem(placement: .topBarTrailing) + { + Button(String(localized: "cancel", bundle: Bundle.module)) + { + dismissTask() + } + } + } + .toolbarBackground(getBackground(for: task.currentStep()), for: .bottomBar) + .toolbarBackground(getBackground(for: task.currentStep()), for: .navigationBar) + .navigationDestination(isPresented: $showNextStep) + { + _PROTaskView(for: task, isPresented: $isPresented, completeWith: completeWith, cancelWith: cancelWith) + } + .onAppear() + { + buttonDisabled = task.isButtonDisabled() + } + } + + private func dismissTask() + { + task.resetIndex() + isPresented.toggle() + } +} + +#Preview +{ + PROTaskView(for: .constant(PROTask.getPreviewObject2()), isPresented: .constant(true)) +} diff --git a/Sources/FhirQuestionnairesOnSwiftUI/util/BoolExtensions.swift b/Sources/FhirQuestionnairesOnSwiftUI/util/BoolExtensions.swift new file mode 100644 index 0000000..89e7141 --- /dev/null +++ b/Sources/FhirQuestionnairesOnSwiftUI/util/BoolExtensions.swift @@ -0,0 +1,29 @@ +import Foundation + +public extension Bool +{ + static var YES: String + { + get { return String(localized: "yes", bundle: Bundle.module) } + } + + static var NO: String + { + get { return String(localized: "no", bundle: Bundle.module) } + } + + var asString: String + { + get + { + if (self == true) + { + return Bool.YES + } + else + { + return Bool.NO + } + } + } +} diff --git a/Sources/FhirQuestionnairesOnSwiftUI/util/DateExtensions.swift b/Sources/FhirQuestionnairesOnSwiftUI/util/DateExtensions.swift new file mode 100644 index 0000000..5cfe760 --- /dev/null +++ b/Sources/FhirQuestionnairesOnSwiftUI/util/DateExtensions.swift @@ -0,0 +1,79 @@ +import Foundation + +public extension Date +{ + private static func DEFAULT_FORMATTER() -> DateFormatter + { + let formatter = DateFormatter() + formatter.timeZone = TimeZone(abbreviation: "UTC") + + return formatter + } + + private static func DATE_TIME_FORMATTER() -> DateFormatter + { + let formatter = DEFAULT_FORMATTER() + formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSXXX" + + return formatter + } + + private static func TIME_FORMATTER() -> DateFormatter + { + let formatter = DEFAULT_FORMATTER() + formatter.dateFormat = "HH:mm:ss.SSSXXX" + + return formatter + } + + private static func DATE_FORMATTER() -> DateFormatter + { + let formatter = DEFAULT_FORMATTER() + formatter.dateFormat = "yyyy-MM-dd" + + return formatter + } + + private static func DISPLAY_DATE_FORMATTER() -> DateFormatter + { + let formatter = DEFAULT_FORMATTER() + formatter.dateFormat = "dd.MM.yyyy" + + return formatter + } + + func toIsoDateTimeString() -> String + { + return Self.DATE_TIME_FORMATTER().string(from: self) + } + + static func fromIsoDateTimeString(_ string: String) -> Date? + { + return Self.DATE_TIME_FORMATTER().date(from: string) + } + + func toIsoDateString() -> String + { + return Self.DATE_FORMATTER().string(from: self) + } + + func toDisplayDateString() -> String + { + return Self.DISPLAY_DATE_FORMATTER().string(from: self) + } + + static func fromIsoDateString(_ string: String) -> Date? + { + return DATE_FORMATTER().date(from: string) + } + + func toIsoTimeString() -> String + { + return Self.TIME_FORMATTER().string(from: self) + } + + static func fromIsoTimeString(_ string: String) -> Date? + { + return Self.TIME_FORMATTER().date(from: string) + } +} diff --git a/Sources/FhirQuestionnairesOnSwiftUI/util/FontExtension.swift b/Sources/FhirQuestionnairesOnSwiftUI/util/FontExtension.swift new file mode 100644 index 0000000..3605150 --- /dev/null +++ b/Sources/FhirQuestionnairesOnSwiftUI/util/FontExtension.swift @@ -0,0 +1,8 @@ +// +// FontExtension.swift +// dips +// +// Created by Reto Wettstein on 21.11.23. +// + +import Foundation diff --git a/Sources/FhirQuestionnairesOnSwiftUI/util/StringExtensions.swift b/Sources/FhirQuestionnairesOnSwiftUI/util/StringExtensions.swift new file mode 100644 index 0000000..6bacf06 --- /dev/null +++ b/Sources/FhirQuestionnairesOnSwiftUI/util/StringExtensions.swift @@ -0,0 +1,14 @@ +import Foundation +import SwiftUI + +public extension String +{ + func widthWithConstrainedHeight(_ height: CGFloat, font: UIFont) -> CGFloat + { + let constraintRect = CGSize(width: CGFloat.greatestFiniteMagnitude, height: height) + + let boundingBox = self.boundingRect(with: constraintRect, options: .usesLineFragmentOrigin, attributes: [NSAttributedString.Key.font: font], context: nil) + + return boundingBox.width + } +} diff --git a/Sources/FhirQuestionnairesOnSwiftUI/util/UIScreen.swift b/Sources/FhirQuestionnairesOnSwiftUI/util/UIScreen.swift new file mode 100644 index 0000000..282d98d --- /dev/null +++ b/Sources/FhirQuestionnairesOnSwiftUI/util/UIScreen.swift @@ -0,0 +1,6 @@ +import SwiftUI + +public extension UIScreen +{ + static let SCREEN_WIDTH = UIScreen.main.bounds.size.width +} diff --git a/Sources/FhirQuestionnairesOnSwiftUI/util/ViewExtensions.swift b/Sources/FhirQuestionnairesOnSwiftUI/util/ViewExtensions.swift new file mode 100644 index 0000000..f2109a9 --- /dev/null +++ b/Sources/FhirQuestionnairesOnSwiftUI/util/ViewExtensions.swift @@ -0,0 +1,22 @@ +import Foundation +import SwiftUI + +public extension View +{ + func getBackground(for colorScheme: ColorScheme) -> Color + { + if (colorScheme == .dark) + { + return Color(UIColor.systemBackground) + } + else + { + return Color(.systemGray6) + } + } + + func getBackground(for step: any PROStep) -> Color + { + return Color(UIColor.systemGray6) + } +} diff --git a/Tests/FhirQuestionnairesOnSwiftUITests/FhirQuestionnairesOnSwiftUITests.swift b/Tests/FhirQuestionnairesOnSwiftUITests/FhirQuestionnairesOnSwiftUITests.swift new file mode 100644 index 0000000..bfb383c --- /dev/null +++ b/Tests/FhirQuestionnairesOnSwiftUITests/FhirQuestionnairesOnSwiftUITests.swift @@ -0,0 +1,12 @@ +import XCTest +@testable import FhirQuestionnairesOnSwiftUI + +final class FhirQuestionnairesOnSwiftUITests: XCTestCase { + func testExample() throws { + // XCTest Documentation + // https://developer.apple.com/documentation/xctest + + // Defining Test Cases and Test Methods + // https://developer.apple.com/documentation/xctest/defining_test_cases_and_test_methods + } +}