From 0dcd044b2581bc1cf3b1e532b53ee611a73299c5 Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Wed, 23 Oct 2024 16:09:18 +0200 Subject: [PATCH 01/24] Allow to use SignatureView manually --- Sources/SpeziOnboarding/SignatureView.swift | 6 +++--- Sources/SpeziOnboarding/SignatureViewBackground.swift | 2 ++ 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/Sources/SpeziOnboarding/SignatureView.swift b/Sources/SpeziOnboarding/SignatureView.swift index c98f99a..260bc88 100644 --- a/Sources/SpeziOnboarding/SignatureView.swift +++ b/Sources/SpeziOnboarding/SignatureView.swift @@ -35,7 +35,7 @@ public struct SignatureView: View { #else @Binding private var signature: String #endif - private let name: PersonNameComponents + private let name: PersonNameComponents // TODO: allow to just specify a string! additional initializer for name components! private let lineOffset: CGFloat @@ -107,7 +107,7 @@ public struct SignatureView: View { /// - canvasSize: The size of the canvas as a Binding. /// - name: The name that is displayed under the signature line. /// - lineOffset: Defines the distance of the signature line from the bottom of the view. The default value is 30. - init( + public init( signature: Binding = .constant(PKDrawing()), isSigning: Binding = .constant(false), canvasSize: Binding = .constant(.zero), @@ -126,7 +126,7 @@ public struct SignatureView: View { /// - signature: A `Binding` containing the current text-based signature as a `String`. /// - name: The name that is displayed under the signature line. /// - lineOffset: Defines the distance of the signature line from the bottom of the view. The default value is 30. - init( + public init( signature: Binding = .constant(String()), name: PersonNameComponents = PersonNameComponents(), lineOffset: CGFloat = 30 diff --git a/Sources/SpeziOnboarding/SignatureViewBackground.swift b/Sources/SpeziOnboarding/SignatureViewBackground.swift index 79d072a..28cb9c7 100644 --- a/Sources/SpeziOnboarding/SignatureViewBackground.swift +++ b/Sources/SpeziOnboarding/SignatureViewBackground.swift @@ -13,6 +13,8 @@ import SwiftUI struct SignatureViewBackground: View { private let name: PersonNameComponents private let lineOffset: CGFloat + // TODO: add ability to show date! + #if !os(macOS) private let backgroundColor: UIColor #else From 2d2fd61c30fe90862c71eaac379c76f92c078b20 Mon Sep 17 00:00:00 2001 From: Philipp Zagar Date: Wed, 22 Jan 2025 09:36:12 +0100 Subject: [PATCH 02/24] String-based name components --- Sources/SpeziOnboarding/SignatureView.swift | 44 ++++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) diff --git a/Sources/SpeziOnboarding/SignatureView.swift b/Sources/SpeziOnboarding/SignatureView.swift index 462fdc5..00fc067 100644 --- a/Sources/SpeziOnboarding/SignatureView.swift +++ b/Sources/SpeziOnboarding/SignatureView.swift @@ -36,7 +36,7 @@ public struct SignatureView: View { #else @Binding private var signature: String #endif - private let name: PersonNameComponents // TODO: allow to just specify a string! additional initializer for name components! + private let name: PersonNameComponents private let lineOffset: CGFloat @@ -123,6 +123,29 @@ public struct SignatureView: View { self.name = name self.lineOffset = lineOffset } + + /// Creates a new instance of an ``SignatureView`` with `String`-based name components. + /// - Parameters: + /// - signature: A `Binding` containing the current signature as an `PKDrawing`. + /// - isSigning: A `Binding` indicating if the user is currently signing. + /// - canvasSize: The size of the canvas as a Binding. + /// - givenName: The given name that is displayed under the signature line. + /// - familyName: The family name that is displayed under the signature line. + /// - lineOffset: Defines the distance of the signature line from the bottom of the view. The default value is 30. + public init( + signature: Binding = .constant(PKDrawing()), + isSigning: Binding = .constant(false), + canvasSize: Binding = .constant(.zero), + givenName: String, + familyName: String, + lineOffset: CGFloat = 30 + ) { + self._signature = signature + self._isSigning = isSigning + self._canvasSize = canvasSize + self.name = .init(givenName: givenName, familyName: familyName) + self.lineOffset = lineOffset + } #else /// Creates a new instance of an ``SignatureView``. /// - Parameters: @@ -138,6 +161,23 @@ public struct SignatureView: View { self.name = name self.lineOffset = lineOffset } + + /// Creates a new instance of an ``SignatureView`` with `String`-based name components. + /// - Parameters: + /// - signature: A `Binding` containing the current text-based signature as a `String`. + /// - givenName: The given name that is displayed under the signature line. + /// - familyName: The family name that is displayed under the signature line. + /// - lineOffset: Defines the distance of the signature line from the bottom of the view. The default value is 30. + public init( + signature: Binding = .constant(String()), + givenName: String, + familyName: String, + lineOffset: CGFloat = 30 + ) { + self._signature = signature + self.name = .init(givenName: givenName, familyName: familyName) + self.lineOffset = lineOffset + } #endif } @@ -148,6 +188,8 @@ struct SignatureView_Previews: PreviewProvider { SignatureView() SignatureView(name: PersonNameComponents(givenName: "Leland", familyName: "Stanford")) + + SignatureView(givenName: "Leland", familyName: "Stanford") } } #endif From 57ce8bcfa06f1b323e49b15b0be30b04b33b2d9c Mon Sep 17 00:00:00 2001 From: Philipp Zagar Date: Wed, 22 Jan 2025 10:01:35 +0100 Subject: [PATCH 03/24] Signature date --- Sources/SpeziOnboarding/SignatureView.swift | 98 +++++++++++++++---- .../SignatureViewBackground.swift | 67 +++++++++++-- 2 files changed, 138 insertions(+), 27 deletions(-) diff --git a/Sources/SpeziOnboarding/SignatureView.swift b/Sources/SpeziOnboarding/SignatureView.swift index 00fc067..ce446e8 100644 --- a/Sources/SpeziOnboarding/SignatureView.swift +++ b/Sources/SpeziOnboarding/SignatureView.swift @@ -37,14 +37,20 @@ public struct SignatureView: View { @Binding private var signature: String #endif private let name: PersonNameComponents + private let date: Date? + private let dateFormatter: DateFormatter private let lineOffset: CGFloat public var body: some View { VStack { ZStack(alignment: .bottomLeading) { - SignatureViewBackground(name: name, lineOffset: lineOffset) - + SignatureViewBackground( + name: name, + formattedDate: { if let date { dateFormatter.string(from: date) } else { nil } }(), + lineOffset: lineOffset + ) + #if !os(macOS) CanvasView(drawing: $signature, isDrawing: $isSigning, showToolPicker: .constant(false)) .accessibilityLabel(Text("SIGNATURE_FIELD", bundle: .module)) @@ -115,12 +121,20 @@ public struct SignatureView: View { isSigning: Binding = .constant(false), canvasSize: Binding = .constant(.zero), name: PersonNameComponents = PersonNameComponents(), + date: Date? = nil, + dateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateStyle = .short + return formatter + }(), lineOffset: CGFloat = 30 ) { self._signature = signature self._isSigning = isSigning self._canvasSize = canvasSize self.name = name + self.date = date + self.dateFormatter = dateFormatter self.lineOffset = lineOffset } @@ -138,13 +152,23 @@ public struct SignatureView: View { canvasSize: Binding = .constant(.zero), givenName: String, familyName: String, + date: Date? = nil, + dateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateStyle = .short + return formatter + }(), lineOffset: CGFloat = 30 ) { - self._signature = signature - self._isSigning = isSigning - self._canvasSize = canvasSize - self.name = .init(givenName: givenName, familyName: familyName) - self.lineOffset = lineOffset + self.init( + signature: signature, + isSigning: isSigning, + canvasSize: canvasSize, + name: .init(givenName: givenName, familyName: familyName), + date: date, + dateFormatter: dateFormatter, + lineOffset: lineOffset + ) } #else /// Creates a new instance of an ``SignatureView``. @@ -155,10 +179,18 @@ public struct SignatureView: View { public init( signature: Binding = .constant(String()), name: PersonNameComponents = PersonNameComponents(), + date: Date? = nil, + dateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateStyle = .short + return formatter + }(), lineOffset: CGFloat = 30 ) { self._signature = signature - self.name = name + self.name = names + self.date = date + self.dateFormatter = dateFormatter self.lineOffset = lineOffset } @@ -172,24 +204,54 @@ public struct SignatureView: View { signature: Binding = .constant(String()), givenName: String, familyName: String, + date: Date? = nil, + dateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateStyle = .short + return formatter + }(), lineOffset: CGFloat = 30 ) { - self._signature = signature - self.name = .init(givenName: givenName, familyName: familyName) - self.lineOffset = lineOffset + self.init( + signature: signature, + name: .init(givenName: givenName, familyName: familyName), + date: date, + dateFormatter: dateFormatter, + lineOffset: lineOffset + ) } #endif } - #if DEBUG -struct SignatureView_Previews: PreviewProvider { - static var previews: some View { - SignatureView() +#Preview("Base Signature View") { + SignatureView() +} - SignatureView(name: PersonNameComponents(givenName: "Leland", familyName: "Stanford")) +#Preview("Including PersonNameComponents") { + SignatureView(name: PersonNameComponents(givenName: "Leland", familyName: "Stanford")) +} - SignatureView(givenName: "Leland", familyName: "Stanford") - } +#Preview("Including String-based names") { + SignatureView(givenName: "Leland", familyName: "Stanford") +} + +#Preview("Including PersonNameComponents and Date") { + SignatureView( + name: PersonNameComponents(givenName: "Leland", familyName: "Stanford"), + date: .now + ) +} + +#Preview("Including PersonNameComponents and Date with custom format") { + SignatureView( + name: PersonNameComponents(givenName: "Leland", familyName: "Stanford"), + date: .now, + dateFormatter: { + let formatter = DateFormatter() + formatter.dateStyle = .medium + return formatter + }() + ) } #endif diff --git a/Sources/SpeziOnboarding/SignatureViewBackground.swift b/Sources/SpeziOnboarding/SignatureViewBackground.swift index 28cb9c7..02e817f 100644 --- a/Sources/SpeziOnboarding/SignatureViewBackground.swift +++ b/Sources/SpeziOnboarding/SignatureViewBackground.swift @@ -12,8 +12,8 @@ import SwiftUI /// The `SignatureViewBackground` provides the background view for the ``SignatureView`` including the name and the signature line. struct SignatureViewBackground: View { private let name: PersonNameComponents + private let formattedDate: String? private let lineOffset: CGFloat - // TODO: add ability to show date! #if !os(macOS) private let backgroundColor: UIColor @@ -40,41 +40,90 @@ struct SignatureViewBackground: View { .padding(.bottom, lineOffset + 2) .accessibilityHidden(true) - let name = name.formatted(.name(style: .long)) - Text(name) - .font(.subheadline) - .foregroundColor(.secondary) - .padding(.horizontal, 20) - .padding(.bottom, lineOffset - 18) - .accessibilityLabel(Text("SIGNATURE_NAME \(name)", bundle: .module)) - .accessibilityHidden(name.isEmpty) + HStack { + let name = name.formatted(.name(style: .long)) + Text(name) + .font(.subheadline) + .foregroundColor(.secondary) + .padding(.horizontal, 20) + .padding(.bottom, lineOffset - 18) + .accessibilityLabel(Text("SIGNATURE_NAME \(name)", bundle: .module)) + .accessibilityHidden(name.isEmpty) + + Spacer() + + if let formattedDate { + Text(formattedDate) + .font(.subheadline) + .foregroundColor(.secondary) + .padding(.horizontal, 20) + .padding(.bottom, lineOffset - 18) + .accessibilityLabel(Text("SIGNATURE_DATE \(formattedDate)", bundle: .module)) + .accessibilityHidden(formattedDate.isEmpty) + } + } } /// Creates a new instance of an `SignatureViewBackground`. /// - Parameters: /// - name: The name that is displayed under the signature line. + /// - formattedDate: The formatted date that is displayed under the signature line. /// - lineOffset: Defines the distance of the signature line from the bottom of the view. The default value is 30. /// - backgroundColor: The color of the background of the signature canvas. #if !os(macOS) init( name: PersonNameComponents = PersonNameComponents(), + formattedDate: String? = nil, lineOffset: CGFloat = 30, backgroundColor: UIColor = .secondarySystemBackground ) { self.name = name + self.formattedDate = formattedDate self.lineOffset = lineOffset self.backgroundColor = backgroundColor } #else init( name: PersonNameComponents = PersonNameComponents(), + formattedDate: String? = nil, lineOffset: CGFloat = 30, backgroundColor: NSColor = .secondarySystemFill ) { self.name = name + self.formattedDate = formattedDate self.lineOffset = lineOffset self.backgroundColor = backgroundColor } #endif } + + +#if DEBUG +#Preview("No signature date") { + ZStack(alignment: .bottomLeading) { + SignatureViewBackground( + name: .init(givenName: "Leland", familyName: "Stanford"), + formattedDate: nil + ) + } + .frame(height: 120) +} + +#Preview("Including signature date") { + let dateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateStyle = .short + return formatter + }() + + + ZStack(alignment: .bottomLeading) { + SignatureViewBackground( + name: .init(givenName: "Leland", familyName: "Stanford"), + formattedDate: dateFormatter.string(from: .now) + ) + } + .frame(height: 120) +} +#endif From 73f34f42342d6613fceffeb089ed064d0e933c2c Mon Sep 17 00:00:00 2001 From: Philipp Zagar Date: Wed, 22 Jan 2025 10:29:25 +0100 Subject: [PATCH 04/24] Refactorings to Preview --- .../ConsentView/ConsentDocument.swift | 28 +++++++-------- .../OnboardingConsentView.swift | 22 ++++++------ .../IllegalOnboardingStepView.swift | 6 ++-- .../OnboardingInformationView.swift | 35 ++++--------------- Sources/SpeziOnboarding/OnboardingView.swift | 22 +++++++++++- .../Resources/Localizable.xcstrings | 3 ++ Sources/SpeziOnboarding/SignatureView.swift | 9 ++--- .../UITests/TestApp/OnboardingTestsView.swift | 6 ++-- .../Views/HelperViews/CustomToggleView.swift | 12 +++---- .../Views/OnboardingConditionalTestView.swift | 10 +++--- ...boardingConsentMarkdownRenderingView.swift | 17 +++++---- .../OnboardingConsentMarkdownTestView.swift | 10 +++--- .../Views/OnboardingCustomTestView1.swift | 10 +++--- .../Views/OnboardingCustomTestView2.swift | 10 +++--- ...OnboardingIdentifiableTestViewCustom.swift | 10 +++--- .../Views/OnboardingSequentialTestView.swift | 10 +++--- .../Views/OnboardingStartTestView.swift | 10 +++--- .../OnboardingTestViewNotIdentifiable.swift | 10 +++--- .../Views/OnboardingWelcomeTestView.swift | 10 +++--- 19 files changed, 111 insertions(+), 139 deletions(-) diff --git a/Sources/SpeziOnboarding/ConsentView/ConsentDocument.swift b/Sources/SpeziOnboarding/ConsentView/ConsentDocument.swift index 2148080..490733e 100644 --- a/Sources/SpeziOnboarding/ConsentView/ConsentDocument.swift +++ b/Sources/SpeziOnboarding/ConsentView/ConsentDocument.swift @@ -232,21 +232,19 @@ public struct ConsentDocument: View { #if DEBUG -struct ConsentDocument_Previews: PreviewProvider { - @State private static var viewState: ConsentViewState = .base(.idle) - - - static var previews: some View { - NavigationStack { - ConsentDocument( - markdown: { - Data("This is a *markdown* **example**".utf8) - }, - viewState: $viewState - ) - .navigationTitle(Text(verbatim: "Consent")) - .padding() - } +#Preview { + @Previewable @State var viewState: ConsentViewState = .base(.idle) + + + NavigationStack { + ConsentDocument( + markdown: { + Data("This is a *markdown* **example**".utf8) + }, + viewState: $viewState + ) + .navigationTitle(Text(verbatim: "Consent")) + .padding() } } #endif diff --git a/Sources/SpeziOnboarding/OnboardingConsentView.swift b/Sources/SpeziOnboarding/OnboardingConsentView.swift index e94c090..f161f0a 100644 --- a/Sources/SpeziOnboarding/OnboardingConsentView.swift +++ b/Sources/SpeziOnboarding/OnboardingConsentView.swift @@ -231,18 +231,16 @@ public struct OnboardingConsentView: View { #if DEBUG -struct OnboardingConsentView_Previews: PreviewProvider { - @State private static var viewState: ConsentViewState = .base(.idle) - - - static var previews: some View { - NavigationStack { - OnboardingConsentView(markdown: { - Data("This is a *markdown* **example**".utf8) - }, action: { - print("Next") - }) - } +#Preview { + @Previewable @State var viewState: ConsentViewState = .base(.idle) + + + NavigationStack { + OnboardingConsentView(markdown: { + Data("This is a *markdown* **example**".utf8) + }, action: { + print("Next") + }) } } #endif diff --git a/Sources/SpeziOnboarding/OnboardingFlow/IllegalOnboardingStepView.swift b/Sources/SpeziOnboarding/OnboardingFlow/IllegalOnboardingStepView.swift index 95cd615..b9823dc 100644 --- a/Sources/SpeziOnboarding/OnboardingFlow/IllegalOnboardingStepView.swift +++ b/Sources/SpeziOnboarding/OnboardingFlow/IllegalOnboardingStepView.swift @@ -20,9 +20,7 @@ struct IllegalOnboardingStepView: View { #if DEBUG -struct IllegalOnboardingStepView_Previews: PreviewProvider { - static var previews: some View { - IllegalOnboardingStepView() - } +#Preview { + IllegalOnboardingStepView() } #endif diff --git a/Sources/SpeziOnboarding/OnboardingInformationView.swift b/Sources/SpeziOnboarding/OnboardingInformationView.swift index 5dadd34..de36626 100644 --- a/Sources/SpeziOnboarding/OnboardingInformationView.swift +++ b/Sources/SpeziOnboarding/OnboardingInformationView.swift @@ -147,43 +147,20 @@ public struct OnboardingInformationView: View { #if DEBUG -struct AreasView_Previews: PreviewProvider { - static var mock: [OnboardingInformationView.Content] { - [ +#Preview { + OnboardingInformationView( + areas: [ OnboardingInformationView.Content( icon: Image(systemName: "pc"), title: String("PC"), - description: String("This is a PC. And we can write a lot about PCs in a section like this. A very long text!") + description: String("This is a PC.") ), OnboardingInformationView.Content( icon: Image(systemName: "desktopcomputer"), title: String("Mac"), - description: String("This is an iMac") - ), - OnboardingInformationView.Content( - icon: Image(systemName: "laptopcomputer"), - title: String("MacBook"), - description: String("This is a MacBook") + description: String("This is an iMac.") ) ] - } - - - static var previews: some View { - OnboardingInformationView( - areas: [ - OnboardingInformationView.Content( - icon: Image(systemName: "pc"), - title: String("PC"), - description: String("This is a PC.") - ), - OnboardingInformationView.Content( - icon: Image(systemName: "desktopcomputer"), - title: String("Mac"), - description: String("This is an iMac.") - ) - ] - ) - } + ) } #endif diff --git a/Sources/SpeziOnboarding/OnboardingView.swift b/Sources/SpeziOnboarding/OnboardingView.swift index 7e79ee0..dd6da78 100644 --- a/Sources/SpeziOnboarding/OnboardingView.swift +++ b/Sources/SpeziOnboarding/OnboardingView.swift @@ -210,10 +210,30 @@ public struct OnboardingView = .constant(PKDrawing()), isSigning: Binding = .constant(false), canvasSize: Binding = .constant(.zero), - givenName: String, - familyName: String, + givenName: String = "", + familyName: String = "", date: Date? = nil, dateFormatter: DateFormatter = { let formatter = DateFormatter() @@ -202,8 +202,8 @@ public struct SignatureView: View { /// - lineOffset: Defines the distance of the signature line from the bottom of the view. The default value is 30. public init( signature: Binding = .constant(String()), - givenName: String, - familyName: String, + givenName: String = "", + familyName: String = "", date: Date? = nil, dateFormatter: DateFormatter = { let formatter = DateFormatter() @@ -223,6 +223,7 @@ public struct SignatureView: View { #endif } + #if DEBUG #Preview("Base Signature View") { SignatureView() diff --git a/Tests/UITests/TestApp/OnboardingTestsView.swift b/Tests/UITests/TestApp/OnboardingTestsView.swift index 114b6c8..f828697 100644 --- a/Tests/UITests/TestApp/OnboardingTestsView.swift +++ b/Tests/UITests/TestApp/OnboardingTestsView.swift @@ -59,9 +59,7 @@ struct OnboardingTestsView: View { #if DEBUG -struct OnboardingTestsView_Previews: PreviewProvider { - static var previews: some View { - OnboardingTestsView(onboardingFlowComplete: .constant(false)) - } +#Preview { + OnboardingTestsView(onboardingFlowComplete: .constant(false)) } #endif diff --git a/Tests/UITests/TestApp/Views/HelperViews/CustomToggleView.swift b/Tests/UITests/TestApp/Views/HelperViews/CustomToggleView.swift index 8c7225e..37fd19c 100644 --- a/Tests/UITests/TestApp/Views/HelperViews/CustomToggleView.swift +++ b/Tests/UITests/TestApp/Views/HelperViews/CustomToggleView.swift @@ -31,12 +31,10 @@ struct CustomToggleView: View { #if DEBUG -struct CustomToggleView_Previews: PreviewProvider { - static var previews: some View { - CustomToggleView( - text: "Test toggle", - condition: .constant(false) - ) - } +#Preview { + CustomToggleView( + text: "Test toggle", + condition: .constant(false) + ) } #endif diff --git a/Tests/UITests/TestApp/Views/OnboardingConditionalTestView.swift b/Tests/UITests/TestApp/Views/OnboardingConditionalTestView.swift index 9684758..579d4fe 100644 --- a/Tests/UITests/TestApp/Views/OnboardingConditionalTestView.swift +++ b/Tests/UITests/TestApp/Views/OnboardingConditionalTestView.swift @@ -30,12 +30,10 @@ struct OnboardingConditionalTestView: View { #if DEBUG -struct OnboardingConditionalTestView_Previews: PreviewProvider { - static var previews: some View { - OnboardingStack(startAtStep: OnboardingConditionalTestView.self) { - for onboardingView in OnboardingFlow.previewSimulatorViews { - onboardingView - } +#Preview { + OnboardingStack(startAtStep: OnboardingConditionalTestView.self) { + for onboardingView in OnboardingFlow.previewSimulatorViews { + onboardingView } } } diff --git a/Tests/UITests/TestApp/Views/OnboardingConsentMarkdownRenderingView.swift b/Tests/UITests/TestApp/Views/OnboardingConsentMarkdownRenderingView.swift index 7f2f160..14ecf9d 100644 --- a/Tests/UITests/TestApp/Views/OnboardingConsentMarkdownRenderingView.swift +++ b/Tests/UITests/TestApp/Views/OnboardingConsentMarkdownRenderingView.swift @@ -65,15 +65,14 @@ struct OnboardingConsentMarkdownRenderingView: View { #if DEBUG -struct OnboardingConsentMarkdownRenderingView_Previews: PreviewProvider { - static var standard: OnboardingDataSource = .init() - - static var previews: some View { - OnboardingStack(startAtStep: OnboardingConsentMarkdownRenderingView.self) { - for onboardingView in OnboardingFlow.previewSimulatorViews { - onboardingView - .environment(standard) - } +#Preview { + var standard: OnboardingDataSource = .init() + + + OnboardingStack(startAtStep: OnboardingConsentMarkdownRenderingView.self) { + for onboardingView in OnboardingFlow.previewSimulatorViews { + onboardingView + .environment(standard) } } } diff --git a/Tests/UITests/TestApp/Views/OnboardingConsentMarkdownTestView.swift b/Tests/UITests/TestApp/Views/OnboardingConsentMarkdownTestView.swift index a8c31aa..9331e7c 100644 --- a/Tests/UITests/TestApp/Views/OnboardingConsentMarkdownTestView.swift +++ b/Tests/UITests/TestApp/Views/OnboardingConsentMarkdownTestView.swift @@ -36,12 +36,10 @@ struct OnboardingConsentMarkdownTestView: View { #if DEBUG -struct OnboardingFirstConsentMarkdownTestView_Previews: PreviewProvider { - static var previews: some View { - OnboardingStack(startAtStep: OnboardingConsentMarkdownTestView.self) { - for onboardingView in OnboardingFlow.previewSimulatorViews { - onboardingView - } +#Preview { + OnboardingStack(startAtStep: OnboardingConsentMarkdownTestView.self) { + for onboardingView in OnboardingFlow.previewSimulatorViews { + onboardingView } } } diff --git a/Tests/UITests/TestApp/Views/OnboardingCustomTestView1.swift b/Tests/UITests/TestApp/Views/OnboardingCustomTestView1.swift index 7da2321..5020c5e 100644 --- a/Tests/UITests/TestApp/Views/OnboardingCustomTestView1.swift +++ b/Tests/UITests/TestApp/Views/OnboardingCustomTestView1.swift @@ -30,12 +30,10 @@ struct OnboardingCustomTestView1: View { #if DEBUG -struct OnboardingCustomTestView1_Previews: PreviewProvider { - static var previews: some View { - OnboardingStack(startAtStep: OnboardingCustomTestView1.self) { - for onboardingView in OnboardingFlow.previewSimulatorViews { - onboardingView - } +#Preview { + OnboardingStack(startAtStep: OnboardingCustomTestView1.self) { + for onboardingView in OnboardingFlow.previewSimulatorViews { + onboardingView } } } diff --git a/Tests/UITests/TestApp/Views/OnboardingCustomTestView2.swift b/Tests/UITests/TestApp/Views/OnboardingCustomTestView2.swift index 332c219..4d563ab 100644 --- a/Tests/UITests/TestApp/Views/OnboardingCustomTestView2.swift +++ b/Tests/UITests/TestApp/Views/OnboardingCustomTestView2.swift @@ -27,12 +27,10 @@ struct OnboardingCustomTestView2: View { } #if DEBUG -struct OnboardingCustomTestView2_Previews: PreviewProvider { - static var previews: some View { - OnboardingStack(startAtStep: OnboardingCustomTestView2.self) { - for onboardingView in OnboardingFlow.previewSimulatorViews { - onboardingView - } +#Preview { + OnboardingStack(startAtStep: OnboardingCustomTestView2.self) { + for onboardingView in OnboardingFlow.previewSimulatorViews { + onboardingView } } } diff --git a/Tests/UITests/TestApp/Views/OnboardingIdentifiableTestViewCustom.swift b/Tests/UITests/TestApp/Views/OnboardingIdentifiableTestViewCustom.swift index d36aa36..4cd28e9 100644 --- a/Tests/UITests/TestApp/Views/OnboardingIdentifiableTestViewCustom.swift +++ b/Tests/UITests/TestApp/Views/OnboardingIdentifiableTestViewCustom.swift @@ -33,12 +33,10 @@ struct OnboardingIdentifiableTestViewCustom: View, Identifiable { } #if DEBUG -struct OnboardingIdentifiableTestViewCustomView_Previews: PreviewProvider { - static var previews: some View { - OnboardingStack(startAtStep: OnboardingIdentifiableTestViewCustom.self) { - for onboardingView in OnboardingFlow.previewSimulatorViews { - onboardingView - } +#Preview { + OnboardingStack(startAtStep: OnboardingIdentifiableTestViewCustom.self) { + for onboardingView in OnboardingFlow.previewSimulatorViews { + onboardingView } } } diff --git a/Tests/UITests/TestApp/Views/OnboardingSequentialTestView.swift b/Tests/UITests/TestApp/Views/OnboardingSequentialTestView.swift index 028d607..6b25889 100644 --- a/Tests/UITests/TestApp/Views/OnboardingSequentialTestView.swift +++ b/Tests/UITests/TestApp/Views/OnboardingSequentialTestView.swift @@ -37,12 +37,10 @@ struct OnboardingSequentialTestView: View { #if DEBUG -struct OnboardingSequentialTestView_Previews: PreviewProvider { - static var previews: some View { - OnboardingStack(startAtStep: OnboardingSequentialTestView.self) { - for onboardingView in OnboardingFlow.previewSimulatorViews { - onboardingView - } +#Preview { + OnboardingStack(startAtStep: OnboardingSequentialTestView.self) { + for onboardingView in OnboardingFlow.previewSimulatorViews { + onboardingView } } } diff --git a/Tests/UITests/TestApp/Views/OnboardingStartTestView.swift b/Tests/UITests/TestApp/Views/OnboardingStartTestView.swift index f8e283a..01a60f4 100644 --- a/Tests/UITests/TestApp/Views/OnboardingStartTestView.swift +++ b/Tests/UITests/TestApp/Views/OnboardingStartTestView.swift @@ -76,12 +76,10 @@ struct OnboardingStartTestView: View { #if DEBUG -struct OnboardingStartTestView_Previews: PreviewProvider { - static var previews: some View { - OnboardingStack(startAtStep: OnboardingStartTestView.self) { - for onboardingView in OnboardingFlow.previewSimulatorViews { - onboardingView - } +#Preview { + OnboardingStack(startAtStep: OnboardingStartTestView.self) { + for onboardingView in OnboardingFlow.previewSimulatorViews { + onboardingView } } } diff --git a/Tests/UITests/TestApp/Views/OnboardingTestViewNotIdentifiable.swift b/Tests/UITests/TestApp/Views/OnboardingTestViewNotIdentifiable.swift index 7b9e244..be06dc5 100644 --- a/Tests/UITests/TestApp/Views/OnboardingTestViewNotIdentifiable.swift +++ b/Tests/UITests/TestApp/Views/OnboardingTestViewNotIdentifiable.swift @@ -29,12 +29,10 @@ struct OnboardingTestViewNotIdentifiable: View { } #if DEBUG -struct OnboardingTestViewNotIdentifiable_Previews: PreviewProvider { - static var previews: some View { - OnboardingStack(startAtStep: OnboardingTestViewNotIdentifiable.self) { - for onboardingView in OnboardingFlow.previewSimulatorViews { - onboardingView - } +#Preview { + OnboardingStack(startAtStep: OnboardingTestViewNotIdentifiable.self) { + for onboardingView in OnboardingFlow.previewSimulatorViews { + onboardingView } } } diff --git a/Tests/UITests/TestApp/Views/OnboardingWelcomeTestView.swift b/Tests/UITests/TestApp/Views/OnboardingWelcomeTestView.swift index 72011c9..afefb5f 100644 --- a/Tests/UITests/TestApp/Views/OnboardingWelcomeTestView.swift +++ b/Tests/UITests/TestApp/Views/OnboardingWelcomeTestView.swift @@ -39,12 +39,10 @@ struct OnboardingWelcomeTestView: View { #if DEBUG -struct OnboardingWelcomeTestView_Previews: PreviewProvider { - static var previews: some View { - OnboardingStack(startAtStep: OnboardingWelcomeTestView.self) { - for onboardingView in OnboardingFlow.previewSimulatorViews { - onboardingView - } +#Preview { + OnboardingStack(startAtStep: OnboardingWelcomeTestView.self) { + for onboardingView in OnboardingFlow.previewSimulatorViews { + onboardingView } } } From c150498736a2a75194d87260a57b9c5e343d73a8 Mon Sep 17 00:00:00 2001 From: Philipp Zagar Date: Wed, 22 Jan 2025 15:01:38 +0100 Subject: [PATCH 05/24] Fix export of date below signature --- README.md | 3 +- .../ConsentView/ConsentDocument+Export.swift | 1 + .../ConsentDocument+ExportConfiguration.swift | 21 +++---- .../ConsentView/ConsentDocument.swift | 55 +++++++++++++---- .../ConsentDocumentExport+Export.swift | 40 +++++++++++-- .../ConsentView/ConsentDocumentExport.swift | 1 + .../ConsentView/ConsentViewState.swift | 2 +- .../ExportConfiguration+Defaults.swift | 8 +-- .../ExportConfiguration+PDFPageFormat.swift | 8 +-- .../OnboardingConsentView.swift | 12 +++- .../OnboardingFlow/OnboardingStack.swift | 4 +- .../OnboardingIdentifiableViewModifier.swift | 2 +- Sources/SpeziOnboarding/SignatureView.swift | 60 ++++++------------- .../SignatureViewBackground.swift | 4 ++ .../ObtainingUserConsent.md | 19 +++--- .../SpeziOnboarding.docc/SpeziOnboarding.md | 3 +- ...boardingConsentMarkdownRenderingView.swift | 2 +- .../OnboardingConsentMarkdownTestView.swift | 3 +- 18 files changed, 147 insertions(+), 101 deletions(-) diff --git a/README.md b/README.md index 73742da..f79d38d 100644 --- a/README.md +++ b/README.md @@ -142,7 +142,8 @@ struct ConsentViewExample: View { action: { // Action to perform once the user has given their consent }, - exportConfiguration: .init(paperSize: .usLetter) // Configure the properties of the exported consent form + exportConfiguration: .init(paperSize: .usLetter), // Configure the properties of the exported consent form + currentDateInSignature: true // Indicates if the consent signature should include the current date. ) } } diff --git a/Sources/SpeziOnboarding/ConsentView/ConsentDocument+Export.swift b/Sources/SpeziOnboarding/ConsentView/ConsentDocument+Export.swift index 9408dbe..fbe3832 100644 --- a/Sources/SpeziOnboarding/ConsentView/ConsentDocument+Export.swift +++ b/Sources/SpeziOnboarding/ConsentView/ConsentDocument+Export.swift @@ -51,6 +51,7 @@ extension ConsentDocument { @MainActor func export() async throws -> PDFKit.PDFDocument { documentExport.signature = signature + documentExport.formattedSignatureDate = formattedConsentSignatureDate documentExport.name = name #if !os(macOS) documentExport.signatureImage = blackInkSignatureImage diff --git a/Sources/SpeziOnboarding/ConsentView/ConsentDocument+ExportConfiguration.swift b/Sources/SpeziOnboarding/ConsentView/ConsentDocument+ExportConfiguration.swift index 51a9732..a0448d2 100644 --- a/Sources/SpeziOnboarding/ConsentView/ConsentDocument+ExportConfiguration.swift +++ b/Sources/SpeziOnboarding/ConsentView/ConsentDocument+ExportConfiguration.swift @@ -48,8 +48,8 @@ extension ConsentDocument { /// The ``FontSettings`` store configuration of the fonts used to render the exported /// consent document, i.e., fonts for the content, title and signature. public struct FontSettings: Sendable { - /// The font of the name rendered below the signature line. - public let signatureNameFont: UIFont + /// The font of the caption rendered below the signature line. + public let signatureCaptionFont: UIFont /// The font of the prefix of the signature ("X" in most cases). public let signaturePrefixFont: UIFont /// The font of the content of the document (i.e., the rendered markdown text) @@ -63,19 +63,19 @@ extension ConsentDocument { /// Creates an instance`FontSettings` specifying the fonts of various components of the exported document /// /// - Parameters: - /// - signatureNameFont: The font used for the signature name. + /// - signatureCaptionFont: The font used for the signature caption. /// - signaturePrefixFont: The font used for the signature prefix text. /// - documentContentFont: The font used for the main content of the document. /// - headerTitleFont: The font used for the header title. /// - headerExportTimeStampFont: The font used for the header timestamp. public init( - signatureNameFont: UIFont, + signatureCaptionFont: UIFont, signaturePrefixFont: UIFont, documentContentFont: UIFont, headerTitleFont: UIFont, headerExportTimeStampFont: UIFont ) { - self.signatureNameFont = signatureNameFont + self.signatureCaptionFont = signatureCaptionFont self.signaturePrefixFont = signaturePrefixFont self.documentContentFont = documentContentFont self.headerTitleFont = headerTitleFont @@ -86,8 +86,8 @@ extension ConsentDocument { /// The ``FontSettings`` store configuration of the fonts used to render the exported /// consent document, i.e., fonts for the content, title and signature. public struct FontSettings: @unchecked Sendable { - /// The font of the name rendered below the signature line. - public let signatureNameFont: NSFont + /// The font of the caption rendered below the signature line. + public let signatureCaptionFont: NSFont /// The font of the prefix of the signature ("X" in most cases). public let signaturePrefixFont: NSFont /// The font of the content of the document (i.e., the rendered markdown text) @@ -101,19 +101,19 @@ extension ConsentDocument { /// Creates an instance`FontSettings` specifying the fonts of various components of the exported document /// /// - Parameters: - /// - signatureNameFont: The font used for the signature name. + /// - signatureCaptionFont: The font used for the signature caption. /// - signaturePrefixFont: The font used for the signature prefix text. /// - documentContentFont: The font used for the main content of the document. /// - headerTitleFont: The font used for the header title. /// - headerExportTimeStampFont: The font used for the header timestamp. public init( - signatureNameFont: NSFont, + signatureCaptionFont: NSFont, signaturePrefixFont: NSFont, documentContentFont: NSFont, headerTitleFont: NSFont, headerExportTimeStampFont: NSFont ) { - self.signatureNameFont = signatureNameFont + self.signatureCaptionFont = signatureCaptionFont self.signaturePrefixFont = signaturePrefixFont self.documentContentFont = documentContentFont self.headerTitleFont = headerTitleFont @@ -134,6 +134,7 @@ extension ConsentDocument { /// - paperSize: The page size of the exported form represented by ``ConsentDocument/ExportConfiguration/PaperSize``. /// - consentTitle: The title of the exported consent form. /// - includingTimestamp: Indicates if the exported form includes a timestamp. + /// - fontSettings: Font settings for the exported form. public init( paperSize: PaperSize = .usLetter, consentTitle: LocalizedStringResource = LocalizationDefaults.exportedConsentFormTitle, diff --git a/Sources/SpeziOnboarding/ConsentView/ConsentDocument.swift b/Sources/SpeziOnboarding/ConsentView/ConsentDocument.swift index 490733e..0496d23 100644 --- a/Sources/SpeziOnboarding/ConsentView/ConsentDocument.swift +++ b/Sources/SpeziOnboarding/ConsentView/ConsentDocument.swift @@ -19,9 +19,9 @@ import SwiftUI /// In addition, it enables the export of the signed form as a PDF document. /// /// To observe and control the current state of the `ConsentDocument`, the view requires passing down a ``ConsentViewState`` as a SwiftUI `Binding` in the -/// ``init(markdown:viewState:givenNameTitle:givenNamePlaceholder:familyNameTitle:familyNamePlaceholder:exportConfiguration:)`` initializer. +/// ``init(markdown:viewState:givenNameTitle:givenNamePlaceholder:familyNameTitle:familyNamePlaceholder:exportConfiguration:documentIdentifier:consentSignatureDate:consentSignatureDateFormatter:)`` initializer. /// This `Binding` can then be used to trigger the export of the consent form via setting the state to ``ConsentViewState/export``. -/// After the rendering completes, the finished `PDFDocument` from Apple's PDFKit is accessible via the associated value of the view state in ``ConsentViewState/exported(document:)``. +/// After the rendering completes, the finished `PDFDocument` from Apple's PDFKit is accessible via the associated value of the view state in ``ConsentViewState/exported(document:export:)``. /// Other possible states of the `ConsentDocument` are the SpeziViews `ViewState`'s accessible via the associated value in ``ConsentViewState/base(_:)``. /// In addition, the view provides information about the signing progress via the ``ConsentViewState/signing`` and ``ConsentViewState/signed`` states. /// @@ -34,7 +34,8 @@ import SwiftUI /// Data("This is a *markdown* **example**".utf8) /// }, /// viewState: $state, -/// exportConfiguration: .init(paperSize: .usLetter) // Configure the properties of the exported consent form +/// exportConfiguration: .init(paperSize: .usLetter), // Configure the properties of the exported consent form +/// consentSignatureDate: .now /// ) /// ``` public struct ConsentDocument: View { @@ -45,7 +46,9 @@ public struct ConsentDocument: View { private let givenNamePlaceholder: LocalizedStringResource private let familyNameTitle: LocalizedStringResource private let familyNamePlaceholder: LocalizedStringResource - + private let consentSignatureDate: Date? + private let consentSignatureDateFormatter: DateFormatter + let documentExport: ConsentDocumentExport @Environment(\.colorScheme) var colorScheme @@ -85,7 +88,6 @@ public struct ConsentDocument: View { signature.removeAll() #endif } - documentExport.name = name } Divider() @@ -112,9 +114,19 @@ public struct ConsentDocument: View { @MainActor private var signatureView: some View { Group { #if !os(macOS) - SignatureView(signature: $signature, isSigning: $viewState.signing, canvasSize: $signatureSize, name: name) + SignatureView( + signature: $signature, + isSigning: $viewState.signing, + canvasSize: $signatureSize, + name: name, + formattedDate: formattedConsentSignatureDate + ) #else - SignatureView(signature: $signature, name: name) + SignatureView( + signature: $signature, + name: name, + formattedDate: formattedConsentSignatureDate + ) #endif } .padding(.vertical, 4) @@ -130,7 +142,6 @@ public struct ConsentDocument: View { } else { viewState = .namesEntered } - documentExport.signature = signature } } @@ -187,7 +198,15 @@ public struct ConsentDocument: View { private var inputFieldsDisabled: Bool { viewState == .base(.processing) || viewState == .export || viewState == .storing } - + + var formattedConsentSignatureDate: String? { + if let consentSignatureDate { + consentSignatureDateFormatter.string(from: consentSignatureDate) + } else { + nil + } + } + /// Creates a `ConsentDocument` which renders a consent document with a markdown view. /// @@ -202,6 +221,8 @@ public struct ConsentDocument: View { /// - familyNamePlaceholder: The localization to use for the family name field placeholder. /// - exportConfiguration: Defines the properties of the exported consent form via ``ConsentDocument/ExportConfiguration``. /// - documentIdentifier: A unique identifier or "name" for the consent form, helpful for distinguishing consent forms when storing in the `Standard`. + /// - consentSignatureDate: The date that is displayed under the signature line. + /// - consentSignatureDateFormatter: The date formatter used to format the date that is displayed under the signature line. public init( markdown: @escaping () async -> Data, viewState: Binding, @@ -210,14 +231,22 @@ public struct ConsentDocument: View { familyNameTitle: LocalizedStringResource = LocalizationDefaults.familyNameTitle, familyNamePlaceholder: LocalizedStringResource = LocalizationDefaults.familyNamePlaceholder, exportConfiguration: ExportConfiguration = .init(), - documentIdentifier: String = ConsentDocumentExport.Defaults.documentIdentifier + documentIdentifier: String = ConsentDocumentExport.Defaults.documentIdentifier, + consentSignatureDate: Date? = nil, + consentSignatureDateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateStyle = .short + return formatter + }() ) { self._viewState = viewState self.givenNameTitle = givenNameTitle self.givenNamePlaceholder = givenNamePlaceholder self.familyNameTitle = familyNameTitle self.familyNamePlaceholder = familyNamePlaceholder - + self.consentSignatureDate = consentSignatureDate + self.consentSignatureDateFormatter = consentSignatureDateFormatter + self.documentExport = ConsentDocumentExport( markdown: markdown, exportConfiguration: exportConfiguration, @@ -243,8 +272,8 @@ public struct ConsentDocument: View { }, viewState: $viewState ) - .navigationTitle(Text(verbatim: "Consent")) - .padding() + .navigationTitle(Text(verbatim: "Consent")) + .padding() } } #endif diff --git a/Sources/SpeziOnboarding/ConsentView/ConsentDocumentExport+Export.swift b/Sources/SpeziOnboarding/ConsentView/ConsentDocumentExport+Export.swift index 54b335a..49b3e34 100644 --- a/Sources/SpeziOnboarding/ConsentView/ConsentDocumentExport+Export.swift +++ b/Sources/SpeziOnboarding/ConsentView/ConsentDocumentExport+Export.swift @@ -90,12 +90,40 @@ extension ConsentDocumentExport { #endif group.set(font: exportConfiguration.fontSettings.signaturePrefixFont) - group.add(PDFGroupContainer.left, text: signaturePrefix) + group.add(.left, text: signaturePrefix) group.addLineSeparator(style: PDFLineStyle(color: .black)) - - group.set(font: exportConfiguration.fontSettings.signatureNameFont) - group.add(PDFGroupContainer.left, text: personName) + + // Add person name and date as the caption below the signature line + // Sadly a quite complex table is required to have the caption within one line + let table = PDFTable(rows: 1, columns: 2) + table.widths = [0.5, 0.5] // Two equal-width columns for left and right alignment + table.margin = .zero + table.padding = 0 + table.style.outline = .none + + let cellStyle = PDFTableCellStyle( + colors: (Color.clear, Color.black), + borders: .none, + font: exportConfiguration.fontSettings.signatureCaptionFont + ) + + // Add person name to the left cell + table[0, 0] = PDFTableCell( + content: try? .init(content: personName), + alignment: .left, + style: cellStyle + ) + + // Add formatted date to the right cell + table[0, 1] = PDFTableCell( + content: try? .init(content: formattedSignatureDate ?? ""), + alignment: .right, + style: cellStyle + ) + + group.add(.left, table: table) + return group } @@ -116,8 +144,8 @@ extension ConsentDocumentExport { signatureFooter: PDFGroup, exportTimeStamp: PDFAttributedText? = nil ) async throws -> PDFKit.PDFDocument { - let document = TPPDF.PDFDocument(format: exportConfiguration.getPDFPageFormat()) - + let document = TPPDF.PDFDocument(format: exportConfiguration.pdfPageFormat) + if let exportStamp = exportTimeStamp { document.add(.contentRight, attributedTextObject: exportStamp) } diff --git a/Sources/SpeziOnboarding/ConsentView/ConsentDocumentExport.swift b/Sources/SpeziOnboarding/ConsentView/ConsentDocumentExport.swift index f5bc8ea..340fd47 100644 --- a/Sources/SpeziOnboarding/ConsentView/ConsentDocumentExport.swift +++ b/Sources/SpeziOnboarding/ConsentView/ConsentDocumentExport.swift @@ -41,6 +41,7 @@ public final class ConsentDocumentExport: Equatable { /// The signature of the signee as string. public var signature = String() #endif + public var formattedSignatureDate: String? /// Creates a `ConsentDocumentExport`, which holds an exported PDF and the corresponding document identifier string. diff --git a/Sources/SpeziOnboarding/ConsentView/ConsentViewState.swift b/Sources/SpeziOnboarding/ConsentView/ConsentViewState.swift index a31108a..2ccab50 100644 --- a/Sources/SpeziOnboarding/ConsentView/ConsentViewState.swift +++ b/Sources/SpeziOnboarding/ConsentView/ConsentViewState.swift @@ -33,7 +33,7 @@ public enum ConsentViewState: Equatable { /// The `exported` state indicates that the /// ``ConsentDocument`` has been successfully exported. The rendered `PDFDocument` can be found as the associated value of the state. /// - /// The export procedure (resulting in the ``ConsentViewState/exported(document:)`` state) can be triggered via setting the ``ConsentViewState/export`` state of the ``ConsentDocument`` . + /// The export procedure (resulting in the ``ConsentViewState/exported(document:export:)`` state) can be triggered via setting the ``ConsentViewState/export`` state of the ``ConsentDocument`` . case exported(document: PDFDocument, export: ConsentDocumentExport) /// The `storing` state indicates that the ``ConsentDocument`` is currently being stored to the Standard. case storing diff --git a/Sources/SpeziOnboarding/ConsentView/ExportConfiguration+Defaults.swift b/Sources/SpeziOnboarding/ConsentView/ExportConfiguration+Defaults.swift index b084a1b..a4cb11d 100644 --- a/Sources/SpeziOnboarding/ConsentView/ExportConfiguration+Defaults.swift +++ b/Sources/SpeziOnboarding/ConsentView/ExportConfiguration+Defaults.swift @@ -19,7 +19,7 @@ extension ConsentDocument.ExportConfiguration { /// This configuration uses `systemFont` and `boldSystemFont` with absolute font sizes to achieve uniform font sizes /// on different operating systems such as macOS, iOS, and visionOS. public static let defaultExportFontSettings = FontSettings( - signatureNameFont: UIFont.systemFont(ofSize: 10), + signatureCaptionFont: UIFont.systemFont(ofSize: 10), signaturePrefixFont: UIFont.boldSystemFont(ofSize: 12), documentContentFont: UIFont.systemFont(ofSize: 12), headerTitleFont: UIFont.boldSystemFont(ofSize: 28), @@ -30,7 +30,7 @@ extension ConsentDocument.ExportConfiguration { /// the font sizes might change according to the system settings, potentially leading to varying exported PDF documents /// on devices with different system settings (e.g., larger default font size). public static let defaultSystemDefaultFontSettings = FontSettings( - signatureNameFont: UIFont.preferredFont(forTextStyle: .subheadline), + signatureCaptionFont: UIFont.preferredFont(forTextStyle: .subheadline), signaturePrefixFont: UIFont.preferredFont(forTextStyle: .title2), documentContentFont: UIFont.preferredFont(forTextStyle: .body), headerTitleFont: UIFont.boldSystemFont(ofSize: UIFont.preferredFont(forTextStyle: .largeTitle).pointSize), @@ -42,7 +42,7 @@ extension ConsentDocument.ExportConfiguration { /// This configuration uses `systemFont` and `boldSystemFont` with absolute font sizes to achieve uniform font sizes /// on different operating systems such as macOS, iOS, and visionOS. public static let defaultExportFontSettings = FontSettings( - signatureNameFont: NSFont.systemFont(ofSize: 10), + signatureCaptionFont: NSFont.systemFont(ofSize: 10), signaturePrefixFont: NSFont.boldSystemFont(ofSize: 12), documentContentFont: NSFont.systemFont(ofSize: 12), headerTitleFont: NSFont.boldSystemFont(ofSize: 28), @@ -53,7 +53,7 @@ extension ConsentDocument.ExportConfiguration { /// the font sizes might change according to the system settings, potentially leading to varying exported PDF documents /// on devices with different system settings (e.g., larger default font size). public static let defaultSystemDefaultFontSettings = FontSettings( - signatureNameFont: NSFont.preferredFont(forTextStyle: .subheadline), + signatureCaptionFont: NSFont.preferredFont(forTextStyle: .subheadline), signaturePrefixFont: NSFont.preferredFont(forTextStyle: .title2), documentContentFont: NSFont.preferredFont(forTextStyle: .body), headerTitleFont: NSFont.boldSystemFont(ofSize: NSFont.preferredFont(forTextStyle: .largeTitle).pointSize), diff --git a/Sources/SpeziOnboarding/ConsentView/ExportConfiguration+PDFPageFormat.swift b/Sources/SpeziOnboarding/ConsentView/ExportConfiguration+PDFPageFormat.swift index ece0429..3ecaea6 100644 --- a/Sources/SpeziOnboarding/ConsentView/ExportConfiguration+PDFPageFormat.swift +++ b/Sources/SpeziOnboarding/ConsentView/ExportConfiguration+PDFPageFormat.swift @@ -12,12 +12,8 @@ import TPPDF /// The ``ExportConfiguration`` enables developers to define the properties of the exported consent form. extension ConsentDocument.ExportConfiguration { - /// Returns a `TPPDF.PDFPageFormat` which corresponds to Spezi's `ExportConfiguration.PaperSize`. - /// - /// - Parameters: - /// - paperSize: The paperSize of an ExportConfiguration. - /// - Returns: A TPPDF `PDFPageFormat` according to the `ExportConfiguration.PaperSize`. - func getPDFPageFormat() -> PDFPageFormat { + /// `TPPDF.PDFPageFormat` which corresponds to SpeziOnboarding's `ExportConfiguration.PaperSize`. + var pdfPageFormat: PDFPageFormat { switch paperSize { case .dinA4: return PDFPageFormat.a4 diff --git a/Sources/SpeziOnboarding/OnboardingConsentView.swift b/Sources/SpeziOnboarding/OnboardingConsentView.swift index f161f0a..a5e1c94 100644 --- a/Sources/SpeziOnboarding/OnboardingConsentView.swift +++ b/Sources/SpeziOnboarding/OnboardingConsentView.swift @@ -46,7 +46,8 @@ import SwiftUI /// title: "Consent", // Configure the title of the consent view /// identifier: DocumentIdentifiers.first, // Specify a unique identifier String, preferably bundled /// // in an enum (see above). Only relevant if more than one OnboardingConsentView is needed. -/// exportConfiguration: .init(paperSize: .usLetter) // Configure the properties of the exported consent form +/// exportConfiguration: .init(paperSize: .usLetter), // Configure the properties of the exported consent form +/// currentDateInSignature: true // Indicates if the consent signature should include the current date /// ) /// ``` public struct OnboardingConsentView: View { @@ -63,6 +64,7 @@ public struct OnboardingConsentView: View { private let title: LocalizedStringResource? private let identifier: String private let exportConfiguration: ConsentDocument.ExportConfiguration + private let currentDateInSignature: Bool private var backButtonHidden: Bool { viewState == .storing || (viewState == .export && !willShowShareSheet) } @@ -88,7 +90,8 @@ public struct OnboardingConsentView: View { markdown: markdown, viewState: $viewState, exportConfiguration: exportConfiguration, - documentIdentifier: identifier + documentIdentifier: identifier, + consentSignatureDate: currentDateInSignature ? .now : nil ) .padding(.bottom) }, @@ -214,18 +217,21 @@ public struct OnboardingConsentView: View { /// - title: The title of the view displayed at the top. Can be `nil`, meaning no title is displayed. /// - identifier: A unique identifier or "name" for the consent form, helpful for distinguishing consent forms when storing in the `Standard`. /// - exportConfiguration: Defines the properties of the exported consent form via ``ConsentDocument/ExportConfiguration``. + /// - currentDateInSignature: Indicates if the consent document should include the current date in the signature field. Defaults to `true`. public init( markdown: @escaping () async -> Data, action: @escaping () async -> Void, title: LocalizedStringResource? = LocalizationDefaults.consentFormTitle, identifier: String = ConsentDocumentExport.Defaults.documentIdentifier, - exportConfiguration: ConsentDocument.ExportConfiguration = .init() + exportConfiguration: ConsentDocument.ExportConfiguration = .init(), + currentDateInSignature: Bool = true ) { self.markdown = markdown self.exportConfiguration = exportConfiguration self.title = title self.action = action self.identifier = identifier + self.currentDateInSignature = currentDateInSignature } } diff --git a/Sources/SpeziOnboarding/OnboardingFlow/OnboardingStack.swift b/Sources/SpeziOnboarding/OnboardingFlow/OnboardingStack.swift index ed2cbb5..baac4b7 100644 --- a/Sources/SpeziOnboarding/OnboardingFlow/OnboardingStack.swift +++ b/Sources/SpeziOnboarding/OnboardingFlow/OnboardingStack.swift @@ -52,7 +52,7 @@ import SwiftUI /// /// ### Identifying Onboarding Views /// -/// Apply the ``SwiftUI/View/onboardingIdentifier(_:)`` modifier to clearly identify a view in the `OnboardingStack`. +/// Apply the ``SwiftUICore/View/onboardingIdentifier(_:)`` modifier to clearly identify a view in the `OnboardingStack`. /// This is particularly useful in scenarios where multiple instances of the same view type might appear in the stack. /// /// ```swift @@ -71,7 +71,7 @@ import SwiftUI /// } /// ``` /// -/// - Note: When the ``SwiftUI/View/onboardingIdentifier(_:)`` modifier is applied multiple times to the same view, the outermost identifier takes precedence. +/// - Note: When the ``SwiftUICore/View/onboardingIdentifier(_:)`` modifier is applied multiple times to the same view, the outermost identifier takes precedence. public struct OnboardingStack: View { @State var onboardingNavigationPath: OnboardingNavigationPath private let collection: _OnboardingFlowViewCollection diff --git a/Sources/SpeziOnboarding/OnboardingIdentifiableViewModifier.swift b/Sources/SpeziOnboarding/OnboardingIdentifiableViewModifier.swift index 2003322..1a61d5e 100644 --- a/Sources/SpeziOnboarding/OnboardingIdentifiableViewModifier.swift +++ b/Sources/SpeziOnboarding/OnboardingIdentifiableViewModifier.swift @@ -35,7 +35,7 @@ extension View { /// Assign a unique identifier to a `View` appearing in an `OnboardingStack`. /// /// A `ViewModifier` assigning an identifier to the `View` it is applied to. - /// When applying this modifier repeatedly, the outermost ``SwiftUI/View/onboardingIdentifier(_:)`` counts. + /// When applying this modifier repeatedly, the outermost ``SwiftUICore/View/onboardingIdentifier(_:)`` counts. /// /// - Note: This `ViewModifier` should only be used to identify `View`s of the same type within an ``OnboardingStack``. /// diff --git a/Sources/SpeziOnboarding/SignatureView.swift b/Sources/SpeziOnboarding/SignatureView.swift index e887e0f..a25b1fc 100644 --- a/Sources/SpeziOnboarding/SignatureView.swift +++ b/Sources/SpeziOnboarding/SignatureView.swift @@ -37,8 +37,7 @@ public struct SignatureView: View { @Binding private var signature: String #endif private let name: PersonNameComponents - private let date: Date? - private let dateFormatter: DateFormatter + private let formattedDate: String? private let lineOffset: CGFloat @@ -47,7 +46,7 @@ public struct SignatureView: View { ZStack(alignment: .bottomLeading) { SignatureViewBackground( name: name, - formattedDate: { if let date { dateFormatter.string(from: date) } else { nil } }(), + formattedDate: formattedDate, lineOffset: lineOffset ) @@ -115,26 +114,21 @@ public struct SignatureView: View { /// - isSigning: A `Binding` indicating if the user is currently signing. /// - canvasSize: The size of the canvas as a Binding. /// - name: The name that is displayed under the signature line. + /// - formattedDate: The formatted date that is displayed under the signature line. /// - lineOffset: Defines the distance of the signature line from the bottom of the view. The default value is 30. public init( signature: Binding = .constant(PKDrawing()), isSigning: Binding = .constant(false), canvasSize: Binding = .constant(.zero), name: PersonNameComponents = PersonNameComponents(), - date: Date? = nil, - dateFormatter: DateFormatter = { - let formatter = DateFormatter() - formatter.dateStyle = .short - return formatter - }(), + formattedDate: String? = nil, lineOffset: CGFloat = 30 ) { self._signature = signature self._isSigning = isSigning self._canvasSize = canvasSize self.name = name - self.date = date - self.dateFormatter = dateFormatter + self.formattedDate = formattedDate self.lineOffset = lineOffset } @@ -145,6 +139,7 @@ public struct SignatureView: View { /// - canvasSize: The size of the canvas as a Binding. /// - givenName: The given name that is displayed under the signature line. /// - familyName: The family name that is displayed under the signature line. + /// - formattedDate: The formatted date that is displayed under the signature line. /// - lineOffset: Defines the distance of the signature line from the bottom of the view. The default value is 30. public init( signature: Binding = .constant(PKDrawing()), @@ -152,12 +147,7 @@ public struct SignatureView: View { canvasSize: Binding = .constant(.zero), givenName: String = "", familyName: String = "", - date: Date? = nil, - dateFormatter: DateFormatter = { - let formatter = DateFormatter() - formatter.dateStyle = .short - return formatter - }(), + formattedDate: String? = nil, lineOffset: CGFloat = 30 ) { self.init( @@ -165,8 +155,7 @@ public struct SignatureView: View { isSigning: isSigning, canvasSize: canvasSize, name: .init(givenName: givenName, familyName: familyName), - date: date, - dateFormatter: dateFormatter, + formattedDate: formattedDate, lineOffset: lineOffset ) } @@ -175,22 +164,17 @@ public struct SignatureView: View { /// - Parameters: /// - signature: A `Binding` containing the current text-based signature as a `String`. /// - name: The name that is displayed under the signature line. + /// - formattedDate: The formatted date that is displayed under the signature line. /// - lineOffset: Defines the distance of the signature line from the bottom of the view. The default value is 30. public init( signature: Binding = .constant(String()), name: PersonNameComponents = PersonNameComponents(), - date: Date? = nil, - dateFormatter: DateFormatter = { - let formatter = DateFormatter() - formatter.dateStyle = .short - return formatter - }(), + formattedDate: String? = nil, lineOffset: CGFloat = 30 ) { self._signature = signature - self.name = names - self.date = date - self.dateFormatter = dateFormatter + self.name = name + self.formattedDate = formattedDate self.lineOffset = lineOffset } @@ -199,24 +183,19 @@ public struct SignatureView: View { /// - signature: A `Binding` containing the current text-based signature as a `String`. /// - givenName: The given name that is displayed under the signature line. /// - familyName: The family name that is displayed under the signature line. + /// - formattedDate: The formatted date that is displayed under the signature line. /// - lineOffset: Defines the distance of the signature line from the bottom of the view. The default value is 30. public init( signature: Binding = .constant(String()), givenName: String = "", familyName: String = "", - date: Date? = nil, - dateFormatter: DateFormatter = { - let formatter = DateFormatter() - formatter.dateStyle = .short - return formatter - }(), + formattedDate: String? = nil, lineOffset: CGFloat = 30 ) { self.init( signature: signature, name: .init(givenName: givenName, familyName: familyName), - date: date, - dateFormatter: dateFormatter, + formattedDate: formattedDate, lineOffset: lineOffset ) } @@ -240,19 +219,14 @@ public struct SignatureView: View { #Preview("Including PersonNameComponents and Date") { SignatureView( name: PersonNameComponents(givenName: "Leland", familyName: "Stanford"), - date: .now + formattedDate: "01/22/25" ) } #Preview("Including PersonNameComponents and Date with custom format") { SignatureView( name: PersonNameComponents(givenName: "Leland", familyName: "Stanford"), - date: .now, - dateFormatter: { - let formatter = DateFormatter() - formatter.dateStyle = .medium - return formatter - }() + formattedDate: "01/22/25" ) } #endif diff --git a/Sources/SpeziOnboarding/SignatureViewBackground.swift b/Sources/SpeziOnboarding/SignatureViewBackground.swift index 02e817f..3ec0779 100644 --- a/Sources/SpeziOnboarding/SignatureViewBackground.swift +++ b/Sources/SpeziOnboarding/SignatureViewBackground.swift @@ -45,6 +45,8 @@ struct SignatureViewBackground: View { Text(name) .font(.subheadline) .foregroundColor(.secondary) + .lineLimit(1) // Ensures the name is restricted to a single line + .truncationMode(.tail) // Truncate name at the end .padding(.horizontal, 20) .padding(.bottom, lineOffset - 18) .accessibilityLabel(Text("SIGNATURE_NAME \(name)", bundle: .module)) @@ -56,6 +58,8 @@ struct SignatureViewBackground: View { Text(formattedDate) .font(.subheadline) .foregroundColor(.secondary) + .lineLimit(1) // Ensures the date is restricted to a single line + .truncationMode(.middle) // Truncate date in the middle .padding(.horizontal, 20) .padding(.bottom, lineOffset - 18) .accessibilityLabel(Text("SIGNATURE_DATE \(formattedDate)", bundle: .module)) diff --git a/Sources/SpeziOnboarding/SpeziOnboarding.docc/ObtainingUserConsent.md b/Sources/SpeziOnboarding/SpeziOnboarding.docc/ObtainingUserConsent.md index 8b062f9..269ed67 100644 --- a/Sources/SpeziOnboarding/SpeziOnboarding.docc/ObtainingUserConsent.md +++ b/Sources/SpeziOnboarding/SpeziOnboarding.docc/ObtainingUserConsent.md @@ -29,7 +29,8 @@ OnboardingConsentView( // Action to perform once the user has given their consent }, identifier: "MyFirstConsentForm", // Specify an optional unique identifier for the consent form, helpful for distinguishing consent forms when storing. - exportConfiguration: .init(paperSize: .usLetter) // Configure the properties of the exported consent form + exportConfiguration: .init(paperSize: .usLetter), // Configure the properties of the exported consent form. + currentDateInSignature: true // Indicates if the consent signature should include the current date. ) ``` @@ -38,8 +39,8 @@ OnboardingConsentView( If you want to show multiple consent documents to the user, that need to be signed separately, you can add multiple instances of ``OnboardingConsentView``. In that case, it is important that you provide each instance with an unique document identifier String, to distinguish the two consent documents when they are stored. Consider the example code below. -First, you should define an enum which holds a document identifier String for each of the two (or more) documents. We recommend using an enum to hold the -identifier strings to avoid having to write them explicitly throughout your App (e.g., in the ``OnboardingConsentView`` and the ``Standard``). +First, define an enum which holds a document identifier String for each of the two (or more) documents. We recommend using an enum to hold the +identifier strings to avoid having to write them explicitly throughout your App (e.g., in the ``OnboardingConsentView`` and the Spezi `Standard`). ```swift enum DocumentIdentifiers { @@ -48,8 +49,8 @@ enum DocumentIdentifiers { } ``` -Next, you can use the identifier to instantiate two consent views with separate documents. -Note, that you will also have to set the "onboardingIdentifier", so that Spezi can distinguish the views. We recommend that you simply reause your document identifier for the onboardingIdentifier. +Next, use the identifier to instantiate two consent views with separate documents. +Note, that you will also have to set the "onboardingIdentifier", so that Spezi can distinguish the views. We recommend that you simply reuse your document identifier for the onboardingIdentifier. ```swift OnboardingConsentView( @@ -60,7 +61,8 @@ OnboardingConsentView( // Action to perform once the user has given their consent }, identifier: DocumentIdentifiers.first, // Specify an optional unique identifier for the consent form, helpful for distinguishing consent forms when storing. - exportConfiguration: .init(paperSize: .usLetter) // Configure the properties of the exported consent form + exportConfiguration: .init(paperSize: .usLetter), // Configure the properties of the exported consent form. + currentDateInSignature: true // Indicates if the consent signature should include the current date. ) .onboardingIdentifier(DocumentIdentifiers.first) // Set an identifier (String) for the view, to distinguish it from other views of the same type. @@ -72,9 +74,10 @@ OnboardingConsentView( // Action to perform once the user has given their consent }, identifier: DocumentIdentifiers.second, // Specify an optional unique identifier for the consent form, helpful for distinguishing consent forms when storing. - exportConfiguration: .init(paperSize: .usLetter) // Configure the properties of the exported consent form + exportConfiguration: .init(paperSize: .usLetter), // Configure the properties of the exported consent form. + currentDateInSignature: false // Indicates if the consent signature should include the current date. ) - .onboardingIdentifier(DocumentIdentifiers.second) // Set an identifier for the view, to distinguish it from other views of the same type. + .onboardingIdentifier(DocumentIdentifiers.second), // Set an identifier for the view, to distinguish it from other views of the same type. ``` ## Topics diff --git a/Sources/SpeziOnboarding/SpeziOnboarding.docc/SpeziOnboarding.md b/Sources/SpeziOnboarding/SpeziOnboarding.docc/SpeziOnboarding.md index 7c2952b..f37de30 100644 --- a/Sources/SpeziOnboarding/SpeziOnboarding.docc/SpeziOnboarding.md +++ b/Sources/SpeziOnboarding/SpeziOnboarding.docc/SpeziOnboarding.md @@ -149,7 +149,8 @@ struct ConsentViewExample: View { action: { // Action to perform once the user has given their consent }, - exportConfiguration: .init(paperSize: .usLetter) // Configure the properties of the exported consent form + exportConfiguration: .init(paperSize: .usLetter), // Configure the properties of the exported consent form + currentDateInSignature: true // Indicates if the consent signature should include the current date.) ) } } diff --git a/Tests/UITests/TestApp/Views/OnboardingConsentMarkdownRenderingView.swift b/Tests/UITests/TestApp/Views/OnboardingConsentMarkdownRenderingView.swift index 14ecf9d..dca0687 100644 --- a/Tests/UITests/TestApp/Views/OnboardingConsentMarkdownRenderingView.swift +++ b/Tests/UITests/TestApp/Views/OnboardingConsentMarkdownRenderingView.swift @@ -66,7 +66,7 @@ struct OnboardingConsentMarkdownRenderingView: View { #if DEBUG #Preview { - var standard: OnboardingDataSource = .init() + let standard: OnboardingDataSource = .init() OnboardingStack(startAtStep: OnboardingConsentMarkdownRenderingView.self) { diff --git a/Tests/UITests/TestApp/Views/OnboardingConsentMarkdownTestView.swift b/Tests/UITests/TestApp/Views/OnboardingConsentMarkdownTestView.swift index 9331e7c..29bb3b2 100644 --- a/Tests/UITests/TestApp/Views/OnboardingConsentMarkdownTestView.swift +++ b/Tests/UITests/TestApp/Views/OnboardingConsentMarkdownTestView.swift @@ -29,7 +29,8 @@ struct OnboardingConsentMarkdownTestView: View { }, title: consentTitle.localized(), identifier: documentIdentifier, - exportConfiguration: .init(paperSize: .dinA4, includingTimestamp: true) + exportConfiguration: .init(paperSize: .dinA4, includingTimestamp: true), + currentDateInSignature: true ) } } From 8fd56a232f22c187f756a656930b964479e974a1 Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Wed, 23 Oct 2024 16:09:18 +0200 Subject: [PATCH 06/24] Allow to use SignatureView manually --- Sources/SpeziOnboarding/SignatureView.swift | 6 +++--- Sources/SpeziOnboarding/SignatureViewBackground.swift | 2 ++ 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/Sources/SpeziOnboarding/SignatureView.swift b/Sources/SpeziOnboarding/SignatureView.swift index 3cfbd8b..462fdc5 100644 --- a/Sources/SpeziOnboarding/SignatureView.swift +++ b/Sources/SpeziOnboarding/SignatureView.swift @@ -36,7 +36,7 @@ public struct SignatureView: View { #else @Binding private var signature: String #endif - private let name: PersonNameComponents + private let name: PersonNameComponents // TODO: allow to just specify a string! additional initializer for name components! private let lineOffset: CGFloat @@ -110,7 +110,7 @@ public struct SignatureView: View { /// - canvasSize: The size of the canvas as a Binding. /// - name: The name that is displayed under the signature line. /// - lineOffset: Defines the distance of the signature line from the bottom of the view. The default value is 30. - init( + public init( signature: Binding = .constant(PKDrawing()), isSigning: Binding = .constant(false), canvasSize: Binding = .constant(.zero), @@ -129,7 +129,7 @@ public struct SignatureView: View { /// - signature: A `Binding` containing the current text-based signature as a `String`. /// - name: The name that is displayed under the signature line. /// - lineOffset: Defines the distance of the signature line from the bottom of the view. The default value is 30. - init( + public init( signature: Binding = .constant(String()), name: PersonNameComponents = PersonNameComponents(), lineOffset: CGFloat = 30 diff --git a/Sources/SpeziOnboarding/SignatureViewBackground.swift b/Sources/SpeziOnboarding/SignatureViewBackground.swift index 79d072a..28cb9c7 100644 --- a/Sources/SpeziOnboarding/SignatureViewBackground.swift +++ b/Sources/SpeziOnboarding/SignatureViewBackground.swift @@ -13,6 +13,8 @@ import SwiftUI struct SignatureViewBackground: View { private let name: PersonNameComponents private let lineOffset: CGFloat + // TODO: add ability to show date! + #if !os(macOS) private let backgroundColor: UIColor #else From bb74e075d640bf108ec5f2a92d9fcac844b9ed52 Mon Sep 17 00:00:00 2001 From: Philipp Zagar Date: Wed, 22 Jan 2025 09:36:12 +0100 Subject: [PATCH 07/24] String-based name components --- Sources/SpeziOnboarding/SignatureView.swift | 44 ++++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) diff --git a/Sources/SpeziOnboarding/SignatureView.swift b/Sources/SpeziOnboarding/SignatureView.swift index 462fdc5..00fc067 100644 --- a/Sources/SpeziOnboarding/SignatureView.swift +++ b/Sources/SpeziOnboarding/SignatureView.swift @@ -36,7 +36,7 @@ public struct SignatureView: View { #else @Binding private var signature: String #endif - private let name: PersonNameComponents // TODO: allow to just specify a string! additional initializer for name components! + private let name: PersonNameComponents private let lineOffset: CGFloat @@ -123,6 +123,29 @@ public struct SignatureView: View { self.name = name self.lineOffset = lineOffset } + + /// Creates a new instance of an ``SignatureView`` with `String`-based name components. + /// - Parameters: + /// - signature: A `Binding` containing the current signature as an `PKDrawing`. + /// - isSigning: A `Binding` indicating if the user is currently signing. + /// - canvasSize: The size of the canvas as a Binding. + /// - givenName: The given name that is displayed under the signature line. + /// - familyName: The family name that is displayed under the signature line. + /// - lineOffset: Defines the distance of the signature line from the bottom of the view. The default value is 30. + public init( + signature: Binding = .constant(PKDrawing()), + isSigning: Binding = .constant(false), + canvasSize: Binding = .constant(.zero), + givenName: String, + familyName: String, + lineOffset: CGFloat = 30 + ) { + self._signature = signature + self._isSigning = isSigning + self._canvasSize = canvasSize + self.name = .init(givenName: givenName, familyName: familyName) + self.lineOffset = lineOffset + } #else /// Creates a new instance of an ``SignatureView``. /// - Parameters: @@ -138,6 +161,23 @@ public struct SignatureView: View { self.name = name self.lineOffset = lineOffset } + + /// Creates a new instance of an ``SignatureView`` with `String`-based name components. + /// - Parameters: + /// - signature: A `Binding` containing the current text-based signature as a `String`. + /// - givenName: The given name that is displayed under the signature line. + /// - familyName: The family name that is displayed under the signature line. + /// - lineOffset: Defines the distance of the signature line from the bottom of the view. The default value is 30. + public init( + signature: Binding = .constant(String()), + givenName: String, + familyName: String, + lineOffset: CGFloat = 30 + ) { + self._signature = signature + self.name = .init(givenName: givenName, familyName: familyName) + self.lineOffset = lineOffset + } #endif } @@ -148,6 +188,8 @@ struct SignatureView_Previews: PreviewProvider { SignatureView() SignatureView(name: PersonNameComponents(givenName: "Leland", familyName: "Stanford")) + + SignatureView(givenName: "Leland", familyName: "Stanford") } } #endif From a6dfc2cd8828e3983936d5ad7d83aef63256b261 Mon Sep 17 00:00:00 2001 From: Philipp Zagar Date: Wed, 22 Jan 2025 10:01:35 +0100 Subject: [PATCH 08/24] Signature date --- Sources/SpeziOnboarding/SignatureView.swift | 98 +++++++++++++++---- .../SignatureViewBackground.swift | 67 +++++++++++-- 2 files changed, 138 insertions(+), 27 deletions(-) diff --git a/Sources/SpeziOnboarding/SignatureView.swift b/Sources/SpeziOnboarding/SignatureView.swift index 00fc067..ce446e8 100644 --- a/Sources/SpeziOnboarding/SignatureView.swift +++ b/Sources/SpeziOnboarding/SignatureView.swift @@ -37,14 +37,20 @@ public struct SignatureView: View { @Binding private var signature: String #endif private let name: PersonNameComponents + private let date: Date? + private let dateFormatter: DateFormatter private let lineOffset: CGFloat public var body: some View { VStack { ZStack(alignment: .bottomLeading) { - SignatureViewBackground(name: name, lineOffset: lineOffset) - + SignatureViewBackground( + name: name, + formattedDate: { if let date { dateFormatter.string(from: date) } else { nil } }(), + lineOffset: lineOffset + ) + #if !os(macOS) CanvasView(drawing: $signature, isDrawing: $isSigning, showToolPicker: .constant(false)) .accessibilityLabel(Text("SIGNATURE_FIELD", bundle: .module)) @@ -115,12 +121,20 @@ public struct SignatureView: View { isSigning: Binding = .constant(false), canvasSize: Binding = .constant(.zero), name: PersonNameComponents = PersonNameComponents(), + date: Date? = nil, + dateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateStyle = .short + return formatter + }(), lineOffset: CGFloat = 30 ) { self._signature = signature self._isSigning = isSigning self._canvasSize = canvasSize self.name = name + self.date = date + self.dateFormatter = dateFormatter self.lineOffset = lineOffset } @@ -138,13 +152,23 @@ public struct SignatureView: View { canvasSize: Binding = .constant(.zero), givenName: String, familyName: String, + date: Date? = nil, + dateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateStyle = .short + return formatter + }(), lineOffset: CGFloat = 30 ) { - self._signature = signature - self._isSigning = isSigning - self._canvasSize = canvasSize - self.name = .init(givenName: givenName, familyName: familyName) - self.lineOffset = lineOffset + self.init( + signature: signature, + isSigning: isSigning, + canvasSize: canvasSize, + name: .init(givenName: givenName, familyName: familyName), + date: date, + dateFormatter: dateFormatter, + lineOffset: lineOffset + ) } #else /// Creates a new instance of an ``SignatureView``. @@ -155,10 +179,18 @@ public struct SignatureView: View { public init( signature: Binding = .constant(String()), name: PersonNameComponents = PersonNameComponents(), + date: Date? = nil, + dateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateStyle = .short + return formatter + }(), lineOffset: CGFloat = 30 ) { self._signature = signature - self.name = name + self.name = names + self.date = date + self.dateFormatter = dateFormatter self.lineOffset = lineOffset } @@ -172,24 +204,54 @@ public struct SignatureView: View { signature: Binding = .constant(String()), givenName: String, familyName: String, + date: Date? = nil, + dateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateStyle = .short + return formatter + }(), lineOffset: CGFloat = 30 ) { - self._signature = signature - self.name = .init(givenName: givenName, familyName: familyName) - self.lineOffset = lineOffset + self.init( + signature: signature, + name: .init(givenName: givenName, familyName: familyName), + date: date, + dateFormatter: dateFormatter, + lineOffset: lineOffset + ) } #endif } - #if DEBUG -struct SignatureView_Previews: PreviewProvider { - static var previews: some View { - SignatureView() +#Preview("Base Signature View") { + SignatureView() +} - SignatureView(name: PersonNameComponents(givenName: "Leland", familyName: "Stanford")) +#Preview("Including PersonNameComponents") { + SignatureView(name: PersonNameComponents(givenName: "Leland", familyName: "Stanford")) +} - SignatureView(givenName: "Leland", familyName: "Stanford") - } +#Preview("Including String-based names") { + SignatureView(givenName: "Leland", familyName: "Stanford") +} + +#Preview("Including PersonNameComponents and Date") { + SignatureView( + name: PersonNameComponents(givenName: "Leland", familyName: "Stanford"), + date: .now + ) +} + +#Preview("Including PersonNameComponents and Date with custom format") { + SignatureView( + name: PersonNameComponents(givenName: "Leland", familyName: "Stanford"), + date: .now, + dateFormatter: { + let formatter = DateFormatter() + formatter.dateStyle = .medium + return formatter + }() + ) } #endif diff --git a/Sources/SpeziOnboarding/SignatureViewBackground.swift b/Sources/SpeziOnboarding/SignatureViewBackground.swift index 28cb9c7..02e817f 100644 --- a/Sources/SpeziOnboarding/SignatureViewBackground.swift +++ b/Sources/SpeziOnboarding/SignatureViewBackground.swift @@ -12,8 +12,8 @@ import SwiftUI /// The `SignatureViewBackground` provides the background view for the ``SignatureView`` including the name and the signature line. struct SignatureViewBackground: View { private let name: PersonNameComponents + private let formattedDate: String? private let lineOffset: CGFloat - // TODO: add ability to show date! #if !os(macOS) private let backgroundColor: UIColor @@ -40,41 +40,90 @@ struct SignatureViewBackground: View { .padding(.bottom, lineOffset + 2) .accessibilityHidden(true) - let name = name.formatted(.name(style: .long)) - Text(name) - .font(.subheadline) - .foregroundColor(.secondary) - .padding(.horizontal, 20) - .padding(.bottom, lineOffset - 18) - .accessibilityLabel(Text("SIGNATURE_NAME \(name)", bundle: .module)) - .accessibilityHidden(name.isEmpty) + HStack { + let name = name.formatted(.name(style: .long)) + Text(name) + .font(.subheadline) + .foregroundColor(.secondary) + .padding(.horizontal, 20) + .padding(.bottom, lineOffset - 18) + .accessibilityLabel(Text("SIGNATURE_NAME \(name)", bundle: .module)) + .accessibilityHidden(name.isEmpty) + + Spacer() + + if let formattedDate { + Text(formattedDate) + .font(.subheadline) + .foregroundColor(.secondary) + .padding(.horizontal, 20) + .padding(.bottom, lineOffset - 18) + .accessibilityLabel(Text("SIGNATURE_DATE \(formattedDate)", bundle: .module)) + .accessibilityHidden(formattedDate.isEmpty) + } + } } /// Creates a new instance of an `SignatureViewBackground`. /// - Parameters: /// - name: The name that is displayed under the signature line. + /// - formattedDate: The formatted date that is displayed under the signature line. /// - lineOffset: Defines the distance of the signature line from the bottom of the view. The default value is 30. /// - backgroundColor: The color of the background of the signature canvas. #if !os(macOS) init( name: PersonNameComponents = PersonNameComponents(), + formattedDate: String? = nil, lineOffset: CGFloat = 30, backgroundColor: UIColor = .secondarySystemBackground ) { self.name = name + self.formattedDate = formattedDate self.lineOffset = lineOffset self.backgroundColor = backgroundColor } #else init( name: PersonNameComponents = PersonNameComponents(), + formattedDate: String? = nil, lineOffset: CGFloat = 30, backgroundColor: NSColor = .secondarySystemFill ) { self.name = name + self.formattedDate = formattedDate self.lineOffset = lineOffset self.backgroundColor = backgroundColor } #endif } + + +#if DEBUG +#Preview("No signature date") { + ZStack(alignment: .bottomLeading) { + SignatureViewBackground( + name: .init(givenName: "Leland", familyName: "Stanford"), + formattedDate: nil + ) + } + .frame(height: 120) +} + +#Preview("Including signature date") { + let dateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateStyle = .short + return formatter + }() + + + ZStack(alignment: .bottomLeading) { + SignatureViewBackground( + name: .init(givenName: "Leland", familyName: "Stanford"), + formattedDate: dateFormatter.string(from: .now) + ) + } + .frame(height: 120) +} +#endif From eab5689770c606ac7619fb6a86874076288603c8 Mon Sep 17 00:00:00 2001 From: Philipp Zagar Date: Wed, 22 Jan 2025 10:29:25 +0100 Subject: [PATCH 09/24] Refactorings to Preview --- .../ConsentView/ConsentDocument.swift | 28 +++++++-------- .../OnboardingConsentView.swift | 22 ++++++------ .../IllegalOnboardingStepView.swift | 6 ++-- .../OnboardingInformationView.swift | 35 ++++--------------- Sources/SpeziOnboarding/OnboardingView.swift | 22 +++++++++++- .../Resources/Localizable.xcstrings | 3 ++ Sources/SpeziOnboarding/SignatureView.swift | 9 ++--- .../UITests/TestApp/OnboardingTestsView.swift | 6 ++-- .../Views/HelperViews/CustomToggleView.swift | 12 +++---- .../Views/OnboardingConditionalTestView.swift | 10 +++--- ...boardingConsentMarkdownRenderingView.swift | 17 +++++---- .../OnboardingConsentMarkdownTestView.swift | 10 +++--- .../Views/OnboardingCustomTestView1.swift | 10 +++--- .../Views/OnboardingCustomTestView2.swift | 10 +++--- ...OnboardingIdentifiableTestViewCustom.swift | 10 +++--- .../Views/OnboardingSequentialTestView.swift | 10 +++--- .../Views/OnboardingStartTestView.swift | 10 +++--- .../OnboardingTestViewNotIdentifiable.swift | 10 +++--- .../Views/OnboardingWelcomeTestView.swift | 10 +++--- 19 files changed, 111 insertions(+), 139 deletions(-) diff --git a/Sources/SpeziOnboarding/ConsentView/ConsentDocument.swift b/Sources/SpeziOnboarding/ConsentView/ConsentDocument.swift index 2148080..490733e 100644 --- a/Sources/SpeziOnboarding/ConsentView/ConsentDocument.swift +++ b/Sources/SpeziOnboarding/ConsentView/ConsentDocument.swift @@ -232,21 +232,19 @@ public struct ConsentDocument: View { #if DEBUG -struct ConsentDocument_Previews: PreviewProvider { - @State private static var viewState: ConsentViewState = .base(.idle) - - - static var previews: some View { - NavigationStack { - ConsentDocument( - markdown: { - Data("This is a *markdown* **example**".utf8) - }, - viewState: $viewState - ) - .navigationTitle(Text(verbatim: "Consent")) - .padding() - } +#Preview { + @Previewable @State var viewState: ConsentViewState = .base(.idle) + + + NavigationStack { + ConsentDocument( + markdown: { + Data("This is a *markdown* **example**".utf8) + }, + viewState: $viewState + ) + .navigationTitle(Text(verbatim: "Consent")) + .padding() } } #endif diff --git a/Sources/SpeziOnboarding/OnboardingConsentView.swift b/Sources/SpeziOnboarding/OnboardingConsentView.swift index e94c090..f161f0a 100644 --- a/Sources/SpeziOnboarding/OnboardingConsentView.swift +++ b/Sources/SpeziOnboarding/OnboardingConsentView.swift @@ -231,18 +231,16 @@ public struct OnboardingConsentView: View { #if DEBUG -struct OnboardingConsentView_Previews: PreviewProvider { - @State private static var viewState: ConsentViewState = .base(.idle) - - - static var previews: some View { - NavigationStack { - OnboardingConsentView(markdown: { - Data("This is a *markdown* **example**".utf8) - }, action: { - print("Next") - }) - } +#Preview { + @Previewable @State var viewState: ConsentViewState = .base(.idle) + + + NavigationStack { + OnboardingConsentView(markdown: { + Data("This is a *markdown* **example**".utf8) + }, action: { + print("Next") + }) } } #endif diff --git a/Sources/SpeziOnboarding/OnboardingFlow/IllegalOnboardingStepView.swift b/Sources/SpeziOnboarding/OnboardingFlow/IllegalOnboardingStepView.swift index 95cd615..b9823dc 100644 --- a/Sources/SpeziOnboarding/OnboardingFlow/IllegalOnboardingStepView.swift +++ b/Sources/SpeziOnboarding/OnboardingFlow/IllegalOnboardingStepView.swift @@ -20,9 +20,7 @@ struct IllegalOnboardingStepView: View { #if DEBUG -struct IllegalOnboardingStepView_Previews: PreviewProvider { - static var previews: some View { - IllegalOnboardingStepView() - } +#Preview { + IllegalOnboardingStepView() } #endif diff --git a/Sources/SpeziOnboarding/OnboardingInformationView.swift b/Sources/SpeziOnboarding/OnboardingInformationView.swift index 5dadd34..de36626 100644 --- a/Sources/SpeziOnboarding/OnboardingInformationView.swift +++ b/Sources/SpeziOnboarding/OnboardingInformationView.swift @@ -147,43 +147,20 @@ public struct OnboardingInformationView: View { #if DEBUG -struct AreasView_Previews: PreviewProvider { - static var mock: [OnboardingInformationView.Content] { - [ +#Preview { + OnboardingInformationView( + areas: [ OnboardingInformationView.Content( icon: Image(systemName: "pc"), title: String("PC"), - description: String("This is a PC. And we can write a lot about PCs in a section like this. A very long text!") + description: String("This is a PC.") ), OnboardingInformationView.Content( icon: Image(systemName: "desktopcomputer"), title: String("Mac"), - description: String("This is an iMac") - ), - OnboardingInformationView.Content( - icon: Image(systemName: "laptopcomputer"), - title: String("MacBook"), - description: String("This is a MacBook") + description: String("This is an iMac.") ) ] - } - - - static var previews: some View { - OnboardingInformationView( - areas: [ - OnboardingInformationView.Content( - icon: Image(systemName: "pc"), - title: String("PC"), - description: String("This is a PC.") - ), - OnboardingInformationView.Content( - icon: Image(systemName: "desktopcomputer"), - title: String("Mac"), - description: String("This is an iMac.") - ) - ] - ) - } + ) } #endif diff --git a/Sources/SpeziOnboarding/OnboardingView.swift b/Sources/SpeziOnboarding/OnboardingView.swift index 7e79ee0..dd6da78 100644 --- a/Sources/SpeziOnboarding/OnboardingView.swift +++ b/Sources/SpeziOnboarding/OnboardingView.swift @@ -210,10 +210,30 @@ public struct OnboardingView = .constant(PKDrawing()), isSigning: Binding = .constant(false), canvasSize: Binding = .constant(.zero), - givenName: String, - familyName: String, + givenName: String = "", + familyName: String = "", date: Date? = nil, dateFormatter: DateFormatter = { let formatter = DateFormatter() @@ -202,8 +202,8 @@ public struct SignatureView: View { /// - lineOffset: Defines the distance of the signature line from the bottom of the view. The default value is 30. public init( signature: Binding = .constant(String()), - givenName: String, - familyName: String, + givenName: String = "", + familyName: String = "", date: Date? = nil, dateFormatter: DateFormatter = { let formatter = DateFormatter() @@ -223,6 +223,7 @@ public struct SignatureView: View { #endif } + #if DEBUG #Preview("Base Signature View") { SignatureView() diff --git a/Tests/UITests/TestApp/OnboardingTestsView.swift b/Tests/UITests/TestApp/OnboardingTestsView.swift index 114b6c8..f828697 100644 --- a/Tests/UITests/TestApp/OnboardingTestsView.swift +++ b/Tests/UITests/TestApp/OnboardingTestsView.swift @@ -59,9 +59,7 @@ struct OnboardingTestsView: View { #if DEBUG -struct OnboardingTestsView_Previews: PreviewProvider { - static var previews: some View { - OnboardingTestsView(onboardingFlowComplete: .constant(false)) - } +#Preview { + OnboardingTestsView(onboardingFlowComplete: .constant(false)) } #endif diff --git a/Tests/UITests/TestApp/Views/HelperViews/CustomToggleView.swift b/Tests/UITests/TestApp/Views/HelperViews/CustomToggleView.swift index 8c7225e..37fd19c 100644 --- a/Tests/UITests/TestApp/Views/HelperViews/CustomToggleView.swift +++ b/Tests/UITests/TestApp/Views/HelperViews/CustomToggleView.swift @@ -31,12 +31,10 @@ struct CustomToggleView: View { #if DEBUG -struct CustomToggleView_Previews: PreviewProvider { - static var previews: some View { - CustomToggleView( - text: "Test toggle", - condition: .constant(false) - ) - } +#Preview { + CustomToggleView( + text: "Test toggle", + condition: .constant(false) + ) } #endif diff --git a/Tests/UITests/TestApp/Views/OnboardingConditionalTestView.swift b/Tests/UITests/TestApp/Views/OnboardingConditionalTestView.swift index 9684758..579d4fe 100644 --- a/Tests/UITests/TestApp/Views/OnboardingConditionalTestView.swift +++ b/Tests/UITests/TestApp/Views/OnboardingConditionalTestView.swift @@ -30,12 +30,10 @@ struct OnboardingConditionalTestView: View { #if DEBUG -struct OnboardingConditionalTestView_Previews: PreviewProvider { - static var previews: some View { - OnboardingStack(startAtStep: OnboardingConditionalTestView.self) { - for onboardingView in OnboardingFlow.previewSimulatorViews { - onboardingView - } +#Preview { + OnboardingStack(startAtStep: OnboardingConditionalTestView.self) { + for onboardingView in OnboardingFlow.previewSimulatorViews { + onboardingView } } } diff --git a/Tests/UITests/TestApp/Views/OnboardingConsentMarkdownRenderingView.swift b/Tests/UITests/TestApp/Views/OnboardingConsentMarkdownRenderingView.swift index 7f2f160..14ecf9d 100644 --- a/Tests/UITests/TestApp/Views/OnboardingConsentMarkdownRenderingView.swift +++ b/Tests/UITests/TestApp/Views/OnboardingConsentMarkdownRenderingView.swift @@ -65,15 +65,14 @@ struct OnboardingConsentMarkdownRenderingView: View { #if DEBUG -struct OnboardingConsentMarkdownRenderingView_Previews: PreviewProvider { - static var standard: OnboardingDataSource = .init() - - static var previews: some View { - OnboardingStack(startAtStep: OnboardingConsentMarkdownRenderingView.self) { - for onboardingView in OnboardingFlow.previewSimulatorViews { - onboardingView - .environment(standard) - } +#Preview { + var standard: OnboardingDataSource = .init() + + + OnboardingStack(startAtStep: OnboardingConsentMarkdownRenderingView.self) { + for onboardingView in OnboardingFlow.previewSimulatorViews { + onboardingView + .environment(standard) } } } diff --git a/Tests/UITests/TestApp/Views/OnboardingConsentMarkdownTestView.swift b/Tests/UITests/TestApp/Views/OnboardingConsentMarkdownTestView.swift index a8c31aa..9331e7c 100644 --- a/Tests/UITests/TestApp/Views/OnboardingConsentMarkdownTestView.swift +++ b/Tests/UITests/TestApp/Views/OnboardingConsentMarkdownTestView.swift @@ -36,12 +36,10 @@ struct OnboardingConsentMarkdownTestView: View { #if DEBUG -struct OnboardingFirstConsentMarkdownTestView_Previews: PreviewProvider { - static var previews: some View { - OnboardingStack(startAtStep: OnboardingConsentMarkdownTestView.self) { - for onboardingView in OnboardingFlow.previewSimulatorViews { - onboardingView - } +#Preview { + OnboardingStack(startAtStep: OnboardingConsentMarkdownTestView.self) { + for onboardingView in OnboardingFlow.previewSimulatorViews { + onboardingView } } } diff --git a/Tests/UITests/TestApp/Views/OnboardingCustomTestView1.swift b/Tests/UITests/TestApp/Views/OnboardingCustomTestView1.swift index 7da2321..5020c5e 100644 --- a/Tests/UITests/TestApp/Views/OnboardingCustomTestView1.swift +++ b/Tests/UITests/TestApp/Views/OnboardingCustomTestView1.swift @@ -30,12 +30,10 @@ struct OnboardingCustomTestView1: View { #if DEBUG -struct OnboardingCustomTestView1_Previews: PreviewProvider { - static var previews: some View { - OnboardingStack(startAtStep: OnboardingCustomTestView1.self) { - for onboardingView in OnboardingFlow.previewSimulatorViews { - onboardingView - } +#Preview { + OnboardingStack(startAtStep: OnboardingCustomTestView1.self) { + for onboardingView in OnboardingFlow.previewSimulatorViews { + onboardingView } } } diff --git a/Tests/UITests/TestApp/Views/OnboardingCustomTestView2.swift b/Tests/UITests/TestApp/Views/OnboardingCustomTestView2.swift index 332c219..4d563ab 100644 --- a/Tests/UITests/TestApp/Views/OnboardingCustomTestView2.swift +++ b/Tests/UITests/TestApp/Views/OnboardingCustomTestView2.swift @@ -27,12 +27,10 @@ struct OnboardingCustomTestView2: View { } #if DEBUG -struct OnboardingCustomTestView2_Previews: PreviewProvider { - static var previews: some View { - OnboardingStack(startAtStep: OnboardingCustomTestView2.self) { - for onboardingView in OnboardingFlow.previewSimulatorViews { - onboardingView - } +#Preview { + OnboardingStack(startAtStep: OnboardingCustomTestView2.self) { + for onboardingView in OnboardingFlow.previewSimulatorViews { + onboardingView } } } diff --git a/Tests/UITests/TestApp/Views/OnboardingIdentifiableTestViewCustom.swift b/Tests/UITests/TestApp/Views/OnboardingIdentifiableTestViewCustom.swift index d36aa36..4cd28e9 100644 --- a/Tests/UITests/TestApp/Views/OnboardingIdentifiableTestViewCustom.swift +++ b/Tests/UITests/TestApp/Views/OnboardingIdentifiableTestViewCustom.swift @@ -33,12 +33,10 @@ struct OnboardingIdentifiableTestViewCustom: View, Identifiable { } #if DEBUG -struct OnboardingIdentifiableTestViewCustomView_Previews: PreviewProvider { - static var previews: some View { - OnboardingStack(startAtStep: OnboardingIdentifiableTestViewCustom.self) { - for onboardingView in OnboardingFlow.previewSimulatorViews { - onboardingView - } +#Preview { + OnboardingStack(startAtStep: OnboardingIdentifiableTestViewCustom.self) { + for onboardingView in OnboardingFlow.previewSimulatorViews { + onboardingView } } } diff --git a/Tests/UITests/TestApp/Views/OnboardingSequentialTestView.swift b/Tests/UITests/TestApp/Views/OnboardingSequentialTestView.swift index 028d607..6b25889 100644 --- a/Tests/UITests/TestApp/Views/OnboardingSequentialTestView.swift +++ b/Tests/UITests/TestApp/Views/OnboardingSequentialTestView.swift @@ -37,12 +37,10 @@ struct OnboardingSequentialTestView: View { #if DEBUG -struct OnboardingSequentialTestView_Previews: PreviewProvider { - static var previews: some View { - OnboardingStack(startAtStep: OnboardingSequentialTestView.self) { - for onboardingView in OnboardingFlow.previewSimulatorViews { - onboardingView - } +#Preview { + OnboardingStack(startAtStep: OnboardingSequentialTestView.self) { + for onboardingView in OnboardingFlow.previewSimulatorViews { + onboardingView } } } diff --git a/Tests/UITests/TestApp/Views/OnboardingStartTestView.swift b/Tests/UITests/TestApp/Views/OnboardingStartTestView.swift index f8e283a..01a60f4 100644 --- a/Tests/UITests/TestApp/Views/OnboardingStartTestView.swift +++ b/Tests/UITests/TestApp/Views/OnboardingStartTestView.swift @@ -76,12 +76,10 @@ struct OnboardingStartTestView: View { #if DEBUG -struct OnboardingStartTestView_Previews: PreviewProvider { - static var previews: some View { - OnboardingStack(startAtStep: OnboardingStartTestView.self) { - for onboardingView in OnboardingFlow.previewSimulatorViews { - onboardingView - } +#Preview { + OnboardingStack(startAtStep: OnboardingStartTestView.self) { + for onboardingView in OnboardingFlow.previewSimulatorViews { + onboardingView } } } diff --git a/Tests/UITests/TestApp/Views/OnboardingTestViewNotIdentifiable.swift b/Tests/UITests/TestApp/Views/OnboardingTestViewNotIdentifiable.swift index 7b9e244..be06dc5 100644 --- a/Tests/UITests/TestApp/Views/OnboardingTestViewNotIdentifiable.swift +++ b/Tests/UITests/TestApp/Views/OnboardingTestViewNotIdentifiable.swift @@ -29,12 +29,10 @@ struct OnboardingTestViewNotIdentifiable: View { } #if DEBUG -struct OnboardingTestViewNotIdentifiable_Previews: PreviewProvider { - static var previews: some View { - OnboardingStack(startAtStep: OnboardingTestViewNotIdentifiable.self) { - for onboardingView in OnboardingFlow.previewSimulatorViews { - onboardingView - } +#Preview { + OnboardingStack(startAtStep: OnboardingTestViewNotIdentifiable.self) { + for onboardingView in OnboardingFlow.previewSimulatorViews { + onboardingView } } } diff --git a/Tests/UITests/TestApp/Views/OnboardingWelcomeTestView.swift b/Tests/UITests/TestApp/Views/OnboardingWelcomeTestView.swift index 72011c9..afefb5f 100644 --- a/Tests/UITests/TestApp/Views/OnboardingWelcomeTestView.swift +++ b/Tests/UITests/TestApp/Views/OnboardingWelcomeTestView.swift @@ -39,12 +39,10 @@ struct OnboardingWelcomeTestView: View { #if DEBUG -struct OnboardingWelcomeTestView_Previews: PreviewProvider { - static var previews: some View { - OnboardingStack(startAtStep: OnboardingWelcomeTestView.self) { - for onboardingView in OnboardingFlow.previewSimulatorViews { - onboardingView - } +#Preview { + OnboardingStack(startAtStep: OnboardingWelcomeTestView.self) { + for onboardingView in OnboardingFlow.previewSimulatorViews { + onboardingView } } } From 9b6e6ec5f79c8b3c36abbb5c5d62e7eb73ecb684 Mon Sep 17 00:00:00 2001 From: Philipp Zagar Date: Wed, 22 Jan 2025 23:52:18 +0100 Subject: [PATCH 10/24] Some general improvements --- Package.swift | 6 ----- .../ConsentView/ConsentDocument+Export.swift | 17 ++++++++++--- .../ConsentView/ConsentDocument.swift | 25 +++++++------------ .../ConsentView/ConsentDocumentExport.swift | 11 ++------ .../ConsentDocumentExportError.swift | 6 +++-- .../ConsentView/ConsentViewState.swift | 16 +++++++++++- .../OnboardingConsentView.swift | 14 +++++------ .../OnboardingDataSource.swift | 9 ++++--- .../UITests/UITests.xcodeproj/project.pbxproj | 9 +++---- 9 files changed, 60 insertions(+), 53 deletions(-) diff --git a/Package.swift b/Package.swift index 0d94515..d851cc0 100644 --- a/Package.swift +++ b/Package.swift @@ -39,9 +39,6 @@ let package = Package( .product(name: "OrderedCollections", package: "swift-collections"), .product(name: "TPPDF", package: "TPPDF") ], - swiftSettings: [ - .enableUpcomingFeature("ExistentialAny") - ], plugins: [] + swiftLintPlugin() ), .testTarget( @@ -52,9 +49,6 @@ let package = Package( resources: [ .process("Resources/") ], - swiftSettings: [ - .enableUpcomingFeature("ExistentialAny") - ], plugins: [] + swiftLintPlugin() ) ] diff --git a/Sources/SpeziOnboarding/ConsentView/ConsentDocument+Export.swift b/Sources/SpeziOnboarding/ConsentView/ConsentDocument+Export.swift index fbe3832..c93cf7d 100644 --- a/Sources/SpeziOnboarding/ConsentView/ConsentDocument+Export.swift +++ b/Sources/SpeziOnboarding/ConsentView/ConsentDocument+Export.swift @@ -49,15 +49,26 @@ extension ConsentDocument { /// /// - Returns: The exported consent form in PDF format as a PDFKit `PDFDocument` @MainActor - func export() async throws -> PDFKit.PDFDocument { + func export() async throws -> ConsentDocumentExport { + var documentExport = ConsentDocumentExport( + markdown: self.markdown, + exportConfiguration: self.exportConfiguration, + documentIdentifier: self.documentIdentifier + ) + documentExport.signature = signature documentExport.formattedSignatureDate = formattedConsentSignatureDate documentExport.name = name + #if !os(macOS) documentExport.signatureImage = blackInkSignatureImage - return try await documentExport.export() + let pdf = try await documentExport.export() #else - return try await documentExport.export() + let pdf = try await documentExport.export() #endif + + documentExport.cachedPDF = pdf + + return documentExport } } diff --git a/Sources/SpeziOnboarding/ConsentView/ConsentDocument.swift b/Sources/SpeziOnboarding/ConsentView/ConsentDocument.swift index 0496d23..f2f5d8e 100644 --- a/Sources/SpeziOnboarding/ConsentView/ConsentDocument.swift +++ b/Sources/SpeziOnboarding/ConsentView/ConsentDocument.swift @@ -49,7 +49,9 @@ public struct ConsentDocument: View { private let consentSignatureDate: Date? private let consentSignatureDateFormatter: DateFormatter - let documentExport: ConsentDocumentExport + let markdown: () async -> Data + let exportConfiguration: ExportConfiguration + let documentIdentifier: String @Environment(\.colorScheme) var colorScheme @State var name = PersonNameComponents() @@ -147,7 +149,7 @@ public struct ConsentDocument: View { public var body: some View { VStack { - MarkdownView(asyncMarkdown: documentExport.asyncMarkdown, state: $viewState.base) + MarkdownView(asyncMarkdown: markdown, state: $viewState.base) Spacer() Group { nameView @@ -166,11 +168,9 @@ public struct ConsentDocument: View { if case .export = viewState { Task { do { - /// Stores the finished PDF in the Spezi `Standard`. + // Stores the finished PDF in the Spezi `Standard`. let exportedConsent = try await export() - - documentExport.cachedPDF = exportedConsent - viewState = .exported(document: exportedConsent, export: documentExport) + viewState = .exported(document: exportedConsent) } catch { // In case of error, go back to previous state. viewState = .base(.error(AnyLocalizedError(error: error))) @@ -239,23 +239,16 @@ public struct ConsentDocument: View { return formatter }() ) { + self.markdown = markdown self._viewState = viewState self.givenNameTitle = givenNameTitle self.givenNamePlaceholder = givenNamePlaceholder self.familyNameTitle = familyNameTitle self.familyNamePlaceholder = familyNamePlaceholder + self.exportConfiguration = exportConfiguration + self.documentIdentifier = documentIdentifier self.consentSignatureDate = consentSignatureDate self.consentSignatureDateFormatter = consentSignatureDateFormatter - - self.documentExport = ConsentDocumentExport( - markdown: markdown, - exportConfiguration: exportConfiguration, - documentIdentifier: documentIdentifier - ) - // Set initial values for the name and signature. - // These will be updated once the name and signature change. - self.documentExport.name = name - self.documentExport.signature = signature } } diff --git a/Sources/SpeziOnboarding/ConsentView/ConsentDocumentExport.swift b/Sources/SpeziOnboarding/ConsentView/ConsentDocumentExport.swift index 340fd47..bdf007a 100644 --- a/Sources/SpeziOnboarding/ConsentView/ConsentDocumentExport.swift +++ b/Sources/SpeziOnboarding/ConsentView/ConsentDocumentExport.swift @@ -12,7 +12,7 @@ import SwiftUI /// A type representing an exported `ConsentDocument`. It holds the exported `PDFDocument` and the corresponding document identifier String. -public final class ConsentDocumentExport: Equatable { +public struct ConsentDocumentExport { /// Provides default values for fields related to the `ConsentDocumentExport`. public enum Defaults { /// Default value for a document identifier. @@ -41,6 +41,7 @@ public final class ConsentDocumentExport: Equatable { /// The signature of the signee as string. public var signature = String() #endif + /// Formatted data displayed in the signature caption. public var formattedSignatureDate: String? @@ -63,14 +64,6 @@ public final class ConsentDocumentExport: Equatable { } - public static func == (lhs: ConsentDocumentExport, rhs: ConsentDocumentExport) -> Bool { - lhs.documentIdentifier == rhs.documentIdentifier && - lhs.name == rhs.name && - lhs.signature == rhs.signature && - lhs.cachedPDF == rhs.cachedPDF - } - - /// Consume the exported `PDFDocument` from a `ConsentDocument`. /// /// This method consumes the `ConsentDocumentExport/cachedPDF` by retrieving the exported `PDFDocument`. diff --git a/Sources/SpeziOnboarding/ConsentView/ConsentDocumentExportError.swift b/Sources/SpeziOnboarding/ConsentView/ConsentDocumentExportError.swift index 74083ed..c38a6ff 100644 --- a/Sources/SpeziOnboarding/ConsentView/ConsentDocumentExportError.swift +++ b/Sources/SpeziOnboarding/ConsentView/ConsentDocumentExportError.swift @@ -8,10 +8,12 @@ import Foundation -// Error that can occur if PDF export fails in ``ConsentDocumentExport``. + +/// Error that can occur if PDF export fails in ``ConsentDocumentExport``. enum ConsentDocumentExportError: LocalizedError { case invalidPdfData(String) - + + var errorDescription: String? { switch self { case .invalidPdfData: diff --git a/Sources/SpeziOnboarding/ConsentView/ConsentViewState.swift b/Sources/SpeziOnboarding/ConsentView/ConsentViewState.swift index 2ccab50..9b9b044 100644 --- a/Sources/SpeziOnboarding/ConsentView/ConsentViewState.swift +++ b/Sources/SpeziOnboarding/ConsentView/ConsentViewState.swift @@ -34,7 +34,21 @@ public enum ConsentViewState: Equatable { /// ``ConsentDocument`` has been successfully exported. The rendered `PDFDocument` can be found as the associated value of the state. /// /// The export procedure (resulting in the ``ConsentViewState/exported(document:export:)`` state) can be triggered via setting the ``ConsentViewState/export`` state of the ``ConsentDocument`` . - case exported(document: PDFDocument, export: ConsentDocumentExport) + case exported(document: ConsentDocumentExport) /// The `storing` state indicates that the ``ConsentDocument`` is currently being stored to the Standard. case storing + + + public static func == (lhs: ConsentViewState, rhs: ConsentViewState) -> Bool { + switch (lhs, rhs) { + case let (.base(lhsValue), .base(rhsValue)): lhsValue == rhsValue + case (.namesEntered, .namesEntered): true + case (.signing, .signing): true + case (.signed, .signed): true + case (.export, .export): true + case (.exported, .exported): true + case (.storing, .storing): true + default: false + } + } } diff --git a/Sources/SpeziOnboarding/OnboardingConsentView.swift b/Sources/SpeziOnboarding/OnboardingConsentView.swift index a5e1c94..c4d1a7b 100644 --- a/Sources/SpeziOnboarding/OnboardingConsentView.swift +++ b/Sources/SpeziOnboarding/OnboardingConsentView.swift @@ -115,13 +115,13 @@ public struct OnboardingConsentView: View { .scrollDisabled($viewState.signing.wrappedValue) .navigationBarBackButtonHidden(backButtonHidden) .onChange(of: viewState) { - if case .exported(_, let export) = viewState { + if case .exported(let consentExport) = viewState { if !willShowShareSheet { viewState = .storing Task { do { - /// Stores the finished PDF in the Spezi `Standard`. - try await onboardingDataSource.store(export) + // Stores the finished consent export in the Spezi `Standard`. + try await onboardingDataSource.store(consentExport) await action() viewState = .base(.idle) @@ -166,9 +166,9 @@ public struct OnboardingConsentView: View { } } .sheet(isPresented: $showShareSheet) { - if case .exported(let document, _) = viewState { + if case .exported(let exportedConsent) = viewState { #if !os(macOS) - ShareSheet(sharedItem: document) + ShareSheet(sharedItem: exportedConsent.consumePDF()) .presentationDetents([.medium]) .task { willShowShareSheet = false @@ -182,8 +182,8 @@ public struct OnboardingConsentView: View { #if os(macOS) .onChange(of: showShareSheet) { _, isPresented in if isPresented, - case .exported(let exportedConsentDocumented, _) = viewState { - let shareSheet = ShareSheet(sharedItem: exportedConsentDocumented) + case .exported(let exportedConsentDocument) = viewState { + let shareSheet = ShareSheet(sharedItem: exportedConsentDocument.consumePDF()) shareSheet.show() showShareSheet = false diff --git a/Sources/SpeziOnboarding/OnboardingDataSource.swift b/Sources/SpeziOnboarding/OnboardingDataSource.swift index 9cc9fb4..951d1ae 100644 --- a/Sources/SpeziOnboarding/OnboardingDataSource.swift +++ b/Sources/SpeziOnboarding/OnboardingDataSource.swift @@ -12,7 +12,7 @@ import SwiftUI private protocol DeprecationSuppression { - func storeInLegacyConstraint(for standard: any Standard, _ consent: sending ConsentDocumentExport) async + func storeInLegacyConstraint(for standard: any Standard, _ consent: consuming sending ConsentDocumentExport) async } @@ -87,7 +87,10 @@ public final class OnboardingDataSource: Module, EnvironmentAccessible, @uncheck /// - Parameters: /// - consent: The exported consent form represented as `ConsentDocumentExport` that should be added. /// - identifier: The document identifier for the exported consent document. - public func store(_ consent: sending ConsentDocumentExport, identifier: String = ConsentDocumentExport.Defaults.documentIdentifier) async throws { + public func store( + _ consent: consuming sending ConsentDocumentExport, + identifier: String = ConsentDocumentExport.Defaults.documentIdentifier + ) async throws { if let consentConstraint = standard as? any ConsentConstraint { try await consentConstraint.store(consent: consent) } else { @@ -101,7 +104,7 @@ public final class OnboardingDataSource: Module, EnvironmentAccessible, @uncheck extension OnboardingDataSource: DeprecationSuppression { @available(*, deprecated, message: "Suppress deprecation warning.") - func storeInLegacyConstraint(for standard: any Standard, _ consent: sending ConsentDocumentExport) async { + func storeInLegacyConstraint(for standard: any Standard, _ consent: consuming sending ConsentDocumentExport) async { if let onboardingConstraint = standard as? any OnboardingConstraint { await onboardingConstraint.store(consent: consent.consumePDF()) } else { diff --git a/Tests/UITests/UITests.xcodeproj/project.pbxproj b/Tests/UITests/UITests.xcodeproj/project.pbxproj index 46b4b5f..f86a262 100644 --- a/Tests/UITests/UITests.xcodeproj/project.pbxproj +++ b/Tests/UITests/UITests.xcodeproj/project.pbxproj @@ -406,6 +406,7 @@ SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_STRICT_CONCURRENCY = complete; + SWIFT_VERSION = 6.0; XROS_DEPLOYMENT_TARGET = 1.0; }; name = Debug; @@ -464,6 +465,7 @@ SWIFT_COMPILATION_MODE = wholemodule; SWIFT_OPTIMIZATION_LEVEL = "-O"; SWIFT_STRICT_CONCURRENCY = complete; + SWIFT_VERSION = 6.0; VALIDATE_PRODUCT = YES; XROS_DEPLOYMENT_TARGET = 1.0; }; @@ -499,7 +501,6 @@ SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = YES; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_STRICT_CONCURRENCY = complete; - SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2,7"; }; name = Debug; @@ -533,7 +534,6 @@ SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = YES; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_STRICT_CONCURRENCY = complete; - SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2,7"; }; name = Release; @@ -555,7 +555,6 @@ SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = YES; SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2,7"; TEST_TARGET_NAME = TestApp; }; @@ -577,7 +576,6 @@ SUPPORTS_MACCATALYST = NO; SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = YES; SWIFT_EMIT_LOC_STRINGS = NO; - SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2,7"; TEST_TARGET_NAME = TestApp; }; @@ -644,6 +642,7 @@ SWIFT_ACTIVE_COMPILATION_CONDITIONS = TEST; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_STRICT_CONCURRENCY = complete; + SWIFT_VERSION = 6.0; XROS_DEPLOYMENT_TARGET = 1.0; }; name = Test; @@ -677,7 +676,6 @@ SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = YES; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_STRICT_CONCURRENCY = complete; - SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2,7"; }; name = Test; @@ -699,7 +697,6 @@ SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = YES; SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2,7"; TEST_TARGET_NAME = TestApp; }; From 288813480bba32d41fe35614f286875fd905147d Mon Sep 17 00:00:00 2001 From: Philipp Zagar Date: Wed, 22 Jan 2025 23:56:27 +0100 Subject: [PATCH 11/24] . --- .../TestApp/Views/OnboardingConsentMarkdownRenderingView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/UITests/TestApp/Views/OnboardingConsentMarkdownRenderingView.swift b/Tests/UITests/TestApp/Views/OnboardingConsentMarkdownRenderingView.swift index 14ecf9d..dca0687 100644 --- a/Tests/UITests/TestApp/Views/OnboardingConsentMarkdownRenderingView.swift +++ b/Tests/UITests/TestApp/Views/OnboardingConsentMarkdownRenderingView.swift @@ -66,7 +66,7 @@ struct OnboardingConsentMarkdownRenderingView: View { #if DEBUG #Preview { - var standard: OnboardingDataSource = .init() + let standard: OnboardingDataSource = .init() OnboardingStack(startAtStep: OnboardingConsentMarkdownRenderingView.self) { From c619a07e65564a759b0bfc0678ab031f6e578f81 Mon Sep 17 00:00:00 2001 From: Philipp Zagar Date: Thu, 23 Jan 2025 00:00:28 +0100 Subject: [PATCH 12/24] doc update --- .../SpeziOnboarding/ConsentView/ConsentDocumentExport.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/SpeziOnboarding/ConsentView/ConsentDocumentExport.swift b/Sources/SpeziOnboarding/ConsentView/ConsentDocumentExport.swift index bdf007a..d7bd025 100644 --- a/Sources/SpeziOnboarding/ConsentView/ConsentDocumentExport.swift +++ b/Sources/SpeziOnboarding/ConsentView/ConsentDocumentExport.swift @@ -71,10 +71,10 @@ public struct ConsentDocumentExport { /// - Note: For now, we always require a PDF to be cached to create a `ConsentDocumentExport`. In the future, we might change this to lazy-PDF loading. public consuming func consumePDF() -> sending PDFDocument { // Accessing `cachedPDF` via `take()` ensures single consumption of the `PDFDocument` by transferring ownership - // from the enclosing class and leaving `nil` behind after the access. Though `ConsentDocumentExport` is a reference + // from the enclosing struct and leaving `nil` behind after the access. Though `ConsentDocumentExport` is a copyable // type, this manual ownership model guarantees the PDF is only used once, enabling safe cross-concurrency transfer. // The explicit `sending` return type reinforces transfer semantics, while `take()` enforces single-access at runtime. - // This pattern provides compiler-verifiable safety for the `PDFDocument` transfer despite the class's reference semantics. + // This pattern provides compiler-verifiable safety for the `PDFDocument` transfer despite the struct's reference semantics. // // See similar discussion: https://forums.swift.org/t/swift-6-consume-optional-noncopyable-property-and-transfer-sending-it-out/72414/3 nonisolated(unsafe) let cachedPDF = cachedPDF.take() ?? .init() From 06edcf399205536f8220d9092ca45f98bac9ce09 Mon Sep 17 00:00:00 2001 From: Philipp Zagar Date: Thu, 23 Jan 2025 00:01:59 +0100 Subject: [PATCH 13/24] test fix --- Tests/SpeziOnboardingTests/SpeziOnboardingTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/SpeziOnboardingTests/SpeziOnboardingTests.swift b/Tests/SpeziOnboardingTests/SpeziOnboardingTests.swift index 3252dd6..a8556ed 100644 --- a/Tests/SpeziOnboardingTests/SpeziOnboardingTests.swift +++ b/Tests/SpeziOnboardingTests/SpeziOnboardingTests.swift @@ -56,7 +56,7 @@ final class SpeziOnboardingTests: XCTestCase { includingTimestamp: false ) - let documentExport = ConsentDocumentExport( + var documentExport = ConsentDocumentExport( markdown: markdownData, exportConfiguration: exportConfiguration, documentIdentifier: ConsentDocumentExport.Defaults.documentIdentifier From ded0a33726a31337976fc0104c79c28409a5ac1f Mon Sep 17 00:00:00 2001 From: Philipp Zagar Date: Fri, 24 Jan 2025 00:41:33 +0100 Subject: [PATCH 14/24] Lots of cleanup --- Package.swift | 6 + .../SpeziOnboarding/ConsentConstraint.swift | 4 +- .../ConsentView/ConsentDocument+Error.swift | 30 --- .../ConsentView/ConsentDocument+Export.swift | 51 +++-- .../ConsentView/ConsentDocument.swift | 18 +- .../ConsentDocumentExport+Export.swift | 188 ------------------ .../ConsentView/ConsentDocumentExport.swift | 83 -------- .../ConsentDocumentExportError.swift | 29 --- ...tExportRepresentation+Configuration.swift} | 26 ++- ...cumentExportRepresentation+Defaults.swift} | 2 +- ...tDocumentExportRepresentation+Render.swift | 141 +++++++++++++ .../ConsentDocumentExportRepresentation.swift | 89 +++++++++ .../ConsentView/ConsentViewState.swift | 4 +- .../ExportConfiguration+PDFPageFormat.swift | 24 --- .../OnboardingConsentView+Error.swift | 39 ++++ .../OnboardingConsentView.swift | 31 ++- .../OnboardingConstraint.swift | 37 ---- .../OnboardingDataSource.swift | 72 +------ .../Resources/Localizable.xcstrings | 13 +- .../SpeziOnboardingTests.swift | 43 ++-- Tests/UITests/TestApp/ConsentStoreError.swift | 25 --- .../UITests/TestApp/DocumentIdentifiers.swift | 16 -- Tests/UITests/TestApp/ExampleStandard.swift | 30 +-- .../CustomToggleView.swift | 0 .../UITests/UITests.xcodeproj/project.pbxproj | 14 +- 25 files changed, 410 insertions(+), 605 deletions(-) delete mode 100644 Sources/SpeziOnboarding/ConsentView/ConsentDocument+Error.swift delete mode 100644 Sources/SpeziOnboarding/ConsentView/ConsentDocumentExport+Export.swift delete mode 100644 Sources/SpeziOnboarding/ConsentView/ConsentDocumentExport.swift delete mode 100644 Sources/SpeziOnboarding/ConsentView/ConsentDocumentExportError.swift rename Sources/SpeziOnboarding/ConsentView/{ConsentDocument+ExportConfiguration.swift => ConsentDocumentExportRepresentation+Configuration.swift} (88%) rename Sources/SpeziOnboarding/ConsentView/{ExportConfiguration+Defaults.swift => ConsentDocumentExportRepresentation+Defaults.swift} (98%) create mode 100644 Sources/SpeziOnboarding/ConsentView/ConsentDocumentExportRepresentation+Render.swift create mode 100644 Sources/SpeziOnboarding/ConsentView/ConsentDocumentExportRepresentation.swift delete mode 100644 Sources/SpeziOnboarding/ConsentView/ExportConfiguration+PDFPageFormat.swift create mode 100644 Sources/SpeziOnboarding/OnboardingConsentView+Error.swift delete mode 100644 Sources/SpeziOnboarding/OnboardingConstraint.swift delete mode 100644 Tests/UITests/TestApp/ConsentStoreError.swift delete mode 100644 Tests/UITests/TestApp/DocumentIdentifiers.swift rename Tests/UITests/TestApp/Views/{HelperViews => Helpers}/CustomToggleView.swift (100%) diff --git a/Package.swift b/Package.swift index d851cc0..0d94515 100644 --- a/Package.swift +++ b/Package.swift @@ -39,6 +39,9 @@ let package = Package( .product(name: "OrderedCollections", package: "swift-collections"), .product(name: "TPPDF", package: "TPPDF") ], + swiftSettings: [ + .enableUpcomingFeature("ExistentialAny") + ], plugins: [] + swiftLintPlugin() ), .testTarget( @@ -49,6 +52,9 @@ let package = Package( resources: [ .process("Resources/") ], + swiftSettings: [ + .enableUpcomingFeature("ExistentialAny") + ], plugins: [] + swiftLintPlugin() ) ] diff --git a/Sources/SpeziOnboarding/ConsentConstraint.swift b/Sources/SpeziOnboarding/ConsentConstraint.swift index b2f9b3b..fb15758 100644 --- a/Sources/SpeziOnboarding/ConsentConstraint.swift +++ b/Sources/SpeziOnboarding/ConsentConstraint.swift @@ -16,6 +16,6 @@ public protocol ConsentConstraint: Standard { /// Adds a new exported consent form represented as `PDFDocument` to the `Standard` conforming to ``ConsentConstraint``. /// /// - Parameters: - /// - consent: The exported consent form represented as `ConsentDocumentExport` that should be added. - func store(consent: consuming sending ConsentDocumentExport) async throws + /// - consent: The exported consent form represented as `ConsentDocumentExportRepresentation` that should be added. + func store(consent: consuming sending ConsentDocumentExportRepresentation) async throws } diff --git a/Sources/SpeziOnboarding/ConsentView/ConsentDocument+Error.swift b/Sources/SpeziOnboarding/ConsentView/ConsentDocument+Error.swift deleted file mode 100644 index 15498d5..0000000 --- a/Sources/SpeziOnboarding/ConsentView/ConsentDocument+Error.swift +++ /dev/null @@ -1,30 +0,0 @@ -// -// This source file is part of the Stanford Spezi open-source project -// -// SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) -// -// SPDX-License-Identifier: MIT -// - -import Foundation - - -extension ConsentDocument { - /// Represents possible errors occurring within the ``ConsentDocument`` during the export of the signed consent form. - public enum Error: LocalizedError { - case memoryAllocationError - - - public var errorDescription: String? { - LocalizedStringResource("CONSENT_EXPORT_ERROR_DESCRIPTION", bundle: .atURL(from: .module)).localizedString() - } - - public var recoverySuggestion: String? { - LocalizedStringResource("CONSENT_EXPORT_ERROR_RECOVERY_SUGGESTION", bundle: .atURL(from: .module)).localizedString() - } - - public var failureReason: String? { - LocalizedStringResource("CONSENT_EXPORT_ERROR_FAILURE_REASON", bundle: .atURL(from: .module)).localizedString() - } - } -} diff --git a/Sources/SpeziOnboarding/ConsentView/ConsentDocument+Export.swift b/Sources/SpeziOnboarding/ConsentView/ConsentDocument+Export.swift index c93cf7d..b1e77a1 100644 --- a/Sources/SpeziOnboarding/ConsentView/ConsentDocument+Export.swift +++ b/Sources/SpeziOnboarding/ConsentView/ConsentDocument+Export.swift @@ -43,32 +43,31 @@ extension ConsentDocument { } #endif - /// Exports the signed consent form as a `PDFKit.PDFDocument`. - /// The PDF generated by TPPDF and then converted to a TPDFKit.PDFDocument. - /// Renders the `PDFDocument` according to the specified ``ConsentDocument/ExportConfiguration``. + /// Creates the export representation of the ``ConsentDocument`` including all necessary consent. /// - /// - Returns: The exported consent form in PDF format as a PDFKit `PDFDocument` - @MainActor - func export() async throws -> ConsentDocumentExport { - var documentExport = ConsentDocumentExport( - markdown: self.markdown, - exportConfiguration: self.exportConfiguration, - documentIdentifier: self.documentIdentifier - ) - - documentExport.signature = signature - documentExport.formattedSignatureDate = formattedConsentSignatureDate - documentExport.name = name - - #if !os(macOS) - documentExport.signatureImage = blackInkSignatureImage - let pdf = try await documentExport.export() - #else - let pdf = try await documentExport.export() - #endif - - documentExport.cachedPDF = pdf - - return documentExport + /// - Returns: The exported consent representation as a ``ConsentDocumentExportRepresentation`` + var exportRepresentation: ConsentDocumentExportRepresentation { + get async { + #if !os(macOS) + .init( + markdown: await self.markdown(), + signature: self.signature, + signatureImage: self.blackInkSignatureImage, + name: self.name, + formattedSignatureDate: self.formattedConsentSignatureDate, + documentIdentifier: self.documentIdentifier, + configuration: self.exportConfiguration + ) + #else + .init( + markdown: await self.markdown(), + signature: self.signature, + name: self.name, + formattedSignatureDate: self.formattedConsentSignatureDate, + documentIdentifier: self.documentIdentifier, + configuration: self.exportConfiguration + ) + #endif + } } } diff --git a/Sources/SpeziOnboarding/ConsentView/ConsentDocument.swift b/Sources/SpeziOnboarding/ConsentView/ConsentDocument.swift index 5b32324..2169bf0 100644 --- a/Sources/SpeziOnboarding/ConsentView/ConsentDocument.swift +++ b/Sources/SpeziOnboarding/ConsentView/ConsentDocument.swift @@ -50,7 +50,7 @@ public struct ConsentDocument: View { private let consentSignatureDateFormatter: DateFormatter let markdown: () async -> Data - let exportConfiguration: ExportConfiguration + let exportConfiguration: ConsentDocumentExportRepresentation.Configuration let documentIdentifier: String @Environment(\.colorScheme) var colorScheme @@ -167,14 +167,10 @@ public struct ConsentDocument: View { .onChange(of: viewState) { if case .export = viewState { Task { - do { - // Stores the finished PDF in the Spezi `Standard`. - let exportedConsent = try await export() - viewState = .exported(document: exportedConsent) - } catch { - // In case of error, go back to previous state. - viewState = .base(.error(AnyLocalizedError(error: error))) - } + // Captures the current state of the document and transforms it to the `ConsentDocumentExportRepresentation` + viewState = .exported( + representation: await self.exportRepresentation + ) } } else if case .base(let baseViewState) = viewState, case .idle = baseViewState { @@ -230,8 +226,8 @@ public struct ConsentDocument: View { givenNamePlaceholder: LocalizedStringResource = LocalizationDefaults.givenNamePlaceholder, familyNameTitle: LocalizedStringResource = LocalizationDefaults.familyNameTitle, familyNamePlaceholder: LocalizedStringResource = LocalizationDefaults.familyNamePlaceholder, - exportConfiguration: ExportConfiguration = .init(), - documentIdentifier: String = ConsentDocumentExport.Defaults.documentIdentifier, + exportConfiguration: ConsentDocumentExportRepresentation.Configuration = .init(), + documentIdentifier: String = ConsentDocumentExportRepresentation.Defaults.documentIdentifier, consentSignatureDate: Date? = nil, consentSignatureDateFormatter: DateFormatter = { let formatter = DateFormatter() diff --git a/Sources/SpeziOnboarding/ConsentView/ConsentDocumentExport+Export.swift b/Sources/SpeziOnboarding/ConsentView/ConsentDocumentExport+Export.swift deleted file mode 100644 index 49b3e34..0000000 --- a/Sources/SpeziOnboarding/ConsentView/ConsentDocumentExport+Export.swift +++ /dev/null @@ -1,188 +0,0 @@ -// -// This source file is part of the Stanford Spezi open-source project -// -// SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) -// -// SPDX-License-Identifier: MIT -// - -import PDFKit -import PencilKit -import SwiftUI -import TPPDF - - -/// Extension of `ConsentDocumentExport` enabling the export of the signed consent page. -extension ConsentDocumentExport { - /// Generates a `PDFAttributedText` containing the timestamp of the time at which the PDF was exported. - /// - /// - Returns: A TPPDF `PDFAttributedText` representation of the export time stamp. - private func exportTimeStamp() -> PDFAttributedText { - let stampText = String(localized: "EXPORTED_TAG", bundle: .module) + ": " + - DateFormatter.localizedString(from: Date(), dateStyle: .medium, timeStyle: .short) + "\n\n\n\n" - - let attributedTitle = NSMutableAttributedString( - string: stampText, - attributes: [ - NSAttributedString.Key.font: exportConfiguration.fontSettings.headerExportTimeStampFont - ] - ) - - return PDFAttributedText(text: attributedTitle) - } - - /// Converts the header text (i.e., document title) to a PDFAttributedText, which can be - /// added to the exported PDFDocument. - /// - /// - Returns: A TPPDF `PDFAttributedText` representation of the document title. - private func exportHeader() -> PDFAttributedText { - let attributedTitle = NSMutableAttributedString( - string: exportConfiguration.consentTitle.localizedString() + "\n\n", - attributes: [ - NSAttributedString.Key.font: exportConfiguration.fontSettings.headerTitleFont - ] - ) - - return PDFAttributedText(text: attributedTitle) - } - - /// Converts the content (i.e., the markdown text) of the consent document to a PDFAttributedText, which can be - /// added to the exported PDFDocument. - /// - /// - Returns: A TPPDF `PDFAttributedText` representation of the document content. - @MainActor - private func exportDocumentContent() async -> PDFAttributedText { - let markdown = await asyncMarkdown() - var markdownString = (try? AttributedString( - markdown: markdown, - options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace) - )) ?? AttributedString(String(localized: "MARKDOWN_LOADING_ERROR", bundle: .module)) - - markdownString.font = exportConfiguration.fontSettings.documentContentFont - - return PDFAttributedText(text: NSAttributedString(markdownString)) - } - - /// Exports the signature to a `PDFGroup` which can be added to the exported PDFDocument. - /// The signature group will contain a prefix ("X"), the name of the signee as well as the signature image. - /// - /// - Returns: A TPPDF `PDFAttributedText` representation of the export time stamp. - @MainActor - private func exportSignature() -> PDFGroup { - let personName = name.formatted(.name(style: .long)) - - #if !os(macOS) - let group = PDFGroup( - allowsBreaks: false, - backgroundImage: PDFImage(image: signatureImage), - padding: EdgeInsets(top: 50, left: 50, bottom: 0, right: 100) - ) - let signaturePrefix = "X" - #else - // On macOS, we do not have a "drawn" signature, hence we do - // not set a backgroundImage for the PDFGroup. - // Instead, we render the person name. - let group = PDFGroup( - allowsBreaks: false, - padding: EdgeInsets(top: 50, left: 50, bottom: 0, right: 100) - ) - let signaturePrefix = "X " + signature - #endif - - group.set(font: exportConfiguration.fontSettings.signaturePrefixFont) - group.add(.left, text: signaturePrefix) - - group.addLineSeparator(style: PDFLineStyle(color: .black)) - - // Add person name and date as the caption below the signature line - // Sadly a quite complex table is required to have the caption within one line - let table = PDFTable(rows: 1, columns: 2) - table.widths = [0.5, 0.5] // Two equal-width columns for left and right alignment - table.margin = .zero - table.padding = 0 - table.style.outline = .none - - let cellStyle = PDFTableCellStyle( - colors: (Color.clear, Color.black), - borders: .none, - font: exportConfiguration.fontSettings.signatureCaptionFont - ) - - // Add person name to the left cell - table[0, 0] = PDFTableCell( - content: try? .init(content: personName), - alignment: .left, - style: cellStyle - ) - - // Add formatted date to the right cell - table[0, 1] = PDFTableCell( - content: try? .init(content: formattedSignatureDate ?? ""), - alignment: .right, - style: cellStyle - ) - - group.add(.left, table: table) - - return group - } - - - /// Creates a `PDFKit.PDFDocument` containing the header, content and signature from the exported `ConsentDocument`. - /// An export time stamp can be added optionally. - /// - /// - Parameters: - /// - header: The header of the document exported to a PDFAttributedText, e.g., using `exportHeader()`. - /// - pdfTextContent: The content of the document exported to a PDFAttributedText, e.g., using `exportDocumentContent()`. - /// - signatureFooter: The footer including the signature of the document, exported to a PDFGroup, e.g., using `exportSignature()`. - /// - exportTimeStamp: Optional parameter representing the timestamp of the time at which the document was exported. Can be created using `exportTimeStamp()` - /// - Returns: The exported consent form in PDF format as a PDFKit `PDFDocument` - @MainActor - private func createDocument( - header: PDFAttributedText, - pdfTextContent: PDFAttributedText, - signatureFooter: PDFGroup, - exportTimeStamp: PDFAttributedText? = nil - ) async throws -> PDFKit.PDFDocument { - let document = TPPDF.PDFDocument(format: exportConfiguration.pdfPageFormat) - - if let exportStamp = exportTimeStamp { - document.add(.contentRight, attributedTextObject: exportStamp) - } - - document.add(.contentCenter, attributedTextObject: header) - document.add(attributedTextObject: pdfTextContent) - document.add(group: signatureFooter) - - // Convert TPPDF.PDFDocument to PDFKit.PDFDocument - let generator = PDFGenerator(document: document) - - let data = try generator.generateData() - - guard let pdfDocument = PDFKit.PDFDocument(data: data) else { - throw ConsentDocumentExportError.invalidPdfData("PDF data not compatible with PDFDocument") - } - - return pdfDocument - } - - /// Exports the signed consent form as a `PDFKit.PDFDocument`. - /// The PDF generated by TPPDF and then converted to a TPDFKit.PDFDocument. - /// Renders the `PDFDocument` according to the specified ``ConsentDocument/ExportConfiguration``. - /// - /// - Returns: The exported consent form in PDF format as a PDFKit `PDFDocument` - @MainActor - public func export() async throws -> PDFKit.PDFDocument { - let exportTimeStamp = exportConfiguration.includingTimestamp ? exportTimeStamp() : nil - let header = exportHeader() - let pdfTextContent = await exportDocumentContent() - let signature = exportSignature() - - return try await createDocument( - header: header, - pdfTextContent: pdfTextContent, - signatureFooter: signature, - exportTimeStamp: exportTimeStamp - ) - } -} diff --git a/Sources/SpeziOnboarding/ConsentView/ConsentDocumentExport.swift b/Sources/SpeziOnboarding/ConsentView/ConsentDocumentExport.swift deleted file mode 100644 index d7bd025..0000000 --- a/Sources/SpeziOnboarding/ConsentView/ConsentDocumentExport.swift +++ /dev/null @@ -1,83 +0,0 @@ -// -// This source file is part of the Stanford Spezi open-source project -// -// SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) -// -// SPDX-License-Identifier: MIT -// - -@preconcurrency import PDFKit -import PencilKit -import SwiftUI - - -/// A type representing an exported `ConsentDocument`. It holds the exported `PDFDocument` and the corresponding document identifier String. -public struct ConsentDocumentExport { - /// Provides default values for fields related to the `ConsentDocumentExport`. - public enum Defaults { - /// Default value for a document identifier. - /// - /// This identifier will be used as default value if no identifier is provided. - public static let documentIdentifier = "ConsentDocument" - } - - let asyncMarkdown: () async -> Data - let exportConfiguration: ConsentDocument.ExportConfiguration - var cachedPDF: PDFDocument? - - /// An unique identifier for the exported `ConsentDocument`. - /// - /// Corresponds to the identifier which was passed when creating the `ConsentDocument` using an `OnboardingConsentView`. - public let documentIdentifier: String - - /// The name of the person which signed the document. - public var name = PersonNameComponents() - #if !os(macOS) - /// The signature of the signee as drawing. - public var signature = PKDrawing() - /// The image generated from the signature drawing. - public var signatureImage = UIImage() - #else - /// The signature of the signee as string. - public var signature = String() - #endif - /// Formatted data displayed in the signature caption. - public var formattedSignatureDate: String? - - - /// Creates a `ConsentDocumentExport`, which holds an exported PDF and the corresponding document identifier string. - /// - Parameters: - /// - markdown: The markdown text for the document, which is shown to the user. - /// - exportConfiguration: The `ExportConfiguration` holding the properties of the document. - /// - documentIdentifier: A unique String identifying the exported `ConsentDocument`. - /// - cachedPDF: A `PDFDocument` exported from a `ConsentDocument`. - init( - markdown: @escaping () async -> Data, - exportConfiguration: ConsentDocument.ExportConfiguration, - documentIdentifier: String, - cachedPDF: sending PDFDocument? = nil - ) { - self.asyncMarkdown = markdown - self.exportConfiguration = exportConfiguration - self.documentIdentifier = documentIdentifier - self.cachedPDF = cachedPDF - } - - - /// Consume the exported `PDFDocument` from a `ConsentDocument`. - /// - /// This method consumes the `ConsentDocumentExport/cachedPDF` by retrieving the exported `PDFDocument`. - /// - /// - Note: For now, we always require a PDF to be cached to create a `ConsentDocumentExport`. In the future, we might change this to lazy-PDF loading. - public consuming func consumePDF() -> sending PDFDocument { - // Accessing `cachedPDF` via `take()` ensures single consumption of the `PDFDocument` by transferring ownership - // from the enclosing struct and leaving `nil` behind after the access. Though `ConsentDocumentExport` is a copyable - // type, this manual ownership model guarantees the PDF is only used once, enabling safe cross-concurrency transfer. - // The explicit `sending` return type reinforces transfer semantics, while `take()` enforces single-access at runtime. - // This pattern provides compiler-verifiable safety for the `PDFDocument` transfer despite the struct's reference semantics. - // - // See similar discussion: https://forums.swift.org/t/swift-6-consume-optional-noncopyable-property-and-transfer-sending-it-out/72414/3 - nonisolated(unsafe) let cachedPDF = cachedPDF.take() ?? .init() - return cachedPDF - } -} diff --git a/Sources/SpeziOnboarding/ConsentView/ConsentDocumentExportError.swift b/Sources/SpeziOnboarding/ConsentView/ConsentDocumentExportError.swift deleted file mode 100644 index c38a6ff..0000000 --- a/Sources/SpeziOnboarding/ConsentView/ConsentDocumentExportError.swift +++ /dev/null @@ -1,29 +0,0 @@ -// -// This source file is part of the Stanford Spezi open-source project -// -// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) -// -// SPDX-License-Identifier: MIT -// - -import Foundation - - -/// Error that can occur if PDF export fails in ``ConsentDocumentExport``. -enum ConsentDocumentExportError: LocalizedError { - case invalidPdfData(String) - - - var errorDescription: String? { - switch self { - case .invalidPdfData: - String( - localized: "Unable to generate valid PDF document from PDF data.", - comment: """ - Error thrown if we generated a PDF document using TPPDF, - but were unable to convert the generated data into a PDFDocument. - """ - ) - } - } -} diff --git a/Sources/SpeziOnboarding/ConsentView/ConsentDocument+ExportConfiguration.swift b/Sources/SpeziOnboarding/ConsentView/ConsentDocumentExportRepresentation+Configuration.swift similarity index 88% rename from Sources/SpeziOnboarding/ConsentView/ConsentDocument+ExportConfiguration.swift rename to Sources/SpeziOnboarding/ConsentView/ConsentDocumentExportRepresentation+Configuration.swift index a0448d2..f5fe839 100644 --- a/Sources/SpeziOnboarding/ConsentView/ConsentDocument+ExportConfiguration.swift +++ b/Sources/SpeziOnboarding/ConsentView/ConsentDocumentExportRepresentation+Configuration.swift @@ -8,17 +8,18 @@ import Foundation import SwiftUI +import TPPDF -extension ConsentDocument { - /// The ``ExportConfiguration`` enables developers to define the properties of the exported consent form. - public struct ExportConfiguration: Sendable { +extension ConsentDocumentExportRepresentation { + /// The ``Configuration`` enables developers to define the properties of the exported consent form. + public struct Configuration: Equatable, Sendable { /// Represents common paper sizes with their dimensions. /// /// You can use the `dimensions` property to get the width and height of each paper size in points. /// /// - Note: The dimensions are calculated based on the standard DPI (dots per inch) of 72 for print. - public enum PaperSize: Sendable { + public enum PaperSize: Equatable, Sendable { /// Standard US Letter paper size. case usLetter /// Standard DIN A4 paper size. @@ -42,12 +43,20 @@ extension ConsentDocument { return (widthInInches * pointsPerInch, heightInInches * pointsPerInch) } } + + /// `TPPDF/PDFPageFormat` which corresponds to SpeziOnboarding's `PaperSize`. + var pdfPageFormat: PDFPageFormat { + switch self { + case .usLetter: .usLetter + case .dinA4: .a4 + } + } } #if !os(macOS) /// The ``FontSettings`` store configuration of the fonts used to render the exported /// consent document, i.e., fonts for the content, title and signature. - public struct FontSettings: Sendable { + public struct FontSettings: Equatable, Sendable { /// The font of the caption rendered below the signature line. public let signatureCaptionFont: UIFont /// The font of the prefix of the signature ("X" in most cases). @@ -85,7 +94,7 @@ extension ConsentDocument { #else /// The ``FontSettings`` store configuration of the fonts used to render the exported /// consent document, i.e., fonts for the content, title and signature. - public struct FontSettings: @unchecked Sendable { + public struct FontSettings: Equatable, @unchecked Sendable { /// The font of the caption rendered below the signature line. public let signatureCaptionFont: NSFont /// The font of the prefix of the signature ("X" in most cases). @@ -130,6 +139,7 @@ extension ConsentDocument { /// Creates an `ExportConfiguration` specifying the properties of the exported consent form. + /// /// - Parameters: /// - paperSize: The page size of the exported form represented by ``ConsentDocument/ExportConfiguration/PaperSize``. /// - consentTitle: The title of the exported consent form. @@ -137,9 +147,9 @@ extension ConsentDocument { /// - fontSettings: Font settings for the exported form. public init( paperSize: PaperSize = .usLetter, - consentTitle: LocalizedStringResource = LocalizationDefaults.exportedConsentFormTitle, + consentTitle: LocalizedStringResource = ConsentDocument.LocalizationDefaults.exportedConsentFormTitle, includingTimestamp: Bool = true, - fontSettings: FontSettings = ExportConfiguration.Defaults.defaultExportFontSettings + fontSettings: FontSettings = Configuration.Defaults.defaultExportFontSettings ) { self.paperSize = paperSize self.consentTitle = consentTitle diff --git a/Sources/SpeziOnboarding/ConsentView/ExportConfiguration+Defaults.swift b/Sources/SpeziOnboarding/ConsentView/ConsentDocumentExportRepresentation+Defaults.swift similarity index 98% rename from Sources/SpeziOnboarding/ConsentView/ExportConfiguration+Defaults.swift rename to Sources/SpeziOnboarding/ConsentView/ConsentDocumentExportRepresentation+Defaults.swift index a4cb11d..6705554 100644 --- a/Sources/SpeziOnboarding/ConsentView/ExportConfiguration+Defaults.swift +++ b/Sources/SpeziOnboarding/ConsentView/ConsentDocumentExportRepresentation+Defaults.swift @@ -10,7 +10,7 @@ import Foundation import SwiftUI -extension ConsentDocument.ExportConfiguration { +extension ConsentDocumentExportRepresentation.Configuration { /// Provides default values for fields related to the `ConsentDocumentExportConfiguration`. public enum Defaults { #if !os(macOS) diff --git a/Sources/SpeziOnboarding/ConsentView/ConsentDocumentExportRepresentation+Render.swift b/Sources/SpeziOnboarding/ConsentView/ConsentDocumentExportRepresentation+Render.swift new file mode 100644 index 0000000..02cc82a --- /dev/null +++ b/Sources/SpeziOnboarding/ConsentView/ConsentDocumentExportRepresentation+Render.swift @@ -0,0 +1,141 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import PDFKit +import PencilKit +import SwiftUI +import TPPDF + + +/// Extension of `ConsentDocumentExportRepresentation` enabling the export of the signed consent page. +extension ConsentDocumentExportRepresentation { + /// Generates a `PDFAttributedText` containing the timestamp of the time at which the PDF was exported. + private var renderedTimeStamp: PDFAttributedText? { + guard configuration.includingTimestamp else { + return nil + } + + let stampText = String(localized: "EXPORTED_TAG", bundle: .module) + ": " + + DateFormatter.localizedString(from: Date(), dateStyle: .medium, timeStyle: .short) + "\n\n\n\n" + + let attributedTitle = NSMutableAttributedString( + string: stampText, + attributes: [ + NSAttributedString.Key.font: configuration.fontSettings.headerExportTimeStampFont + ] + ) + + return PDFAttributedText(text: attributedTitle) + } + + /// Exports the header text (i.e., document title) as a `PDFAttributedText` + private var renderedHeader: PDFAttributedText { + let attributedTitle = NSMutableAttributedString( + string: configuration.consentTitle.localizedString() + "\n\n", + attributes: [ + NSAttributedString.Key.font: configuration.fontSettings.headerTitleFont + ] + ) + + return PDFAttributedText(text: attributedTitle) + } + + /// Renders the content (i.e., the markdown text) of the consent document as a `PDFAttributedText`. + private var renderedDocumentContent: PDFAttributedText { + var markdownString = (try? AttributedString( + markdown: markdown, + options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace) + )) ?? AttributedString(String(localized: "MARKDOWN_LOADING_ERROR", bundle: .module)) + + markdownString.font = configuration.fontSettings.documentContentFont + + return PDFAttributedText(text: NSAttributedString(markdownString)) + } + + /// Exports the signature as a `PDFGroup`, including the prefix ("X"), the name of the signee, the date, as well as the signature image. + private var renderedSignature: PDFGroup { + let personName = name.formatted(.name(style: .long)) + + #if !os(macOS) + let group = PDFGroup( + allowsBreaks: false, + backgroundImage: PDFImage(image: signatureImage), + padding: EdgeInsets(top: 50, left: 50, bottom: 0, right: 100) + ) + let signaturePrefix = "X" + #else + // On macOS, we do not have a "drawn" signature, hence we do + // not set a backgroundImage for the PDFGroup. + // Instead, we render the person name. + let group = PDFGroup( + allowsBreaks: false, + padding: EdgeInsets(top: 50, left: 50, bottom: 0, right: 100) + ) + let signaturePrefix = "X " + signature + #endif + + group.set(font: configuration.fontSettings.signaturePrefixFont) + group.add(.left, text: signaturePrefix) + + group.addLineSeparator(style: PDFLineStyle(color: .black)) + + // Add person name and date as the caption below the signature line + // Sadly a quite complex table is required to have the caption within one line + let table = PDFTable(rows: 1, columns: 2) + table.widths = [0.5, 0.5] // Two equal-width columns for left and right alignment + table.margin = .zero + table.padding = 0 + table.style.outline = .none + + let cellStyle = PDFTableCellStyle( + colors: (Color.clear, Color.black), + borders: .none, + font: configuration.fontSettings.signatureCaptionFont + ) + + // Add person name to the left cell + table[0, 0] = PDFTableCell( + content: try? .init(content: personName), + alignment: .left, + style: cellStyle + ) + + // Add formatted date to the right cell + table[0, 1] = PDFTableCell( + content: try? .init(content: formattedSignatureDate ?? ""), + alignment: .right, + style: cellStyle + ) + + group.add(.left, table: table) + + return group + } + + + /// Render the signed consent form in PDF format as a `PDFKit.PDFDocument`. + public func render() throws -> PDFKit.PDFDocument { + let document = TPPDF.PDFDocument(format: configuration.paperSize.pdfPageFormat) + + if let renderedTimeStamp { + document.add(.contentRight, attributedTextObject: renderedTimeStamp) + } + document.add(.contentCenter, attributedTextObject: renderedHeader) + document.add(attributedTextObject: renderedDocumentContent) + document.add(group: renderedSignature) + + // Convert `TPPDF.PDFDocument` to `PDFKit.PDFDocument` + let pdfData = try PDFGenerator(document: document).generateData() + + guard let pdfDocument = PDFKit.PDFDocument(data: pdfData) else { + preconditionFailure("Rendered PDF data from TPPDF could not be converted to PDFKit.PDFDocument.") // never happens + } + + return pdfDocument + } +} diff --git a/Sources/SpeziOnboarding/ConsentView/ConsentDocumentExportRepresentation.swift b/Sources/SpeziOnboarding/ConsentView/ConsentDocumentExportRepresentation.swift new file mode 100644 index 0000000..a73abc8 --- /dev/null +++ b/Sources/SpeziOnboarding/ConsentView/ConsentDocumentExportRepresentation.swift @@ -0,0 +1,89 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +@preconcurrency import PDFKit +import PencilKit +import SwiftUI + + +/// A type representing an exported `ConsentDocument`. It holds the exported `PDFDocument` and the corresponding document identifier String. +public struct ConsentDocumentExportRepresentation: Equatable { + /// Provides default values for fields related to the `ConsentDocumentExportRepresentation`. + public enum Defaults { + /// Default value for a document identifier. + /// + /// This identifier will be used as default value if no identifier is provided. + public static let documentIdentifier = "ConsentDocument" + } + + + /// An unique identifier for the exported `ConsentDocument`. + /// + /// Corresponds to the identifier which was passed when creating the `ConsentDocument` using an `OnboardingConsentView`. + public let documentIdentifier: String + /// Export configuration of the document. + let configuration: Configuration + + let markdown: Data + #if !os(macOS) + let signature: PKDrawing + let signatureImage: UIImage + #else + let signature: String + #endif + let name: PersonNameComponents + let formattedSignatureDate: String? + + + #if !os(macOS) + // TODO: Docs + /// Creates a `ConsentDocumentExportRepresentation`, which holds an exported PDF and the corresponding document identifier string. + /// - Parameters: + /// - markdown: The markdown text for the document, which is shown to the user. + /// - exportConfiguration: The `ExportConfiguration` holding the properties of the document. + /// - documentIdentifier: A unique String identifying the exported `ConsentDocument`. + init( + markdown: Data, + signature: PKDrawing, + signatureImage: UIImage, + name: PersonNameComponents, + formattedSignatureDate: String?, + documentIdentifier: String, + configuration: Configuration + ) { + self.markdown = markdown + self.name = name + self.signature = signature + self.signatureImage = signatureImage + self.formattedSignatureDate = formattedSignatureDate + self.documentIdentifier = documentIdentifier + self.configuration = configuration + } + #else + /// Creates a `ConsentDocumentExportRepresentation`, which holds an exported PDF and the corresponding document identifier string. + /// - Parameters: + /// - markdown: The markdown text for the document, which is shown to the user. + /// - exportConfiguration: The `ExportConfiguration` holding the properties of the document. + /// - documentIdentifier: A unique String identifying the exported `ConsentDocument`. + init( + markdown: Data, + signature: String, + name: PersonNameComponents, + formattedSignatureDate: String?, + documentIdentifier: String, + configuration: Configuration + ) { + self.markdown = markdown + self.name = name + self.signature = signature + self.formattedSignatureDate = formattedSignatureDate + self.documentIdentifier = documentIdentifier + self.configuration = configuration + } + #endif +} diff --git a/Sources/SpeziOnboarding/ConsentView/ConsentViewState.swift b/Sources/SpeziOnboarding/ConsentView/ConsentViewState.swift index 9b9b044..7e050ef 100644 --- a/Sources/SpeziOnboarding/ConsentView/ConsentViewState.swift +++ b/Sources/SpeziOnboarding/ConsentView/ConsentViewState.swift @@ -34,7 +34,7 @@ public enum ConsentViewState: Equatable { /// ``ConsentDocument`` has been successfully exported. The rendered `PDFDocument` can be found as the associated value of the state. /// /// The export procedure (resulting in the ``ConsentViewState/exported(document:export:)`` state) can be triggered via setting the ``ConsentViewState/export`` state of the ``ConsentDocument`` . - case exported(document: ConsentDocumentExport) + case exported(representation: ConsentDocumentExportRepresentation) /// The `storing` state indicates that the ``ConsentDocument`` is currently being stored to the Standard. case storing @@ -46,7 +46,7 @@ public enum ConsentViewState: Equatable { case (.signing, .signing): true case (.signed, .signed): true case (.export, .export): true - case (.exported, .exported): true + case let (.exported(lhsValue), .exported(rhsValue)): lhsValue == rhsValue case (.storing, .storing): true default: false } diff --git a/Sources/SpeziOnboarding/ConsentView/ExportConfiguration+PDFPageFormat.swift b/Sources/SpeziOnboarding/ConsentView/ExportConfiguration+PDFPageFormat.swift deleted file mode 100644 index 3ecaea6..0000000 --- a/Sources/SpeziOnboarding/ConsentView/ExportConfiguration+PDFPageFormat.swift +++ /dev/null @@ -1,24 +0,0 @@ -// -// This source file is part of the Stanford Spezi open-source project -// -// SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) -// -// SPDX-License-Identifier: MIT -// - -import Foundation -import TPPDF - - -/// The ``ExportConfiguration`` enables developers to define the properties of the exported consent form. -extension ConsentDocument.ExportConfiguration { - /// `TPPDF.PDFPageFormat` which corresponds to SpeziOnboarding's `ExportConfiguration.PaperSize`. - var pdfPageFormat: PDFPageFormat { - switch paperSize { - case .dinA4: - return PDFPageFormat.a4 - case .usLetter: - return PDFPageFormat.usLetter - } - } -} diff --git a/Sources/SpeziOnboarding/OnboardingConsentView+Error.swift b/Sources/SpeziOnboarding/OnboardingConsentView+Error.swift new file mode 100644 index 0000000..2439427 --- /dev/null +++ b/Sources/SpeziOnboarding/OnboardingConsentView+Error.swift @@ -0,0 +1,39 @@ +// +// This source file is part of the Stanford Spezi open source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import Foundation + + +extension OnboardingConsentView { + enum Error: LocalizedError { + /// Indicates that the local model file is not found. + case consentExportError + + + public var errorDescription: String? { + switch self { + case .consentExportError: + String(localized: LocalizedStringResource("Consent document could not be exported.", bundle: .atURL(from: .module))) + } + } + + public var recoverySuggestion: String? { + switch self { + case .consentExportError: + String(localized: LocalizedStringResource("Please try exporting the consent document again.", bundle: .atURL(from: .module))) + } + } + + public var failureReason: String? { + switch self { + case .consentExportError: + String(localized: LocalizedStringResource("The PDF generation from the consent document failed. ", bundle: .atURL(from: .module))) + } + } + } +} diff --git a/Sources/SpeziOnboarding/OnboardingConsentView.swift b/Sources/SpeziOnboarding/OnboardingConsentView.swift index c4d1a7b..d51f4aa 100644 --- a/Sources/SpeziOnboarding/OnboardingConsentView.swift +++ b/Sources/SpeziOnboarding/OnboardingConsentView.swift @@ -11,6 +11,7 @@ import AppKit #endif import Foundation +import PDFKit import SpeziViews import SwiftUI @@ -63,7 +64,7 @@ public struct OnboardingConsentView: View { private let action: () async -> Void private let title: LocalizedStringResource? private let identifier: String - private let exportConfiguration: ConsentDocument.ExportConfiguration + private let exportConfiguration: ConsentDocumentExportRepresentation.Configuration private let currentDateInSignature: Bool private var backButtonHidden: Bool { viewState == .storing || (viewState == .export && !willShowShareSheet) @@ -120,7 +121,7 @@ public struct OnboardingConsentView: View { viewState = .storing Task { do { - // Stores the finished consent export in the Spezi `Standard`. + // Stores the finished consent export representation in the Spezi `Standard`. try await onboardingDataSource.store(consentExport) await action() @@ -167,13 +168,21 @@ public struct OnboardingConsentView: View { } .sheet(isPresented: $showShareSheet) { if case .exported(let exportedConsent) = viewState { - #if !os(macOS) - ShareSheet(sharedItem: exportedConsent.consumePDF()) - .presentationDetents([.medium]) - .task { - willShowShareSheet = false - } - #endif + if let consentPdf = try? exportedConsent.render() { + #if !os(macOS) + ShareSheet(sharedItem: consentPdf) + .presentationDetents([.medium]) + .task { + willShowShareSheet = false + } + #endif + } else { + ProgressView() + .padding() + .task { + viewState = .base(.error(Error.consentExportError)) + } + } } else { ProgressView() .padding() @@ -222,8 +231,8 @@ public struct OnboardingConsentView: View { markdown: @escaping () async -> Data, action: @escaping () async -> Void, title: LocalizedStringResource? = LocalizationDefaults.consentFormTitle, - identifier: String = ConsentDocumentExport.Defaults.documentIdentifier, - exportConfiguration: ConsentDocument.ExportConfiguration = .init(), + identifier: String = ConsentDocumentExportRepresentation.Defaults.documentIdentifier, + exportConfiguration: ConsentDocumentExportRepresentation.Configuration = .init(), currentDateInSignature: Bool = true ) { self.markdown = markdown diff --git a/Sources/SpeziOnboarding/OnboardingConstraint.swift b/Sources/SpeziOnboarding/OnboardingConstraint.swift deleted file mode 100644 index 346634c..0000000 --- a/Sources/SpeziOnboarding/OnboardingConstraint.swift +++ /dev/null @@ -1,37 +0,0 @@ -// -// This source file is part of the Stanford Spezi open-source project -// -// SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) -// -// SPDX-License-Identifier: MIT -// - -import Foundation -import PDFKit -import Spezi - - -/// A Constraint which all `Standard` instances must conform to when using the Spezi Onboarding module. -@available( - *, - deprecated, - message: """ - Storing consent documents without an identifier is deprecated. - Please use `ConsentConstraint` instead. - """ -) -public protocol OnboardingConstraint: Standard { - /// Adds a new exported consent form represented as `PDFDocument` to the `Standard` conforming to ``OnboardingConstraint``. - /// - /// - Parameters: - /// - consent: The exported consent form represented as `PDFDocument` that should be added. - @available( - *, - deprecated, - message: """ - Storing consent documents without an identifier is deprecated. - Please use `ConsentConstraint.store(consent: PDFDocument, identifier: String)` instead. - """ - ) - func store(consent: sending PDFDocument) async -} diff --git a/Sources/SpeziOnboarding/OnboardingDataSource.swift b/Sources/SpeziOnboarding/OnboardingDataSource.swift index 951d1ae..0be68dc 100644 --- a/Sources/SpeziOnboarding/OnboardingDataSource.swift +++ b/Sources/SpeziOnboarding/OnboardingDataSource.swift @@ -11,11 +11,6 @@ import Spezi import SwiftUI -private protocol DeprecationSuppression { - func storeInLegacyConstraint(for standard: any Standard, _ consent: consuming sending ConsentDocumentExport) async -} - - /// Configuration for the Spezi Onboarding module. /// /// Make sure that your standard in your Spezi Application conforms to the ``OnboardingConstraint`` @@ -39,76 +34,21 @@ private protocol DeprecationSuppression { /// } /// ``` public final class OnboardingDataSource: Module, EnvironmentAccessible, @unchecked Sendable { - @StandardActor var standard: any Standard - + @StandardActor var standard: any ConsentConstraint + public init() { } - @available(*, deprecated, message: "Propagate deprecation warning") - public func configure() { - guard standard is any OnboardingConstraint || standard is any ConsentConstraint else { - fatalError("A \(type(of: standard).self) must conform to `ConsentConstraint` to process signed consent documents.") - } - } - - /// Adds a new exported consent form represented as `PDFDocument` to the ``OnboardingDataSource``. - /// - /// - Parameters - /// - consent: The PDF of the exported consent form. - /// - identifier: The document identifier for the exported consent document. - @available( - *, - deprecated, - message: """ - Storing consent documents using an exported PDF and an identifier is deprecated. - Please store the consent document from the corresponding `ConsentDocumentExport`, - by using `ConsentConstraint.store(_ consent: ConsentDocumentExport)` instead. - """ - ) - public func store(_ consent: sending PDFDocument, identifier: String = ConsentDocumentExport.Defaults.documentIdentifier) async throws { - // Normally, the ConsentDocumentExport stores all data relevant to generate the PDFDocument, such as the data and ExportConfiguration. - // Since we can not determine the original data and the ExportConfiguration at this point, we simply use some placeholder data - // to generate the ConsentDocumentExport. - let dataPlaceholder = { - Data("".utf8) - } - let documentExport = ConsentDocumentExport( - markdown: dataPlaceholder, - exportConfiguration: ConsentDocument.ExportConfiguration(), - documentIdentifier: identifier, - cachedPDF: consent - ) - try await store(documentExport) - } - /// Adds a new exported consent form represented as `PDFDocument` to the ``OnboardingDataSource``. /// /// - Parameters: - /// - consent: The exported consent form represented as `ConsentDocumentExport` that should be added. + /// - consent: The exported consent form represented as `ConsentDocumentExportRepresentation` that should be added. /// - identifier: The document identifier for the exported consent document. public func store( - _ consent: consuming sending ConsentDocumentExport, - identifier: String = ConsentDocumentExport.Defaults.documentIdentifier + _ consent: consuming sending ConsentDocumentExportRepresentation, + identifier: String = ConsentDocumentExportRepresentation.Defaults.documentIdentifier ) async throws { - if let consentConstraint = standard as? any ConsentConstraint { - try await consentConstraint.store(consent: consent) - } else { - // By down-casting to the protocol we avoid "seeing" the deprecation warning, allowing us to hide it from the compiler. - // We need to call the deprecated symbols for backwards-compatibility. - await (self as any DeprecationSuppression).storeInLegacyConstraint(for: standard, consent) - } - } -} - - -extension OnboardingDataSource: DeprecationSuppression { - @available(*, deprecated, message: "Suppress deprecation warning.") - func storeInLegacyConstraint(for standard: any Standard, _ consent: consuming sending ConsentDocumentExport) async { - if let onboardingConstraint = standard as? any OnboardingConstraint { - await onboardingConstraint.store(consent: consent.consumePDF()) - } else { - fatalError("A \(type(of: standard).self) must conform to `ConsentConstraint` to process signed consent documents.") - } + try await standard.store(consent: consent) } } diff --git a/Sources/SpeziOnboarding/Resources/Localizable.xcstrings b/Sources/SpeziOnboarding/Resources/Localizable.xcstrings index 9b06d9f..b66c038 100644 --- a/Sources/SpeziOnboarding/Resources/Localizable.xcstrings +++ b/Sources/SpeziOnboarding/Resources/Localizable.xcstrings @@ -1,6 +1,9 @@ { "sourceLanguage" : "en", "strings" : { + "Consent document could not be exported." : { + + }, "CONSENT_ACTION" : { "localizations" : { "de" : { @@ -18,6 +21,7 @@ } }, "CONSENT_EXPORT_ERROR_DESCRIPTION" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -34,6 +38,7 @@ } }, "CONSENT_EXPORT_ERROR_FAILURE_REASON" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -50,6 +55,7 @@ } }, "CONSENT_EXPORT_ERROR_RECOVERY_SUGGESTION" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -240,6 +246,9 @@ } } } + }, + "Please try exporting the consent document again." : { + }, "SEQUENTIAL_ONBOARDING_NEXT" : { "localizations" : { @@ -308,8 +317,8 @@ } } }, - "Unable to generate valid PDF document from PDF data." : { - "comment" : "Error thrown if we generated a PDF document using TPPDF,\nbut were unable to convert the generated data into a PDFDocument." + "The PDF generation from the consent document failed. " : { + } }, "version" : "1.0" diff --git a/Tests/SpeziOnboardingTests/SpeziOnboardingTests.swift b/Tests/SpeziOnboardingTests/SpeziOnboardingTests.swift index a8556ed..ebd735b 100644 --- a/Tests/SpeziOnboardingTests/SpeziOnboardingTests.swift +++ b/Tests/SpeziOnboardingTests/SpeziOnboardingTests.swift @@ -50,19 +50,33 @@ final class SpeziOnboardingTests: XCTestCase { self.loadMarkdownDataFromFile(path: markdownPath) } - let exportConfiguration = ConsentDocument.ExportConfiguration( + let exportConfiguration = ConsentDocumentExportRepresentation.Configuration( paperSize: .dinA4, consentTitle: "Spezi Onboarding", includingTimestamp: false ) - - var documentExport = ConsentDocumentExport( - markdown: markdownData, - exportConfiguration: exportConfiguration, - documentIdentifier: ConsentDocumentExport.Defaults.documentIdentifier + + #if !os(macOS) + let documentExport = ConsentDocumentExportRepresentation( + markdown: markdownData(), + signature: .init(), + signatureImage: .init(), + name: PersonNameComponents(givenName: "Leland", familyName: "Stanford"), + formattedSignatureDate: "01/23/25", + documentIdentifier: ConsentDocumentExportRepresentation.Defaults.documentIdentifier, + configuration: exportConfiguration ) - documentExport.name = PersonNameComponents(givenName: "Leland", familyName: "Stanford") - + #else + let documentExport = ConsentDocumentExportRepresentation( + markdown: markdownData(), + signature: "Stanford", + name: PersonNameComponents(givenName: "Leland", familyName: "Stanford"), + formattedSignatureDate: "01/23/25", + documentIdentifier: ConsentDocumentExportRepresentation.Defaults.documentIdentifier, + configuration: exportConfiguration + ) + #endif + #if os(macOS) let pdfPath = knownGoodPDFPath + "_mac_os" #elseif os(visionOS) @@ -73,17 +87,8 @@ final class SpeziOnboardingTests: XCTestCase { let knownGoodPdf = loadPDFFromPath(path: pdfPath) - #if !os(macOS) - documentExport.signature = .init() - #else - documentExport.signature = "Stanford" - #endif - - if let pdf = try? await documentExport.export() { - XCTAssert(comparePDFDocuments(pdf1: pdf, pdf2: knownGoodPdf)) - } else { - XCTFail("Failed to export PDF from ConsentDocumentExport.") - } + let pdf = try documentExport.render() + XCTAssert(comparePDFDocuments(pdf1: pdf, pdf2: knownGoodPdf)) } } diff --git a/Tests/UITests/TestApp/ConsentStoreError.swift b/Tests/UITests/TestApp/ConsentStoreError.swift deleted file mode 100644 index 8124b61..0000000 --- a/Tests/UITests/TestApp/ConsentStoreError.swift +++ /dev/null @@ -1,25 +0,0 @@ -// -// This source file is part of the Stanford Spezi open-source project -// -// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) -// -// SPDX-License-Identifier: MIT -// - -import Foundation - -// Error that can occur if ``OnboardingConsentView`` calls store in ExampleStandard -// with an identifier which is not in ``DocumentIdentifiers``. -enum ConsentStoreError: LocalizedError { - case invalidIdentifier(String) - - var errorDescription: String? { - switch self { - case .invalidIdentifier: - String( - localized: "Unknown document identifier provided in the OnboardingConsentView.", - comment: "Error thrown if a document identifier was passed to OnboardingConsentView, which is unknown to the Standard." - ) - } - } -} diff --git a/Tests/UITests/TestApp/DocumentIdentifiers.swift b/Tests/UITests/TestApp/DocumentIdentifiers.swift deleted file mode 100644 index b11442c..0000000 --- a/Tests/UITests/TestApp/DocumentIdentifiers.swift +++ /dev/null @@ -1,16 +0,0 @@ -// -// This source file is part of the Stanford Spezi open-source project -// -// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) -// -// SPDX-License-Identifier: MIT -// - -import Foundation -import SpeziOnboarding - -// An enum to hold identifier strings to identify two separate consent documents. -enum DocumentIdentifiers { - static let first = "firstConsentDocument" - static let second = "secondConsentDocument" -} diff --git a/Tests/UITests/TestApp/ExampleStandard.swift b/Tests/UITests/TestApp/ExampleStandard.swift index b41c4d2..6f278f6 100644 --- a/Tests/UITests/TestApp/ExampleStandard.swift +++ b/Tests/UITests/TestApp/ExampleStandard.swift @@ -12,22 +12,25 @@ import SpeziOnboarding import SwiftUI +// An enum to hold identifier strings to identify two separate consent documents. +enum DocumentIdentifiers { + static let first = "firstConsentDocument" + static let second = "secondConsentDocument" +} + + /// An example `Standard` used for the configuration. actor ExampleStandard: Standard, EnvironmentAccessible { @MainActor var firstConsentData: PDFDocument = .init() @MainActor var secondConsentData: PDFDocument = .init() } - extension ExampleStandard: ConsentConstraint { - // Example of an async function using MainActor and Task - func store(consent: consuming sending ConsentDocumentExport) async throws { + func store(consent: consuming sending ConsentDocumentExportRepresentation) async throws { let documentIdentifier = consent.documentIdentifier - let pdf = consent.consumePDF() - - // Perform operations on the main actor + let pdf = try consent.render() + try await self.store(document: pdf, for: documentIdentifier) - try? await Task.sleep(for: .seconds(0.5)) // Simulates storage delay } @@ -38,17 +41,16 @@ extension ExampleStandard: ConsentConstraint { } else if documentIdentifier == DocumentIdentifiers.second { self.secondConsentData = pdf } else { - throw ConsentStoreError.invalidIdentifier("Invalid Identifier \(documentIdentifier)") + preconditionFailure("Unexpected document identifier when persisting consent document: \(documentIdentifier)") } } + @MainActor func resetDocument(identifier: String) async throws { - await MainActor.run { - if identifier == DocumentIdentifiers.first { - firstConsentData = .init() - } else if identifier == DocumentIdentifiers.second { - secondConsentData = .init() - } + if identifier == DocumentIdentifiers.first { + firstConsentData = .init() + } else if identifier == DocumentIdentifiers.second { + secondConsentData = .init() } } diff --git a/Tests/UITests/TestApp/Views/HelperViews/CustomToggleView.swift b/Tests/UITests/TestApp/Views/Helpers/CustomToggleView.swift similarity index 100% rename from Tests/UITests/TestApp/Views/HelperViews/CustomToggleView.swift rename to Tests/UITests/TestApp/Views/Helpers/CustomToggleView.swift diff --git a/Tests/UITests/UITests.xcodeproj/project.pbxproj b/Tests/UITests/UITests.xcodeproj/project.pbxproj index f86a262..8b7f282 100644 --- a/Tests/UITests/UITests.xcodeproj/project.pbxproj +++ b/Tests/UITests/UITests.xcodeproj/project.pbxproj @@ -27,8 +27,6 @@ 97C6AF7B2ACC89000060155B /* XCTestExtensions in Frameworks */ = {isa = PBXBuildFile; productRef = 97C6AF7A2ACC89000060155B /* XCTestExtensions */; }; 97C6AF7F2ACC94450060155B /* OnboardingFlow+PreviewSimulator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 97C6AF7E2ACC94450060155B /* OnboardingFlow+PreviewSimulator.swift */; }; A950C9C02C68AFAD0052FA6D /* XCUIApplication+Onboarding.swift in Sources */ = {isa = PBXBuildFile; fileRef = A950C9BF2C68AFA80052FA6D /* XCUIApplication+Onboarding.swift */; }; - C49959752C6C8C01008E5256 /* DocumentIdentifiers.swift in Sources */ = {isa = PBXBuildFile; fileRef = C49959732C6C8C01008E5256 /* DocumentIdentifiers.swift */; }; - C49959762C6C8C01008E5256 /* ConsentStoreError.swift in Sources */ = {isa = PBXBuildFile; fileRef = C49959742C6C8C01008E5256 /* ConsentStoreError.swift */; }; C49959792C6C8C1F008E5256 /* OnboardingConsentMarkdownRenderingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C49959772C6C8C1F008E5256 /* OnboardingConsentMarkdownRenderingView.swift */; }; C499597A2C6C8C1F008E5256 /* OnboardingConsentMarkdownTestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C49959782C6C8C1F008E5256 /* OnboardingConsentMarkdownTestView.swift */; }; /* End PBXBuildFile section */ @@ -66,8 +64,6 @@ 97C6AF782ACC88270060155B /* TestAppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestAppDelegate.swift; sourceTree = ""; }; 97C6AF7E2ACC94450060155B /* OnboardingFlow+PreviewSimulator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "OnboardingFlow+PreviewSimulator.swift"; sourceTree = ""; }; A950C9BF2C68AFA80052FA6D /* XCUIApplication+Onboarding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "XCUIApplication+Onboarding.swift"; sourceTree = ""; }; - C49959732C6C8C01008E5256 /* DocumentIdentifiers.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DocumentIdentifiers.swift; sourceTree = ""; }; - C49959742C6C8C01008E5256 /* ConsentStoreError.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ConsentStoreError.swift; sourceTree = ""; }; C49959772C6C8C1F008E5256 /* OnboardingConsentMarkdownRenderingView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OnboardingConsentMarkdownRenderingView.swift; sourceTree = ""; }; C49959782C6C8C1F008E5256 /* OnboardingConsentMarkdownTestView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OnboardingConsentMarkdownTestView.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -116,8 +112,6 @@ 2F6D139428F5F384007C25D6 /* TestApp */ = { isa = PBXGroup; children = ( - C49959742C6C8C01008E5256 /* ConsentStoreError.swift */, - C49959732C6C8C01008E5256 /* DocumentIdentifiers.swift */, 970D44472A6F02E800756FE2 /* Views */, 2FA7382B290ADFAA007ACEB9 /* TestApp.swift */, 97C6AF782ACC88270060155B /* TestAppDelegate.swift */, @@ -151,7 +145,7 @@ C49959782C6C8C1F008E5256 /* OnboardingConsentMarkdownTestView.swift */, 61D77B532BC83F0100E3165F /* OnboardingCustomToggleTestView.swift */, 61040A1B2BAFA2F600EDD4EC /* OnboardingIdentifiableTestViewCustom.swift */, - 97A8FF2F2A74606A008CD91A /* HelperViews */, + 97A8FF2F2A74606A008CD91A /* Helpers */, 970D44522A6F0B1900756FE2 /* OnboardingStartTestView.swift */, 970D444E2A6F048A00756FE2 /* OnboardingWelcomeTestView.swift */, 970D44502A6F04ED00756FE2 /* OnboardingSequentialTestView.swift */, @@ -164,12 +158,12 @@ path = Views; sourceTree = ""; }; - 97A8FF2F2A74606A008CD91A /* HelperViews */ = { + 97A8FF2F2A74606A008CD91A /* Helpers */ = { isa = PBXGroup; children = ( 97A8FF302A74607F008CD91A /* CustomToggleView.swift */, ); - path = HelperViews; + path = Helpers; sourceTree = ""; }; /* End PBXGroup section */ @@ -314,11 +308,9 @@ 61D77B542BC83F0100E3165F /* OnboardingCustomToggleTestView.swift in Sources */, 97A8FF2C2A74449F008CD91A /* OnboardingCustomTestView1.swift in Sources */, 2FA7382C290ADFAA007ACEB9 /* TestApp.swift in Sources */, - C49959752C6C8C01008E5256 /* DocumentIdentifiers.swift in Sources */, 97A8FF2E2A7444FC008CD91A /* OnboardingCustomTestView2.swift in Sources */, 2F61BDC929DD3CC000D71D33 /* OnboardingTestsView.swift in Sources */, 61040A1D2BAFA2F600EDD4EC /* OnboardingIdentifiableTestViewCustom.swift in Sources */, - C49959762C6C8C01008E5256 /* ConsentStoreError.swift in Sources */, 97C6AF7F2ACC94450060155B /* OnboardingFlow+PreviewSimulator.swift in Sources */, 970D444F2A6F048A00756FE2 /* OnboardingWelcomeTestView.swift in Sources */, 97A8FF312A74607F008CD91A /* CustomToggleView.swift in Sources */, From 188debf6de755765b0fa7e7234071cc45f60e599 Mon Sep 17 00:00:00 2001 From: Philipp Zagar Date: Fri, 24 Jan 2025 10:59:15 +0100 Subject: [PATCH 15/24] More cleanup --- .../{ => Consent}/ConsentConstraint.swift | 4 +- .../ConsentViewState+Binding.swift | 0 .../ConsentViewState.swift | 8 +- .../Export}/ConsentDocument+Export.swift | 61 +++--- ...ntExportRepresentation+Configuration.swift | 4 +- ...ocumentExportRepresentation+Defaults.swift | 7 +- ...tDocumentExportRepresentation+Render.swift | 4 +- .../ConsentDocumentExportRepresentation.swift | 86 ++++++++ ...ConsentDocument+LocalizationDefaults.swift | 0 .../Views}/ConsentDocument.swift | 15 +- .../Views}/OnboardingConsentView+Error.swift | 6 +- .../OnboardingConsentView+ShareSheet.swift | 0 .../{ => Consent/Views}/SignatureView.swift | 0 .../Views}/SignatureViewBackground.swift | 0 .../ConsentDocumentExportRepresentation.swift | 89 -------- .../OnboardingConsentView.swift | 20 +- .../OnboardingDataSource.swift | 23 +-- .../ObtainingUserConsent.md | 8 + .../SpeziOnboarding.docc/SpeziOnboarding.md | 4 +- .../Resources/known_good_pdf_one_page_ios.pdf | Bin 130 -> 19527 bytes .../known_good_pdf_one_page_mac_os.pdf | Bin 130 -> 130 bytes .../known_good_pdf_one_page_vision_os.pdf | Bin 130 -> 130 bytes .../known_good_pdf_two_pages_ios.pdf | Bin 130 -> 25574 bytes .../known_good_pdf_two_pages_mac_os.pdf | Bin 130 -> 130 bytes .../known_good_pdf_two_pages_vision_os.pdf | Bin 130 -> 130 bytes .../SpeziOnboardingTests.swift | 194 ++++++++---------- 26 files changed, 266 insertions(+), 267 deletions(-) rename Sources/SpeziOnboarding/{ => Consent}/ConsentConstraint.swift (82%) rename Sources/SpeziOnboarding/{ConsentView => Consent}/ConsentViewState+Binding.swift (100%) rename Sources/SpeziOnboarding/{ConsentView => Consent}/ConsentViewState.swift (80%) rename Sources/SpeziOnboarding/{ConsentView => Consent/Export}/ConsentDocument+Export.swift (77%) rename Sources/SpeziOnboarding/{ConsentView => Consent/Export}/ConsentDocumentExportRepresentation+Configuration.swift (97%) rename Sources/SpeziOnboarding/{ConsentView => Consent/Export}/ConsentDocumentExportRepresentation+Defaults.swift (91%) rename Sources/SpeziOnboarding/{ConsentView => Consent/Export}/ConsentDocumentExportRepresentation+Render.swift (98%) create mode 100644 Sources/SpeziOnboarding/Consent/Export/ConsentDocumentExportRepresentation.swift rename Sources/SpeziOnboarding/{ConsentView => Consent/Views}/ConsentDocument+LocalizationDefaults.swift (100%) rename Sources/SpeziOnboarding/{ConsentView => Consent/Views}/ConsentDocument.swift (92%) rename Sources/SpeziOnboarding/{ => Consent/Views}/OnboardingConsentView+Error.swift (88%) rename Sources/SpeziOnboarding/{ConsentView => Consent/Views}/OnboardingConsentView+ShareSheet.swift (100%) rename Sources/SpeziOnboarding/{ => Consent/Views}/SignatureView.swift (100%) rename Sources/SpeziOnboarding/{ => Consent/Views}/SignatureViewBackground.swift (100%) delete mode 100644 Sources/SpeziOnboarding/ConsentView/ConsentDocumentExportRepresentation.swift diff --git a/Sources/SpeziOnboarding/ConsentConstraint.swift b/Sources/SpeziOnboarding/Consent/ConsentConstraint.swift similarity index 82% rename from Sources/SpeziOnboarding/ConsentConstraint.swift rename to Sources/SpeziOnboarding/Consent/ConsentConstraint.swift index fb15758..c858229 100644 --- a/Sources/SpeziOnboarding/ConsentConstraint.swift +++ b/Sources/SpeziOnboarding/Consent/ConsentConstraint.swift @@ -11,11 +11,11 @@ import PDFKit import Spezi -/// A Constraint which all `Standard` instances must conform to when using the `OnboardingConsentView`. +/// A constraint which all `Standard` instances must conform to when using the `OnboardingConsentView`. public protocol ConsentConstraint: Standard { /// Adds a new exported consent form represented as `PDFDocument` to the `Standard` conforming to ``ConsentConstraint``. /// /// - Parameters: - /// - consent: The exported consent form represented as `ConsentDocumentExportRepresentation` that should be added. + /// - consent: The exported consent form represented as ``ConsentDocumentExportRepresentation`` that should be added. func store(consent: consuming sending ConsentDocumentExportRepresentation) async throws } diff --git a/Sources/SpeziOnboarding/ConsentView/ConsentViewState+Binding.swift b/Sources/SpeziOnboarding/Consent/ConsentViewState+Binding.swift similarity index 100% rename from Sources/SpeziOnboarding/ConsentView/ConsentViewState+Binding.swift rename to Sources/SpeziOnboarding/Consent/ConsentViewState+Binding.swift diff --git a/Sources/SpeziOnboarding/ConsentView/ConsentViewState.swift b/Sources/SpeziOnboarding/Consent/ConsentViewState.swift similarity index 80% rename from Sources/SpeziOnboarding/ConsentView/ConsentViewState.swift rename to Sources/SpeziOnboarding/Consent/ConsentViewState.swift index 7e050ef..f21f75d 100644 --- a/Sources/SpeziOnboarding/ConsentView/ConsentViewState.swift +++ b/Sources/SpeziOnboarding/Consent/ConsentViewState.swift @@ -26,14 +26,16 @@ public enum ConsentViewState: Equatable { /// The `signed` state indicates that the ``ConsentDocument`` is signed by the user. case signed /// The `export` state can be set by an outside view - /// encapsulating the ``ConsentDocument`` to trigger the export of the consent document as a PDF. + /// encapsulating the ``ConsentDocument`` to trigger the export of the consent document as a ``ConsentDocumentExportRepresentation``. /// /// The previous state must be ``ConsentViewState/signed``, indicating that the consent document is signed. case export /// The `exported` state indicates that the - /// ``ConsentDocument`` has been successfully exported. The rendered `PDFDocument` can be found as the associated value of the state. + /// ``ConsentDocumentExportRepresentation`` has been successfully created. + /// The ``ConsentDocumentExportRepresentation`` can then be rendered to a PDF via ``ConsentDocumentExportRepresentation/render()``. /// - /// The export procedure (resulting in the ``ConsentViewState/exported(document:export:)`` state) can be triggered via setting the ``ConsentViewState/export`` state of the ``ConsentDocument`` . + /// The export representation creation (resulting in the ``ConsentViewState/exported(representation:)`` state) can be triggered + /// via setting the ``ConsentViewState/export`` state of the ``ConsentDocument`` . case exported(representation: ConsentDocumentExportRepresentation) /// The `storing` state indicates that the ``ConsentDocument`` is currently being stored to the Standard. case storing diff --git a/Sources/SpeziOnboarding/ConsentView/ConsentDocument+Export.swift b/Sources/SpeziOnboarding/Consent/Export/ConsentDocument+Export.swift similarity index 77% rename from Sources/SpeziOnboarding/ConsentView/ConsentDocument+Export.swift rename to Sources/SpeziOnboarding/Consent/Export/ConsentDocument+Export.swift index b1e77a1..ff84556 100644 --- a/Sources/SpeziOnboarding/ConsentView/ConsentDocument+Export.swift +++ b/Sources/SpeziOnboarding/Consent/Export/ConsentDocument+Export.swift @@ -13,13 +13,38 @@ import TPPDF /// Extension of `ConsentDocument` enabling the export of the signed consent page. extension ConsentDocument { + /// Creates the export representation of the ``ConsentDocument`` including all necessary content. + var exportRepresentation: ConsentDocumentExportRepresentation { + get async { + #if !os(macOS) + .init( + markdown: await self.markdown(), + signature: signatureImage, + name: self.name, + formattedSignatureDate: self.formattedConsentSignatureDate, + documentIdentifier: self.documentIdentifier, + configuration: self.exportConfiguration + ) + #else + .init( + markdown: await self.markdown(), + signature: self.signature, + name: self.name, + formattedSignatureDate: self.formattedConsentSignatureDate, + documentIdentifier: self.documentIdentifier, + configuration: self.exportConfiguration + ) + #endif + } + } + #if !os(macOS) - /// As the `PKDrawing.image()` function automatically converts the ink color dependent on the used color scheme (light or dark mode), - /// force the ink used in the `UIImage` of the `PKDrawing` to always be black by adjusting the signature ink according to the color scheme. - @MainActor private var blackInkSignatureImage: UIImage { + private var signatureImage: UIImage { var updatedDrawing = PKDrawing() - + for stroke in signature.strokes { + // As the `PKDrawing.image()` function automatically converts the ink color dependent on the used color scheme (light or dark mode), + // force the ink used in the `UIImage` of the `PKDrawing` to always be black by adjusting the signature ink according to the color scheme. let blackStroke = PKStroke( ink: PKInk(stroke.ink.inkType, color: colorScheme == .light ? .black : .white), path: stroke.path, @@ -42,32 +67,4 @@ extension ConsentDocument { ) } #endif - - /// Creates the export representation of the ``ConsentDocument`` including all necessary consent. - /// - /// - Returns: The exported consent representation as a ``ConsentDocumentExportRepresentation`` - var exportRepresentation: ConsentDocumentExportRepresentation { - get async { - #if !os(macOS) - .init( - markdown: await self.markdown(), - signature: self.signature, - signatureImage: self.blackInkSignatureImage, - name: self.name, - formattedSignatureDate: self.formattedConsentSignatureDate, - documentIdentifier: self.documentIdentifier, - configuration: self.exportConfiguration - ) - #else - .init( - markdown: await self.markdown(), - signature: self.signature, - name: self.name, - formattedSignatureDate: self.formattedConsentSignatureDate, - documentIdentifier: self.documentIdentifier, - configuration: self.exportConfiguration - ) - #endif - } - } } diff --git a/Sources/SpeziOnboarding/ConsentView/ConsentDocumentExportRepresentation+Configuration.swift b/Sources/SpeziOnboarding/Consent/Export/ConsentDocumentExportRepresentation+Configuration.swift similarity index 97% rename from Sources/SpeziOnboarding/ConsentView/ConsentDocumentExportRepresentation+Configuration.swift rename to Sources/SpeziOnboarding/Consent/Export/ConsentDocumentExportRepresentation+Configuration.swift index f5fe839..4bb962d 100644 --- a/Sources/SpeziOnboarding/ConsentView/ConsentDocumentExportRepresentation+Configuration.swift +++ b/Sources/SpeziOnboarding/Consent/Export/ConsentDocumentExportRepresentation+Configuration.swift @@ -138,10 +138,10 @@ extension ConsentDocumentExportRepresentation { let fontSettings: FontSettings - /// Creates an `ExportConfiguration` specifying the properties of the exported consent form. + /// Creates an ``ConsentDocumentExportRepresentation/Configuration`` specifying the properties of the exported consent form. /// /// - Parameters: - /// - paperSize: The page size of the exported form represented by ``ConsentDocument/ExportConfiguration/PaperSize``. + /// - paperSize: The page size of the exported form represented by ``ConsentDocumentExportRepresentation/Configuration/PaperSize``. /// - consentTitle: The title of the exported consent form. /// - includingTimestamp: Indicates if the exported form includes a timestamp. /// - fontSettings: Font settings for the exported form. diff --git a/Sources/SpeziOnboarding/ConsentView/ConsentDocumentExportRepresentation+Defaults.swift b/Sources/SpeziOnboarding/Consent/Export/ConsentDocumentExportRepresentation+Defaults.swift similarity index 91% rename from Sources/SpeziOnboarding/ConsentView/ConsentDocumentExportRepresentation+Defaults.swift rename to Sources/SpeziOnboarding/Consent/Export/ConsentDocumentExportRepresentation+Defaults.swift index 6705554..9a08073 100644 --- a/Sources/SpeziOnboarding/ConsentView/ConsentDocumentExportRepresentation+Defaults.swift +++ b/Sources/SpeziOnboarding/Consent/Export/ConsentDocumentExportRepresentation+Defaults.swift @@ -11,8 +11,13 @@ import SwiftUI extension ConsentDocumentExportRepresentation.Configuration { - /// Provides default values for fields related to the `ConsentDocumentExportConfiguration`. + /// Provides default values for fields related to the ``ConsentDocumentExportRepresentation/Configuration``. public enum Defaults { + /// Default value for a document identifier. + /// + /// This identifier will be used as default value if no identifier is provided. + public static let documentIdentifier = "ConsentDocument" + #if !os(macOS) /// Default export font settings with fixed font sizes, ensuring a consistent appearance across platforms. /// diff --git a/Sources/SpeziOnboarding/ConsentView/ConsentDocumentExportRepresentation+Render.swift b/Sources/SpeziOnboarding/Consent/Export/ConsentDocumentExportRepresentation+Render.swift similarity index 98% rename from Sources/SpeziOnboarding/ConsentView/ConsentDocumentExportRepresentation+Render.swift rename to Sources/SpeziOnboarding/Consent/Export/ConsentDocumentExportRepresentation+Render.swift index 02cc82a..e8f4a4b 100644 --- a/Sources/SpeziOnboarding/ConsentView/ConsentDocumentExportRepresentation+Render.swift +++ b/Sources/SpeziOnboarding/Consent/Export/ConsentDocumentExportRepresentation+Render.swift @@ -60,11 +60,11 @@ extension ConsentDocumentExportRepresentation { /// Exports the signature as a `PDFGroup`, including the prefix ("X"), the name of the signee, the date, as well as the signature image. private var renderedSignature: PDFGroup { let personName = name.formatted(.name(style: .long)) - + #if !os(macOS) let group = PDFGroup( allowsBreaks: false, - backgroundImage: PDFImage(image: signatureImage), + backgroundImage: PDFImage(image: signature), padding: EdgeInsets(top: 50, left: 50, bottom: 0, right: 100) ) let signaturePrefix = "X" diff --git a/Sources/SpeziOnboarding/Consent/Export/ConsentDocumentExportRepresentation.swift b/Sources/SpeziOnboarding/Consent/Export/ConsentDocumentExportRepresentation.swift new file mode 100644 index 0000000..82e1b7c --- /dev/null +++ b/Sources/SpeziOnboarding/Consent/Export/ConsentDocumentExportRepresentation.swift @@ -0,0 +1,86 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +@preconcurrency import PDFKit +import PencilKit +import SwiftUI + + +/// Represents a to-be-exported ``ConsentDocument``. +/// +/// It holds all the content necessary to export the ``ConsentDocument`` as a `PDFDocument` and the corresponding identifier String in ``ConsentDocumentExportRepresentation/documentIdentifier``. +/// Using ``ConsentDocumentExportRepresentation/render()`` performs the rendering of the ``ConsentDocument`` as a PDF. +public struct ConsentDocumentExportRepresentation: Equatable { + /// An unique identifier for the exported ``ConsentDocument``. + /// + /// Corresponds to the identifier which was passed when creating the ``ConsentDocument`` using an ``OnboardingConsentView``. + public let documentIdentifier: String + + let configuration: Configuration + let markdown: Data + #if !os(macOS) + let signature: UIImage + #else + let signature: String + #endif + let name: PersonNameComponents + let formattedSignatureDate: String? + + + #if !os(macOS) + /// Creates a ``ConsentDocumentExportRepresentation`` with all necessary content to export the ``ConsentDocument`` + /// + /// - Parameters: + /// - markdown: The markdown text of the consent document. + /// - signature: The rendered signature image of the consent document. + /// - name: The name components of the signature. + /// - formattedSignatureDate: The performed `String`-based signature date. + /// - documentIdentifier: A unique `String` identifying the ``ConsentDocumentExportRepresentation`` upon export. + /// - exportConfiguration: Holds configuration properties of the to-be-exported document. + init( + markdown: Data, + signature: UIImage, + name: PersonNameComponents, + formattedSignatureDate: String?, + documentIdentifier: String, + configuration: Configuration + ) { + self.markdown = markdown + self.name = name + self.signature = signature + self.formattedSignatureDate = formattedSignatureDate + self.documentIdentifier = documentIdentifier + self.configuration = configuration + } + #else + /// Creates a ``ConsentDocumentExportRepresentation`` with all necessary content to export the ``ConsentDocument`` + /// + /// - Parameters: + /// - markdown: The markdown text of the consent document. + /// - signature: The `String`-based signature of the consent document. + /// - name: The name components of the signature. + /// - formattedSignatureDate: The performed `String`-based signature date. + /// - documentIdentifier: A unique `String` identifying the ``ConsentDocumentExportRepresentation`` upon export. + /// - exportConfiguration: Holds configuration properties of the to-be-exported document. + init( + markdown: Data, + signature: String, + name: PersonNameComponents, + formattedSignatureDate: String?, + documentIdentifier: String, + configuration: Configuration + ) { + self.markdown = markdown + self.name = name + self.signature = signature + self.formattedSignatureDate = formattedSignatureDate + self.documentIdentifier = documentIdentifier + self.configuration = configuration + } + #endif +} diff --git a/Sources/SpeziOnboarding/ConsentView/ConsentDocument+LocalizationDefaults.swift b/Sources/SpeziOnboarding/Consent/Views/ConsentDocument+LocalizationDefaults.swift similarity index 100% rename from Sources/SpeziOnboarding/ConsentView/ConsentDocument+LocalizationDefaults.swift rename to Sources/SpeziOnboarding/Consent/Views/ConsentDocument+LocalizationDefaults.swift diff --git a/Sources/SpeziOnboarding/ConsentView/ConsentDocument.swift b/Sources/SpeziOnboarding/Consent/Views/ConsentDocument.swift similarity index 92% rename from Sources/SpeziOnboarding/ConsentView/ConsentDocument.swift rename to Sources/SpeziOnboarding/Consent/Views/ConsentDocument.swift index 2169bf0..f0e7c90 100644 --- a/Sources/SpeziOnboarding/ConsentView/ConsentDocument.swift +++ b/Sources/SpeziOnboarding/Consent/Views/ConsentDocument.swift @@ -20,10 +20,13 @@ import SwiftUI /// /// To observe and control the current state of the `ConsentDocument`, the view requires passing down a ``ConsentViewState`` as a SwiftUI `Binding` in the /// ``init(markdown:viewState:givenNameTitle:givenNamePlaceholder:familyNameTitle:familyNamePlaceholder:exportConfiguration:documentIdentifier:consentSignatureDate:consentSignatureDateFormatter:)`` initializer. -/// This `Binding` can then be used to trigger the export of the consent form via setting the state to ``ConsentViewState/export``. -/// After the rendering completes, the finished `PDFDocument` from Apple's PDFKit is accessible via the associated value of the view state in ``ConsentViewState/exported(document:export:)``. +/// +/// This `Binding` can then be used to trigger the creation of the export representation of the consent form via setting the state to ``ConsentViewState/export``. +/// After the export representation completes, the ``ConsentDocumentExportRepresentation`` is accessible via the associated value of the view state in ``ConsentViewState/exported(representation:)``. +/// The ``ConsentDocumentExportRepresentation`` can then be rendered to a PDF via ``ConsentDocumentExportRepresentation/render()``. /// Other possible states of the `ConsentDocument` are the SpeziViews `ViewState`'s accessible via the associated value in ``ConsentViewState/base(_:)``. -/// In addition, the view provides information about the signing progress via the ``ConsentViewState/signing`` and ``ConsentViewState/signed`` states. +/// In addition, the view provides information about the signing progress via the ``ConsentViewState/signing`` and ``ConsentViewState/signed`` states, +/// as well as the ``ConsentViewState/storing`` state that indicates the current storage process via the ``ConsentConstraint``. /// /// ```swift /// // Enables observing the view state of the consent document @@ -113,7 +116,7 @@ public struct ConsentDocument: View { } } - @MainActor private var signatureView: some View { + private var signatureView: some View { Group { #if !os(macOS) SignatureView( @@ -215,7 +218,7 @@ public struct ConsentDocument: View { /// - givenNamePlaceholder: The localization to use for the given name field placeholder. /// - familyNameTitle: The localization to use for the family (last) name field. /// - familyNamePlaceholder: The localization to use for the family name field placeholder. - /// - exportConfiguration: Defines the properties of the exported consent form via ``ConsentDocument/ExportConfiguration``. + /// - exportConfiguration: Defines the properties of the exported consent form via ``ConsentDocumentExportRepresentation/Configuration``. /// - documentIdentifier: A unique identifier or "name" for the consent form, helpful for distinguishing consent forms when storing in the `Standard`. /// - consentSignatureDate: The date that is displayed under the signature line. /// - consentSignatureDateFormatter: The date formatter used to format the date that is displayed under the signature line. @@ -227,7 +230,7 @@ public struct ConsentDocument: View { familyNameTitle: LocalizedStringResource = LocalizationDefaults.familyNameTitle, familyNamePlaceholder: LocalizedStringResource = LocalizationDefaults.familyNamePlaceholder, exportConfiguration: ConsentDocumentExportRepresentation.Configuration = .init(), - documentIdentifier: String = ConsentDocumentExportRepresentation.Defaults.documentIdentifier, + documentIdentifier: String = ConsentDocumentExportRepresentation.Configuration.Defaults.documentIdentifier, consentSignatureDate: Date? = nil, consentSignatureDateFormatter: DateFormatter = { let formatter = DateFormatter() diff --git a/Sources/SpeziOnboarding/OnboardingConsentView+Error.swift b/Sources/SpeziOnboarding/Consent/Views/OnboardingConsentView+Error.swift similarity index 88% rename from Sources/SpeziOnboarding/OnboardingConsentView+Error.swift rename to Sources/SpeziOnboarding/Consent/Views/OnboardingConsentView+Error.swift index 2439427..a91bdd0 100644 --- a/Sources/SpeziOnboarding/OnboardingConsentView+Error.swift +++ b/Sources/SpeziOnboarding/Consent/Views/OnboardingConsentView+Error.swift @@ -15,21 +15,21 @@ extension OnboardingConsentView { case consentExportError - public var errorDescription: String? { + var errorDescription: String? { switch self { case .consentExportError: String(localized: LocalizedStringResource("Consent document could not be exported.", bundle: .atURL(from: .module))) } } - public var recoverySuggestion: String? { + var recoverySuggestion: String? { switch self { case .consentExportError: String(localized: LocalizedStringResource("Please try exporting the consent document again.", bundle: .atURL(from: .module))) } } - public var failureReason: String? { + var failureReason: String? { switch self { case .consentExportError: String(localized: LocalizedStringResource("The PDF generation from the consent document failed. ", bundle: .atURL(from: .module))) diff --git a/Sources/SpeziOnboarding/ConsentView/OnboardingConsentView+ShareSheet.swift b/Sources/SpeziOnboarding/Consent/Views/OnboardingConsentView+ShareSheet.swift similarity index 100% rename from Sources/SpeziOnboarding/ConsentView/OnboardingConsentView+ShareSheet.swift rename to Sources/SpeziOnboarding/Consent/Views/OnboardingConsentView+ShareSheet.swift diff --git a/Sources/SpeziOnboarding/SignatureView.swift b/Sources/SpeziOnboarding/Consent/Views/SignatureView.swift similarity index 100% rename from Sources/SpeziOnboarding/SignatureView.swift rename to Sources/SpeziOnboarding/Consent/Views/SignatureView.swift diff --git a/Sources/SpeziOnboarding/SignatureViewBackground.swift b/Sources/SpeziOnboarding/Consent/Views/SignatureViewBackground.swift similarity index 100% rename from Sources/SpeziOnboarding/SignatureViewBackground.swift rename to Sources/SpeziOnboarding/Consent/Views/SignatureViewBackground.swift diff --git a/Sources/SpeziOnboarding/ConsentView/ConsentDocumentExportRepresentation.swift b/Sources/SpeziOnboarding/ConsentView/ConsentDocumentExportRepresentation.swift deleted file mode 100644 index a73abc8..0000000 --- a/Sources/SpeziOnboarding/ConsentView/ConsentDocumentExportRepresentation.swift +++ /dev/null @@ -1,89 +0,0 @@ -// -// This source file is part of the Stanford Spezi open-source project -// -// SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) -// -// SPDX-License-Identifier: MIT -// - -@preconcurrency import PDFKit -import PencilKit -import SwiftUI - - -/// A type representing an exported `ConsentDocument`. It holds the exported `PDFDocument` and the corresponding document identifier String. -public struct ConsentDocumentExportRepresentation: Equatable { - /// Provides default values for fields related to the `ConsentDocumentExportRepresentation`. - public enum Defaults { - /// Default value for a document identifier. - /// - /// This identifier will be used as default value if no identifier is provided. - public static let documentIdentifier = "ConsentDocument" - } - - - /// An unique identifier for the exported `ConsentDocument`. - /// - /// Corresponds to the identifier which was passed when creating the `ConsentDocument` using an `OnboardingConsentView`. - public let documentIdentifier: String - /// Export configuration of the document. - let configuration: Configuration - - let markdown: Data - #if !os(macOS) - let signature: PKDrawing - let signatureImage: UIImage - #else - let signature: String - #endif - let name: PersonNameComponents - let formattedSignatureDate: String? - - - #if !os(macOS) - // TODO: Docs - /// Creates a `ConsentDocumentExportRepresentation`, which holds an exported PDF and the corresponding document identifier string. - /// - Parameters: - /// - markdown: The markdown text for the document, which is shown to the user. - /// - exportConfiguration: The `ExportConfiguration` holding the properties of the document. - /// - documentIdentifier: A unique String identifying the exported `ConsentDocument`. - init( - markdown: Data, - signature: PKDrawing, - signatureImage: UIImage, - name: PersonNameComponents, - formattedSignatureDate: String?, - documentIdentifier: String, - configuration: Configuration - ) { - self.markdown = markdown - self.name = name - self.signature = signature - self.signatureImage = signatureImage - self.formattedSignatureDate = formattedSignatureDate - self.documentIdentifier = documentIdentifier - self.configuration = configuration - } - #else - /// Creates a `ConsentDocumentExportRepresentation`, which holds an exported PDF and the corresponding document identifier string. - /// - Parameters: - /// - markdown: The markdown text for the document, which is shown to the user. - /// - exportConfiguration: The `ExportConfiguration` holding the properties of the document. - /// - documentIdentifier: A unique String identifying the exported `ConsentDocument`. - init( - markdown: Data, - signature: String, - name: PersonNameComponents, - formattedSignatureDate: String?, - documentIdentifier: String, - configuration: Configuration - ) { - self.markdown = markdown - self.name = name - self.signature = signature - self.formattedSignatureDate = formattedSignatureDate - self.documentIdentifier = documentIdentifier - self.configuration = configuration - } - #endif -} diff --git a/Sources/SpeziOnboarding/OnboardingConsentView.swift b/Sources/SpeziOnboarding/OnboardingConsentView.swift index d51f4aa..7f05ff0 100644 --- a/Sources/SpeziOnboarding/OnboardingConsentView.swift +++ b/Sources/SpeziOnboarding/OnboardingConsentView.swift @@ -21,7 +21,7 @@ import SwiftUI /// signed using a family and given name and a hand drawn signature. /// /// Furthermore, the view includes an export functionality, enabling users to share and store the signed consent form. -/// The exported consent form is automatically stored in the Spezi `Standard`, requiring the `Standard` to conform to the ``OnboardingConstraint``. +/// The exported consent form is automatically stored in the Spezi `Standard`, requiring the `Standard` to conform to the ``ConsentConstraint``. /// /// The `OnboardingConsentView` builds on top of the SpeziOnboarding ``ConsentDocument`` /// by providing a more developer-friendly, convenient API with additional functionalities like the share consent option. @@ -119,7 +119,7 @@ public struct OnboardingConsentView: View { if case .exported(let consentExport) = viewState { if !willShowShareSheet { viewState = .storing - Task { + Task { @MainActor in do { // Stores the finished consent export representation in the Spezi `Standard`. try await onboardingDataSource.store(consentExport) @@ -191,11 +191,15 @@ public struct OnboardingConsentView: View { #if os(macOS) .onChange(of: showShareSheet) { _, isPresented in if isPresented, - case .exported(let exportedConsentDocument) = viewState { - let shareSheet = ShareSheet(sharedItem: exportedConsentDocument.consumePDF()) - shareSheet.show() + case .exported(let exportedConsent) = viewState { + if let consentPdf = try? exportedConsent.render() { + let shareSheet = ShareSheet(sharedItem: consentPdf) + shareSheet.show() - showShareSheet = false + showShareSheet = false + } else { + viewState = .base(.error(Error.consentExportError)) + } } } @@ -225,13 +229,13 @@ public struct OnboardingConsentView: View { /// - action: The action that should be performed once the consent is given. /// - title: The title of the view displayed at the top. Can be `nil`, meaning no title is displayed. /// - identifier: A unique identifier or "name" for the consent form, helpful for distinguishing consent forms when storing in the `Standard`. - /// - exportConfiguration: Defines the properties of the exported consent form via ``ConsentDocument/ExportConfiguration``. + /// - exportConfiguration: Defines the properties of the exported consent form via ``ConsentDocumentExportRepresentation/Configuration``. /// - currentDateInSignature: Indicates if the consent document should include the current date in the signature field. Defaults to `true`. public init( markdown: @escaping () async -> Data, action: @escaping () async -> Void, title: LocalizedStringResource? = LocalizationDefaults.consentFormTitle, - identifier: String = ConsentDocumentExportRepresentation.Defaults.documentIdentifier, + identifier: String = ConsentDocumentExportRepresentation.Configuration.Defaults.documentIdentifier, exportConfiguration: ConsentDocumentExportRepresentation.Configuration = .init(), currentDateInSignature: Bool = true ) { diff --git a/Sources/SpeziOnboarding/OnboardingDataSource.swift b/Sources/SpeziOnboarding/OnboardingDataSource.swift index 0be68dc..166924a 100644 --- a/Sources/SpeziOnboarding/OnboardingDataSource.swift +++ b/Sources/SpeziOnboarding/OnboardingDataSource.swift @@ -13,12 +13,14 @@ import SwiftUI /// Configuration for the Spezi Onboarding module. /// -/// Make sure that your standard in your Spezi Application conforms to the ``OnboardingConstraint`` +/// Make sure that the `Standard` in your Spezi Application conforms to the ``ConsentConstraint`` /// protocol to store exported consent forms. /// ```swift -/// actor ExampleStandard: Standard, OnboardingConstraint { -/// func store(consent: Data) { -/// ... +/// actor ExampleStandard: Standard, ConsentConstraint { +/// func store(consent: consuming sending ConsentDocumentExportRepresentation) async throws +/// let pdf = try consent.render() +/// let documentIdentifier = consent.documentIdentifier +/// // ... /// } /// } /// ``` @@ -33,22 +35,19 @@ import SwiftUI /// } /// } /// ``` -public final class OnboardingDataSource: Module, EnvironmentAccessible, @unchecked Sendable { +public final class OnboardingDataSource: Module, EnvironmentAccessible { @StandardActor var standard: any ConsentConstraint public init() { } - /// Adds a new exported consent form represented as `PDFDocument` to the ``OnboardingDataSource``. + /// Adds a new exported consent form representation ``ConsentDocumentExportRepresentation`` to the ``OnboardingDataSource``. /// /// - Parameters: - /// - consent: The exported consent form represented as `ConsentDocumentExportRepresentation` that should be added. - /// - identifier: The document identifier for the exported consent document. - public func store( - _ consent: consuming sending ConsentDocumentExportRepresentation, - identifier: String = ConsentDocumentExportRepresentation.Defaults.documentIdentifier - ) async throws { + /// - consent: The exported consent form represented as ``ConsentDocumentExportRepresentation`` that should be added. + @MainActor + public func store(_ consent: consuming sending ConsentDocumentExportRepresentation) async throws { try await standard.store(consent: consent) } } diff --git a/Sources/SpeziOnboarding/SpeziOnboarding.docc/ObtainingUserConsent.md b/Sources/SpeziOnboarding/SpeziOnboarding.docc/ObtainingUserConsent.md index 269ed67..c9296ae 100644 --- a/Sources/SpeziOnboarding/SpeziOnboarding.docc/ObtainingUserConsent.md +++ b/Sources/SpeziOnboarding/SpeziOnboarding.docc/ObtainingUserConsent.md @@ -85,3 +85,11 @@ OnboardingConsentView( ### Views - ``OnboardingConsentView`` +- ``ConsentDocument`` +- ``SignatureView`` + +### Export + +- ``ConsentViewState`` +- ``ConsentDocumentExportRepresentation`` +- ``ConsentConstraint`` diff --git a/Sources/SpeziOnboarding/SpeziOnboarding.docc/SpeziOnboarding.md b/Sources/SpeziOnboarding/SpeziOnboarding.docc/SpeziOnboarding.md index f37de30..7507f18 100644 --- a/Sources/SpeziOnboarding/SpeziOnboarding.docc/SpeziOnboarding.md +++ b/Sources/SpeziOnboarding/SpeziOnboarding.docc/SpeziOnboarding.md @@ -150,7 +150,7 @@ struct ConsentViewExample: View { // Action to perform once the user has given their consent }, exportConfiguration: .init(paperSize: .usLetter), // Configure the properties of the exported consent form - currentDateInSignature: true // Indicates if the consent signature should include the current date.) + currentDateInSignature: true // Indicates if the consent signature should include the current date. ) } } @@ -188,4 +188,4 @@ struct ConsentViewExample: View { ### Data Flow - ``OnboardingDataSource`` -- ``OnboardingConstraint`` +- ``ConsentConstraint`` diff --git a/Tests/SpeziOnboardingTests/Resources/known_good_pdf_one_page_ios.pdf b/Tests/SpeziOnboardingTests/Resources/known_good_pdf_one_page_ios.pdf index 769a4db8f48a5929dc929aca06d11c7907f31dff..3b9e272ef4c3baebe352b0aad701bf01727abca0 100644 GIT binary patch literal 19527 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𝔧JQi9s=FF(caS2Em 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 diff --git a/Tests/SpeziOnboardingTests/Resources/known_good_pdf_one_page_vision_os.pdf b/Tests/SpeziOnboardingTests/Resources/known_good_pdf_one_page_vision_os.pdf index 3979b41b865e672aa986d6514918fd1d0e8abb15..43c917bba3e9e22db255ce535e85994c6e2bda40 100644 GIT binary patch delta 83 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 literal 130 zcmWN?K@!3s3;@78uiyg~QV2+Y14$~(sC0z(;OliSd&y_C{?>KQV{FDe+PppYvHY)R zS*X9vIE2gvsyEi7<`u&hWEaRF$pzg~tE}?M!34-n5-bMC5FX+(JzMBd d==yWC*_G`yK$&SEISIij_a61Wp4bq{sD8Xu7FGZN delta 83 zcmV~$u@QhE3QJQy4A6n1)+(U?WoS_D>xmtRJC+aLgBCvk diff --git a/Tests/SpeziOnboardingTests/SpeziOnboardingTests.swift b/Tests/SpeziOnboardingTests/SpeziOnboardingTests.swift index ebd735b..7174036 100644 --- a/Tests/SpeziOnboardingTests/SpeziOnboardingTests.swift +++ b/Tests/SpeziOnboardingTests/SpeziOnboardingTests.swift @@ -9,22 +9,12 @@ import PDFKit @testable import SpeziOnboarding import SwiftUI -import XCTest +import Testing -final class SpeziOnboardingTests: XCTestCase { - func testSpeziOnboardingTests() throws { - XCTAssert(true) - } - - @MainActor - func testAnyViewIssue() throws { - let view = Text("Hello World") - .onboardingIdentifier("Custom Identifier") - - XCTAssertFalse((view as Any) is AnyView) - } - +@Suite("SpeziOnboardingTests") +struct SpeziOnboardingTests { + @Test("OnboardingIdentifier ViewModifier") @MainActor func testOnboardingIdentifierModifier() throws { let stack = OnboardingStack { @@ -32,110 +22,119 @@ final class SpeziOnboardingTests: XCTestCase { .onboardingIdentifier("Custom Identifier") } - let identifier = try XCTUnwrap(stack.onboardingNavigationPath.firstOnboardingStepIdentifier) + let identifier = try #require(stack.onboardingNavigationPath.firstOnboardingStepIdentifier) var hasher = Hasher() hasher.combine("Custom Identifier") let final = hasher.finalize() - XCTAssertEqual(identifier.identifierHash, final) + #expect(identifier.identifierHash == final) } - - @MainActor - func testPDFExport() async throws { - let markdownDataFiles: [String] = ["markdown_data_one_page", "markdown_data_two_pages"] - let knownGoodPDFFiles: [String] = ["known_good_pdf_one_page", "known_good_pdf_two_pages"] - - for (markdownPath, knownGoodPDFPath) in zip(markdownDataFiles, knownGoodPDFFiles) { - let markdownData = { - self.loadMarkdownDataFromFile(path: markdownPath) - } - - let exportConfiguration = ConsentDocumentExportRepresentation.Configuration( - paperSize: .dinA4, - consentTitle: "Spezi Onboarding", - includingTimestamp: false - ) - #if !os(macOS) - let documentExport = ConsentDocumentExportRepresentation( - markdown: markdownData(), - signature: .init(), - signatureImage: .init(), - name: PersonNameComponents(givenName: "Leland", familyName: "Stanford"), - formattedSignatureDate: "01/23/25", - documentIdentifier: ConsentDocumentExportRepresentation.Defaults.documentIdentifier, - configuration: exportConfiguration + @Test("PDF Export", arguments: + zip( + ["markdown_data_one_page", "markdown_data_two_pages"], + ["known_good_pdf_one_page", "known_good_pdf_two_pages"] ) - #else - let documentExport = ConsentDocumentExportRepresentation( - markdown: markdownData(), - signature: "Stanford", - name: PersonNameComponents(givenName: "Leland", familyName: "Stanford"), - formattedSignatureDate: "01/23/25", - documentIdentifier: ConsentDocumentExportRepresentation.Defaults.documentIdentifier, - configuration: exportConfiguration - ) - #endif - - #if os(macOS) - let pdfPath = knownGoodPDFPath + "_mac_os" - #elseif os(visionOS) - let pdfPath = knownGoodPDFPath + "_vision_os" - #else - let pdfPath = knownGoodPDFPath + "_ios" - #endif - - let knownGoodPdf = loadPDFFromPath(path: pdfPath) - - let pdf = try documentExport.render() - XCTAssert(comparePDFDocuments(pdf1: pdf, pdf2: knownGoodPdf)) - } + ) + func testPDFExport(markdownPath: String, knownGoodPDFPath: String) async throws { + let exportConfiguration = ConsentDocumentExportRepresentation.Configuration( + paperSize: .dinA4, + consentTitle: "Spezi Onboarding", + includingTimestamp: false + ) + + #if !os(macOS) + let documentExport = ConsentDocumentExportRepresentation( + markdown: try #require(self.loadMarkdownDataFromFile(path: markdownPath)), + signature: .init(), + name: PersonNameComponents(givenName: "Leland", familyName: "Stanford"), + formattedSignatureDate: "01/23/25", + documentIdentifier: ConsentDocumentExportRepresentation.Configuration.Defaults.documentIdentifier, + configuration: exportConfiguration + ) + #else + let documentExport = ConsentDocumentExportRepresentation( + markdown: try #require(self.loadMarkdownDataFromFile(path: markdownPath)), + signature: "Stanford", + name: PersonNameComponents(givenName: "Leland", familyName: "Stanford"), + formattedSignatureDate: "01/23/25", + documentIdentifier: ConsentDocumentExportRepresentation.Configuration.Defaults.documentIdentifier, + configuration: exportConfiguration + ) + #endif + + #if os(macOS) + let pdfPath = knownGoodPDFPath + "_mac_os" + #elseif os(visionOS) + let pdfPath = knownGoodPDFPath + "_vision_os" + #else + let pdfPath = knownGoodPDFPath + "_ios" + #endif + + let knownGoodPdf = try #require(loadPDFFromPath(path: pdfPath)) + let renderedPdf = try documentExport.render() + + #expect(renderedPdf.equatable == knownGoodPdf.equatable) } - - private func loadMarkdownDataFromFile(path: String) -> Data { + + private func loadMarkdownDataFromFile(path: String) -> Data? { let bundle = Bundle.module // Access the test bundle guard let fileURL = bundle.url(forResource: path, withExtension: "md") else { - XCTFail("Failed to load \(path).md from resources.") - return Data() + Issue.record("Failed to load \(path).md from resources.") + return nil } - + // Load the content of the file into Data var markdownData = Data() do { markdownData = try Data(contentsOf: fileURL) } catch { - XCTFail("Failed to read \(path).md from resources: \(error.localizedDescription)") + Issue.record("Failed to read \(path).md from resources: \(error.localizedDescription)") + return nil } + return markdownData } - - private func loadPDFFromPath(path: String) -> PDFDocument { + + private func loadPDFFromPath(path: String) -> PDFDocument? { let bundle = Bundle.module // Access the test bundle guard let url = bundle.url(forResource: path, withExtension: "pdf") else { - XCTFail("Failed to locate \(path) in resources.") - return .init() + Issue.record("Failed to locate \(path) in resources.") + return nil } - + guard let knownGoodPdf = PDFDocument(url: url) else { - XCTFail("Failed to load \(path) from resources.") - return .init() + Issue.record("Failed to load \(path) from resources.") + return nil } + return knownGoodPdf } - - private func comparePDFDocuments(pdf1: PDFDocument, pdf2: PDFDocument) -> Bool { +} + +// Wrapper type to have a proper `Equatable` conformance of the `PDFDocument` +struct PDFEquatableDocument: Equatable { + let pdf: PDFDocument + + + init(_ pdf: PDFDocument) { + self.pdf = pdf + } + + + static func == (lhs: PDFEquatableDocument, rhs: PDFEquatableDocument) -> Bool { // Check if both documents have the same number of pages - guard pdf1.pageCount == pdf2.pageCount else { + guard lhs.pdf.pageCount == rhs.pdf.pageCount else { return false } // Iterate through each page and compare their contents - for index in 0.. Bool { - // Get the document directory path - let fileManager = FileManager.default - guard let documentsDirectory = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first else { - print("Could not find the documents directory.") - return false - } - - // Create the full file path - let filePath = documentsDirectory.appendingPathComponent("\(fileName).pdf") - - // Attempt to write the PDF document to the file path - if pdfDocument.write(to: filePath) { - print("PDF saved successfully at: \(filePath)") - return true - } else { - print("Failed to save PDF.") - return false +extension PDFDocument { + var equatable: PDFEquatableDocument { + .init(self) } } -} From ce44c56115fc28727aa768784ad596a4c9314317 Mon Sep 17 00:00:00 2001 From: Philipp Zagar Date: Fri, 24 Jan 2025 11:04:37 +0100 Subject: [PATCH 16/24] More cleanup --- .../UITests/TestApp/OnboardingTestsView.swift | 1 + ...boardingConsentMarkdownRenderingView.swift | 0 .../OnboardingConsentMarkdownTestView.swift | 0 .../OnboardingFlow+PreviewSimulator.swift | 0 .../OnboardingConditionalTestView.swift | 0 .../OnboardingCustomTestView1.swift | 0 .../OnboardingCustomTestView2.swift | 0 .../OnboardingCustomToggleTestView.swift | 2 +- ...OnboardingIdentifiableTestViewCustom.swift | 0 .../OnboardingTestViewNotIdentifiable.swift | 0 .../UITests/UITests.xcodeproj/project.pbxproj | 34 ++++++++++++++----- 11 files changed, 27 insertions(+), 10 deletions(-) rename Tests/UITests/TestApp/Views/{ => Consent}/OnboardingConsentMarkdownRenderingView.swift (100%) rename Tests/UITests/TestApp/Views/{ => Consent}/OnboardingConsentMarkdownTestView.swift (100%) rename Tests/UITests/TestApp/Views/{ => Helpers}/OnboardingFlow+PreviewSimulator.swift (100%) rename Tests/UITests/TestApp/Views/{ => OnboardingFlow}/OnboardingConditionalTestView.swift (100%) rename Tests/UITests/TestApp/Views/{ => OnboardingFlow}/OnboardingCustomTestView1.swift (100%) rename Tests/UITests/TestApp/Views/{ => OnboardingFlow}/OnboardingCustomTestView2.swift (100%) rename Tests/UITests/TestApp/Views/{ => OnboardingFlow}/OnboardingCustomToggleTestView.swift (86%) rename Tests/UITests/TestApp/Views/{ => OnboardingFlow}/OnboardingIdentifiableTestViewCustom.swift (100%) rename Tests/UITests/TestApp/Views/{ => OnboardingFlow}/OnboardingTestViewNotIdentifiable.swift (100%) diff --git a/Tests/UITests/TestApp/OnboardingTestsView.swift b/Tests/UITests/TestApp/OnboardingTestsView.swift index f828697..27e85b8 100644 --- a/Tests/UITests/TestApp/OnboardingTestsView.swift +++ b/Tests/UITests/TestApp/OnboardingTestsView.swift @@ -15,6 +15,7 @@ struct OnboardingTestsView: View { @Binding var onboardingFlowComplete: Bool @State var showConditionalView = false + var body: some View { OnboardingStack(onboardingFlowComplete: $onboardingFlowComplete) { OnboardingStartTestView( diff --git a/Tests/UITests/TestApp/Views/OnboardingConsentMarkdownRenderingView.swift b/Tests/UITests/TestApp/Views/Consent/OnboardingConsentMarkdownRenderingView.swift similarity index 100% rename from Tests/UITests/TestApp/Views/OnboardingConsentMarkdownRenderingView.swift rename to Tests/UITests/TestApp/Views/Consent/OnboardingConsentMarkdownRenderingView.swift diff --git a/Tests/UITests/TestApp/Views/OnboardingConsentMarkdownTestView.swift b/Tests/UITests/TestApp/Views/Consent/OnboardingConsentMarkdownTestView.swift similarity index 100% rename from Tests/UITests/TestApp/Views/OnboardingConsentMarkdownTestView.swift rename to Tests/UITests/TestApp/Views/Consent/OnboardingConsentMarkdownTestView.swift diff --git a/Tests/UITests/TestApp/Views/OnboardingFlow+PreviewSimulator.swift b/Tests/UITests/TestApp/Views/Helpers/OnboardingFlow+PreviewSimulator.swift similarity index 100% rename from Tests/UITests/TestApp/Views/OnboardingFlow+PreviewSimulator.swift rename to Tests/UITests/TestApp/Views/Helpers/OnboardingFlow+PreviewSimulator.swift diff --git a/Tests/UITests/TestApp/Views/OnboardingConditionalTestView.swift b/Tests/UITests/TestApp/Views/OnboardingFlow/OnboardingConditionalTestView.swift similarity index 100% rename from Tests/UITests/TestApp/Views/OnboardingConditionalTestView.swift rename to Tests/UITests/TestApp/Views/OnboardingFlow/OnboardingConditionalTestView.swift diff --git a/Tests/UITests/TestApp/Views/OnboardingCustomTestView1.swift b/Tests/UITests/TestApp/Views/OnboardingFlow/OnboardingCustomTestView1.swift similarity index 100% rename from Tests/UITests/TestApp/Views/OnboardingCustomTestView1.swift rename to Tests/UITests/TestApp/Views/OnboardingFlow/OnboardingCustomTestView1.swift diff --git a/Tests/UITests/TestApp/Views/OnboardingCustomTestView2.swift b/Tests/UITests/TestApp/Views/OnboardingFlow/OnboardingCustomTestView2.swift similarity index 100% rename from Tests/UITests/TestApp/Views/OnboardingCustomTestView2.swift rename to Tests/UITests/TestApp/Views/OnboardingFlow/OnboardingCustomTestView2.swift diff --git a/Tests/UITests/TestApp/Views/OnboardingCustomToggleTestView.swift b/Tests/UITests/TestApp/Views/OnboardingFlow/OnboardingCustomToggleTestView.swift similarity index 86% rename from Tests/UITests/TestApp/Views/OnboardingCustomToggleTestView.swift rename to Tests/UITests/TestApp/Views/OnboardingFlow/OnboardingCustomToggleTestView.swift index 60491d7..e59c5a7 100644 --- a/Tests/UITests/TestApp/Views/OnboardingCustomToggleTestView.swift +++ b/Tests/UITests/TestApp/Views/OnboardingFlow/OnboardingCustomToggleTestView.swift @@ -21,7 +21,7 @@ struct OnboardingCustomToggleTestView: View { Text("Next") } - /// We need to use a custom-built toggle as UI tests are very flakey when clicking on SwiftUI `Toggle`'s + // We need to use a custom-built toggle as UI tests are very flakey when clicking on SwiftUI `Toggle`'s CustomToggleView( text: "Show Conditional View", condition: $showConditionalView diff --git a/Tests/UITests/TestApp/Views/OnboardingIdentifiableTestViewCustom.swift b/Tests/UITests/TestApp/Views/OnboardingFlow/OnboardingIdentifiableTestViewCustom.swift similarity index 100% rename from Tests/UITests/TestApp/Views/OnboardingIdentifiableTestViewCustom.swift rename to Tests/UITests/TestApp/Views/OnboardingFlow/OnboardingIdentifiableTestViewCustom.swift diff --git a/Tests/UITests/TestApp/Views/OnboardingTestViewNotIdentifiable.swift b/Tests/UITests/TestApp/Views/OnboardingFlow/OnboardingTestViewNotIdentifiable.swift similarity index 100% rename from Tests/UITests/TestApp/Views/OnboardingTestViewNotIdentifiable.swift rename to Tests/UITests/TestApp/Views/OnboardingFlow/OnboardingTestViewNotIdentifiable.swift diff --git a/Tests/UITests/UITests.xcodeproj/project.pbxproj b/Tests/UITests/UITests.xcodeproj/project.pbxproj index 8b7f282..1b6d1af 100644 --- a/Tests/UITests/UITests.xcodeproj/project.pbxproj +++ b/Tests/UITests/UITests.xcodeproj/project.pbxproj @@ -88,6 +88,28 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 10BD9F752D439C6700E9282B /* Consent */ = { + isa = PBXGroup; + children = ( + C49959772C6C8C1F008E5256 /* OnboardingConsentMarkdownRenderingView.swift */, + C49959782C6C8C1F008E5256 /* OnboardingConsentMarkdownTestView.swift */, + ); + path = Consent; + sourceTree = ""; + }; + 10BD9F762D439C8800E9282B /* OnboardingFlow */ = { + isa = PBXGroup; + children = ( + 97A8FF2B2A74449F008CD91A /* OnboardingCustomTestView1.swift */, + 97A8FF2D2A7444FC008CD91A /* OnboardingCustomTestView2.swift */, + 970D44542A6F119600756FE2 /* OnboardingConditionalTestView.swift */, + 61D77B532BC83F0100E3165F /* OnboardingCustomToggleTestView.swift */, + 61040A1B2BAFA2F600EDD4EC /* OnboardingIdentifiableTestViewCustom.swift */, + 61F1697D2BCA888600D1622B /* OnboardingTestViewNotIdentifiable.swift */, + ); + path = OnboardingFlow; + sourceTree = ""; + }; 2F6D138928F5F384007C25D6 = { isa = PBXGroup; children = ( @@ -141,19 +163,12 @@ 970D44472A6F02E800756FE2 /* Views */ = { isa = PBXGroup; children = ( - C49959772C6C8C1F008E5256 /* OnboardingConsentMarkdownRenderingView.swift */, - C49959782C6C8C1F008E5256 /* OnboardingConsentMarkdownTestView.swift */, - 61D77B532BC83F0100E3165F /* OnboardingCustomToggleTestView.swift */, - 61040A1B2BAFA2F600EDD4EC /* OnboardingIdentifiableTestViewCustom.swift */, + 10BD9F762D439C8800E9282B /* OnboardingFlow */, + 10BD9F752D439C6700E9282B /* Consent */, 97A8FF2F2A74606A008CD91A /* Helpers */, 970D44522A6F0B1900756FE2 /* OnboardingStartTestView.swift */, 970D444E2A6F048A00756FE2 /* OnboardingWelcomeTestView.swift */, 970D44502A6F04ED00756FE2 /* OnboardingSequentialTestView.swift */, - 97A8FF2B2A74449F008CD91A /* OnboardingCustomTestView1.swift */, - 97A8FF2D2A7444FC008CD91A /* OnboardingCustomTestView2.swift */, - 970D44542A6F119600756FE2 /* OnboardingConditionalTestView.swift */, - 97C6AF7E2ACC94450060155B /* OnboardingFlow+PreviewSimulator.swift */, - 61F1697D2BCA888600D1622B /* OnboardingTestViewNotIdentifiable.swift */, ); path = Views; sourceTree = ""; @@ -162,6 +177,7 @@ isa = PBXGroup; children = ( 97A8FF302A74607F008CD91A /* CustomToggleView.swift */, + 97C6AF7E2ACC94450060155B /* OnboardingFlow+PreviewSimulator.swift */, ); path = Helpers; sourceTree = ""; From 4f6f7b5fbe4c3e2980a8a77413c76848859ce709 Mon Sep 17 00:00:00 2001 From: Philipp Zagar Date: Fri, 24 Jan 2025 11:09:02 +0100 Subject: [PATCH 17/24] linter --- .../SpeziOnboardingTests+PDFEquatable.swift | 52 +++++++++++++++++++ .../SpeziOnboardingTests.swift | 42 --------------- 2 files changed, 52 insertions(+), 42 deletions(-) create mode 100644 Tests/SpeziOnboardingTests/SpeziOnboardingTests+PDFEquatable.swift diff --git a/Tests/SpeziOnboardingTests/SpeziOnboardingTests+PDFEquatable.swift b/Tests/SpeziOnboardingTests/SpeziOnboardingTests+PDFEquatable.swift new file mode 100644 index 0000000..941528f --- /dev/null +++ b/Tests/SpeziOnboardingTests/SpeziOnboardingTests+PDFEquatable.swift @@ -0,0 +1,52 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import PDFKit + + +// Wrapper type to have a proper `Equatable` conformance of the `PDFDocument` +struct PDFEquatableDocument: Equatable { + let pdf: PDFDocument + + + init(_ pdf: PDFDocument) { + self.pdf = pdf + } + + + static func == (lhs: PDFEquatableDocument, rhs: PDFEquatableDocument) -> Bool { + // Check if both documents have the same number of pages + guard lhs.pdf.pageCount == rhs.pdf.pageCount else { + return false + } + + // Iterate through each page and compare their contents + for index in 0.. Bool { - // Check if both documents have the same number of pages - guard lhs.pdf.pageCount == rhs.pdf.pageCount else { - return false - } - - // Iterate through each page and compare their contents - for index in 0.. Date: Fri, 24 Jan 2025 12:01:21 +0100 Subject: [PATCH 18/24] Make tests more robust --- .../TestAppUITests/SpeziOnboardingTests.swift | 10 ++++++---- .../XCUIApplication+Onboarding.swift | 14 +++++++------- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/Tests/UITests/TestAppUITests/SpeziOnboardingTests.swift b/Tests/UITests/TestAppUITests/SpeziOnboardingTests.swift index 377d2e3..2f3f4ca 100644 --- a/Tests/UITests/TestAppUITests/SpeziOnboardingTests.swift +++ b/Tests/UITests/TestAppUITests/SpeziOnboardingTests.swift @@ -223,13 +223,13 @@ final class OnboardingTests: XCTestCase { // Check if on custom test view 1 XCTAssert(app.staticTexts["Custom Test View 1: Hello Spezi!"].waitForExistence(timeout: 2)) - XCTAssert(app.buttons["Next"].exists) + XCTAssert(app.buttons["Next"].waitForExistence(timeout: 2)) app.buttons["Next"].tap() // Check if on custom test view 2 XCTAssert(app.staticTexts["Custom Test View 2"].waitForExistence(timeout: 2)) - XCTAssert(app.buttons["Next"].exists) + XCTAssert(app.buttons["Next"].waitForExistence(timeout: 2)) app.buttons["Next"].tap() // Check if on welcome onboarding view @@ -279,12 +279,12 @@ final class OnboardingTests: XCTestCase { XCTAssert(app.buttons["Show Conditional View"].waitForExistence(timeout: 2)) app.buttons["Show Conditional View"].tap() - XCTAssert(app.buttons["Next"].exists) + XCTAssert(app.buttons["Next"].waitForExistence(timeout: 2)) app.buttons["Next"].tap() // Check if on conditional test view XCTAssert(app.staticTexts["Conditional Test View"].waitForExistence(timeout: 2)) - XCTAssert(app.buttons["Next"].exists) + XCTAssert(app.buttons["Next"].waitForExistence(timeout: 2)) app.buttons["Next"].tap() // Check if on final page @@ -301,9 +301,11 @@ final class OnboardingTests: XCTestCase { app.buttons["Onboarding Identifiable View"].tap() XCTAssert(app.staticTexts["ID: 1"].waitForExistence(timeout: 2)) + XCTAssert(app.buttons["Next"].waitForExistence(timeout: 2)) app.buttons["Next"].tap() XCTAssert(app.staticTexts["ID: 2"].waitForExistence(timeout: 2)) + XCTAssert(app.buttons["Next"].waitForExistence(timeout: 2)) app.buttons["Next"].tap() XCTAssert(app.staticTexts["Welcome"].waitForExistence(timeout: 2)) diff --git a/Tests/UITests/TestAppUITests/XCUIApplication+Onboarding.swift b/Tests/UITests/TestAppUITests/XCUIApplication+Onboarding.swift index e6cef4a..4abdf11 100644 --- a/Tests/UITests/TestAppUITests/XCUIApplication+Onboarding.swift +++ b/Tests/UITests/TestAppUITests/XCUIApplication+Onboarding.swift @@ -34,7 +34,7 @@ extension XCUIApplication { // Check if on consent export page XCTAssert(staticTexts["First Consent PDF rendering doesn't exist"].waitForExistence(timeout: 2)) - XCTAssert(buttons["Next"].exists) + XCTAssert(buttons["Next"].waitForExistence(timeout: 2)) buttons["Next"].tap() try consentViewOnboardingFlow(consentTitle: "Second Consent", markdownText: "This is the second markdown example") @@ -52,7 +52,7 @@ extension XCUIApplication { // Check if on conditional test view XCTAssert(staticTexts["Conditional Test View"].waitForExistence(timeout: 2)) - XCTAssert(buttons["Next"].exists) + XCTAssert(buttons["Next"].waitForExistence(timeout: 2)) buttons["Next"].tap() } @@ -62,16 +62,16 @@ extension XCUIApplication { func consentViewOnboardingFlow(consentTitle: String, markdownText: String) throws { XCTAssert(staticTexts[consentTitle].waitForExistence(timeout: 2)) - XCTAssert(staticTexts[markdownText].exists) + XCTAssert(staticTexts[markdownText].waitForExistence(timeout: 2)) #if targetEnvironment(simulator) && (arch(i386) || arch(x86_64)) throw XCTSkip("PKCanvas view-related tests are currently skipped on Intel-based iOS simulators due to a metal bug on the simulator.") #endif - XCTAssert(staticTexts["First Name"].exists) + XCTAssert(staticTexts["First Name"].waitForExistence(timeout: 2)) try textFields["Enter your first name ..."].enter(value: "Leland") - XCTAssert(staticTexts["Last Name"].exists) + XCTAssert(staticTexts["Last Name"].waitForExistence(timeout: 2)) try textFields["Enter your last name ..."].enter(value: "Stanford") hitConsentButton() @@ -85,10 +85,10 @@ extension XCUIApplication { XCTAssertTrue(buttons["Undo"].isEnabled) buttons["Undo"].tap() - XCTAssert(scrollViews["Signature Field"].exists) + XCTAssert(scrollViews["Signature Field"].waitForExistence(timeout: 2)) scrollViews["Signature Field"].swipeRight() #else - XCTAssert(textFields["Signature Field"].exists) + XCTAssert(textFields["Signature Field"].waitForExistence(timeout: 2)) try textFields["Signature Field"].enter(value: "Leland Stanford") #endif From 97c933d287757b7bc008711ec2788d289159d38c Mon Sep 17 00:00:00 2001 From: Philipp Zagar Date: Sat, 25 Jan 2025 21:43:41 +0100 Subject: [PATCH 19/24] Remove OnboardingDataSource and ConsentContraint. --- Package.swift | 10 ++- .../Consent/ConsentConstraint.swift | 21 ----- .../Export/ConsentDocument+Export.swift | 2 - ...ntExportRepresentation+Configuration.swift | 4 +- ...ocumentExportRepresentation+Defaults.swift | 8 +- .../ConsentDocumentExportRepresentation.swift | 13 +-- ...ConsentDocument+LocalizationDefaults.swift | 3 - .../Consent/Views/ConsentDocument.swift | 8 +- .../Consent/Views/SignatureView.swift | 2 +- .../OnboardingConsentView.swift | 56 +++++------- .../OnboardingDataSource.swift | 53 ------------ .../OnboardingIdentifiableViewModifier.swift | 1 - .../Resources/Localizable.xcstrings | 85 ++++++++----------- .../ObtainingUserConsent.md | 1 - .../SpeziOnboarding.docc/SpeziOnboarding.md | 5 -- .../SpeziOnboardingTests.swift | 2 - Tests/UITests/TestApp/ExampleStandard.swift | 57 +------------ .../UITests/TestApp/OnboardingTestsView.swift | 16 ++-- Tests/UITests/TestApp/TestAppDelegate.swift | 4 +- .../Consent/ConsentDocumentIdentifiers.swift | 5 ++ ...ConsentMarkdownFinishedRenderedView.swift} | 19 +++-- .../OnboardingConsentMarkdownTestView.swift | 19 +++-- .../OnboardingFlow+PreviewSimulator.swift | 16 ++-- .../Views/OnboardingStartTestView.swift | 2 +- .../UITests/UITests.xcodeproj/project.pbxproj | 12 ++- 25 files changed, 125 insertions(+), 299 deletions(-) delete mode 100644 Sources/SpeziOnboarding/Consent/ConsentConstraint.swift delete mode 100644 Sources/SpeziOnboarding/OnboardingDataSource.swift rename Sources/SpeziOnboarding/{ => OnboardingFlow}/OnboardingIdentifiableViewModifier.swift (99%) create mode 100644 Tests/UITests/TestApp/Views/Consent/ConsentDocumentIdentifiers.swift rename Tests/UITests/TestApp/Views/Consent/{OnboardingConsentMarkdownRenderingView.swift => OnboardingConsentMarkdownFinishedRenderedView.swift} (74%) diff --git a/Package.swift b/Package.swift index 0d94515..7f2ce02 100644 --- a/Package.swift +++ b/Package.swift @@ -24,16 +24,18 @@ let package = Package( .library(name: "SpeziOnboarding", targets: ["SpeziOnboarding"]) ], dependencies: [ - .package(url: "https://github.com/StanfordSpezi/Spezi.git", from: "1.8.0"), - .package(url: "https://github.com/StanfordSpezi/SpeziViews.git", from: "1.8.0"), - .package(url: "https://github.com/apple/swift-collections.git", from: "1.1.4"), - .package(url: "https://github.com/techprimate/TPPDF.git", from: "2.6.1") + .package(url: "https://github.com/StanfordSpezi/Spezi", from: "1.8.0"), + .package(url: "https://github.com/StanfordSpezi/SpeziFoundation", from: "2.1.0"), + .package(url: "https://github.com/StanfordSpezi/SpeziViews", from: "1.9.0"), + .package(url: "https://github.com/apple/swift-collections", from: "1.1.4"), + .package(url: "https://github.com/techprimate/TPPDF", from: "2.6.1") ] + swiftLintPackage(), targets: [ .target( name: "SpeziOnboarding", dependencies: [ .product(name: "Spezi", package: "Spezi"), + .product(name: "SpeziFoundation", package: "SpeziFoundation"), .product(name: "SpeziViews", package: "SpeziViews"), .product(name: "SpeziPersonalInfo", package: "SpeziViews"), .product(name: "OrderedCollections", package: "swift-collections"), diff --git a/Sources/SpeziOnboarding/Consent/ConsentConstraint.swift b/Sources/SpeziOnboarding/Consent/ConsentConstraint.swift deleted file mode 100644 index c858229..0000000 --- a/Sources/SpeziOnboarding/Consent/ConsentConstraint.swift +++ /dev/null @@ -1,21 +0,0 @@ -// -// This source file is part of the Stanford Spezi open-source project -// -// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) -// -// SPDX-License-Identifier: MIT -// - -import Foundation -import PDFKit -import Spezi - - -/// A constraint which all `Standard` instances must conform to when using the `OnboardingConsentView`. -public protocol ConsentConstraint: Standard { - /// Adds a new exported consent form represented as `PDFDocument` to the `Standard` conforming to ``ConsentConstraint``. - /// - /// - Parameters: - /// - consent: The exported consent form represented as ``ConsentDocumentExportRepresentation`` that should be added. - func store(consent: consuming sending ConsentDocumentExportRepresentation) async throws -} diff --git a/Sources/SpeziOnboarding/Consent/Export/ConsentDocument+Export.swift b/Sources/SpeziOnboarding/Consent/Export/ConsentDocument+Export.swift index ff84556..37a6f8b 100644 --- a/Sources/SpeziOnboarding/Consent/Export/ConsentDocument+Export.swift +++ b/Sources/SpeziOnboarding/Consent/Export/ConsentDocument+Export.swift @@ -22,7 +22,6 @@ extension ConsentDocument { signature: signatureImage, name: self.name, formattedSignatureDate: self.formattedConsentSignatureDate, - documentIdentifier: self.documentIdentifier, configuration: self.exportConfiguration ) #else @@ -31,7 +30,6 @@ extension ConsentDocument { signature: self.signature, name: self.name, formattedSignatureDate: self.formattedConsentSignatureDate, - documentIdentifier: self.documentIdentifier, configuration: self.exportConfiguration ) #endif diff --git a/Sources/SpeziOnboarding/Consent/Export/ConsentDocumentExportRepresentation+Configuration.swift b/Sources/SpeziOnboarding/Consent/Export/ConsentDocumentExportRepresentation+Configuration.swift index 4bb962d..b3530fc 100644 --- a/Sources/SpeziOnboarding/Consent/Export/ConsentDocumentExportRepresentation+Configuration.swift +++ b/Sources/SpeziOnboarding/Consent/Export/ConsentDocumentExportRepresentation+Configuration.swift @@ -131,7 +131,7 @@ extension ConsentDocumentExportRepresentation { } #endif - + let consentTitle: LocalizedStringResource let paperSize: PaperSize let includingTimestamp: Bool @@ -147,7 +147,7 @@ extension ConsentDocumentExportRepresentation { /// - fontSettings: Font settings for the exported form. public init( paperSize: PaperSize = .usLetter, - consentTitle: LocalizedStringResource = ConsentDocument.LocalizationDefaults.exportedConsentFormTitle, + consentTitle: LocalizedStringResource = Configuration.Defaults.exportedConsentFormTitle, includingTimestamp: Bool = true, fontSettings: FontSettings = Configuration.Defaults.defaultExportFontSettings ) { diff --git a/Sources/SpeziOnboarding/Consent/Export/ConsentDocumentExportRepresentation+Defaults.swift b/Sources/SpeziOnboarding/Consent/Export/ConsentDocumentExportRepresentation+Defaults.swift index 9a08073..4d195e9 100644 --- a/Sources/SpeziOnboarding/Consent/Export/ConsentDocumentExportRepresentation+Defaults.swift +++ b/Sources/SpeziOnboarding/Consent/Export/ConsentDocumentExportRepresentation+Defaults.swift @@ -13,11 +13,9 @@ import SwiftUI extension ConsentDocumentExportRepresentation.Configuration { /// Provides default values for fields related to the ``ConsentDocumentExportRepresentation/Configuration``. public enum Defaults { - /// Default value for a document identifier. - /// - /// This identifier will be used as default value if no identifier is provided. - public static let documentIdentifier = "ConsentDocument" - + /// Default localized value for the title of the exported consent form. + public static let exportedConsentFormTitle = LocalizedStringResource("CONSENT_TITLE", bundle: .atURL(from: .module)) + #if !os(macOS) /// Default export font settings with fixed font sizes, ensuring a consistent appearance across platforms. /// diff --git a/Sources/SpeziOnboarding/Consent/Export/ConsentDocumentExportRepresentation.swift b/Sources/SpeziOnboarding/Consent/Export/ConsentDocumentExportRepresentation.swift index 82e1b7c..0d2f6da 100644 --- a/Sources/SpeziOnboarding/Consent/Export/ConsentDocumentExportRepresentation.swift +++ b/Sources/SpeziOnboarding/Consent/Export/ConsentDocumentExportRepresentation.swift @@ -13,14 +13,9 @@ import SwiftUI /// Represents a to-be-exported ``ConsentDocument``. /// -/// It holds all the content necessary to export the ``ConsentDocument`` as a `PDFDocument` and the corresponding identifier String in ``ConsentDocumentExportRepresentation/documentIdentifier``. +/// It holds all the content necessary to export the ``ConsentDocument`` as a `PDFDocument`. /// Using ``ConsentDocumentExportRepresentation/render()`` performs the rendering of the ``ConsentDocument`` as a PDF. public struct ConsentDocumentExportRepresentation: Equatable { - /// An unique identifier for the exported ``ConsentDocument``. - /// - /// Corresponds to the identifier which was passed when creating the ``ConsentDocument`` using an ``OnboardingConsentView``. - public let documentIdentifier: String - let configuration: Configuration let markdown: Data #if !os(macOS) @@ -40,21 +35,18 @@ public struct ConsentDocumentExportRepresentation: Equatable { /// - signature: The rendered signature image of the consent document. /// - name: The name components of the signature. /// - formattedSignatureDate: The performed `String`-based signature date. - /// - documentIdentifier: A unique `String` identifying the ``ConsentDocumentExportRepresentation`` upon export. /// - exportConfiguration: Holds configuration properties of the to-be-exported document. init( markdown: Data, signature: UIImage, name: PersonNameComponents, formattedSignatureDate: String?, - documentIdentifier: String, configuration: Configuration ) { self.markdown = markdown self.name = name self.signature = signature self.formattedSignatureDate = formattedSignatureDate - self.documentIdentifier = documentIdentifier self.configuration = configuration } #else @@ -65,21 +57,18 @@ public struct ConsentDocumentExportRepresentation: Equatable { /// - signature: The `String`-based signature of the consent document. /// - name: The name components of the signature. /// - formattedSignatureDate: The performed `String`-based signature date. - /// - documentIdentifier: A unique `String` identifying the ``ConsentDocumentExportRepresentation`` upon export. /// - exportConfiguration: Holds configuration properties of the to-be-exported document. init( markdown: Data, signature: String, name: PersonNameComponents, formattedSignatureDate: String?, - documentIdentifier: String, configuration: Configuration ) { self.markdown = markdown self.name = name self.signature = signature self.formattedSignatureDate = formattedSignatureDate - self.documentIdentifier = documentIdentifier self.configuration = configuration } #endif diff --git a/Sources/SpeziOnboarding/Consent/Views/ConsentDocument+LocalizationDefaults.swift b/Sources/SpeziOnboarding/Consent/Views/ConsentDocument+LocalizationDefaults.swift index b6e2dd9..bfdc0e5 100644 --- a/Sources/SpeziOnboarding/Consent/Views/ConsentDocument+LocalizationDefaults.swift +++ b/Sources/SpeziOnboarding/Consent/Views/ConsentDocument+LocalizationDefaults.swift @@ -22,8 +22,5 @@ extension ConsentDocument { public static let familyNameTitle = LocalizedStringResource("NAME_FIELD_FAMILY_NAME_TITLE", bundle: .atURL(from: .module)) /// Default localized placeholder for the family name field of the consent form in the ``ConsentDocument``. public static let familyNamePlaceholder = LocalizedStringResource("NAME_FIELD_FAMILY_NAME_PLACEHOLDER", bundle: .atURL(from: .module)) - - /// Default localized value for the title of the exported consent form. - public static let exportedConsentFormTitle = LocalizedStringResource("CONSENT_TITLE", bundle: .atURL(from: .module)) } } diff --git a/Sources/SpeziOnboarding/Consent/Views/ConsentDocument.swift b/Sources/SpeziOnboarding/Consent/Views/ConsentDocument.swift index f0e7c90..d42ee64 100644 --- a/Sources/SpeziOnboarding/Consent/Views/ConsentDocument.swift +++ b/Sources/SpeziOnboarding/Consent/Views/ConsentDocument.swift @@ -19,14 +19,14 @@ import SwiftUI /// In addition, it enables the export of the signed form as a PDF document. /// /// To observe and control the current state of the `ConsentDocument`, the view requires passing down a ``ConsentViewState`` as a SwiftUI `Binding` in the -/// ``init(markdown:viewState:givenNameTitle:givenNamePlaceholder:familyNameTitle:familyNamePlaceholder:exportConfiguration:documentIdentifier:consentSignatureDate:consentSignatureDateFormatter:)`` initializer. +/// ``init(markdown:viewState:givenNameTitle:givenNamePlaceholder:familyNameTitle:familyNamePlaceholder:exportConfiguration:consentSignatureDate:consentSignatureDateFormatter:)`` initializer. /// /// This `Binding` can then be used to trigger the creation of the export representation of the consent form via setting the state to ``ConsentViewState/export``. /// After the export representation completes, the ``ConsentDocumentExportRepresentation`` is accessible via the associated value of the view state in ``ConsentViewState/exported(representation:)``. /// The ``ConsentDocumentExportRepresentation`` can then be rendered to a PDF via ``ConsentDocumentExportRepresentation/render()``. /// Other possible states of the `ConsentDocument` are the SpeziViews `ViewState`'s accessible via the associated value in ``ConsentViewState/base(_:)``. /// In addition, the view provides information about the signing progress via the ``ConsentViewState/signing`` and ``ConsentViewState/signed`` states, -/// as well as the ``ConsentViewState/storing`` state that indicates the current storage process via the ``ConsentConstraint``. +/// as well as the ``ConsentViewState/storing`` state that indicates the current storage process of the exported document. /// /// ```swift /// // Enables observing the view state of the consent document @@ -54,7 +54,6 @@ public struct ConsentDocument: View { let markdown: () async -> Data let exportConfiguration: ConsentDocumentExportRepresentation.Configuration - let documentIdentifier: String @Environment(\.colorScheme) var colorScheme @State var name = PersonNameComponents() @@ -219,7 +218,6 @@ public struct ConsentDocument: View { /// - familyNameTitle: The localization to use for the family (last) name field. /// - familyNamePlaceholder: The localization to use for the family name field placeholder. /// - exportConfiguration: Defines the properties of the exported consent form via ``ConsentDocumentExportRepresentation/Configuration``. - /// - documentIdentifier: A unique identifier or "name" for the consent form, helpful for distinguishing consent forms when storing in the `Standard`. /// - consentSignatureDate: The date that is displayed under the signature line. /// - consentSignatureDateFormatter: The date formatter used to format the date that is displayed under the signature line. public init( @@ -230,7 +228,6 @@ public struct ConsentDocument: View { familyNameTitle: LocalizedStringResource = LocalizationDefaults.familyNameTitle, familyNamePlaceholder: LocalizedStringResource = LocalizationDefaults.familyNamePlaceholder, exportConfiguration: ConsentDocumentExportRepresentation.Configuration = .init(), - documentIdentifier: String = ConsentDocumentExportRepresentation.Configuration.Defaults.documentIdentifier, consentSignatureDate: Date? = nil, consentSignatureDateFormatter: DateFormatter = { let formatter = DateFormatter() @@ -245,7 +242,6 @@ public struct ConsentDocument: View { self.familyNameTitle = familyNameTitle self.familyNamePlaceholder = familyNamePlaceholder self.exportConfiguration = exportConfiguration - self.documentIdentifier = documentIdentifier self.consentSignatureDate = consentSignatureDate self.consentSignatureDateFormatter = consentSignatureDateFormatter } diff --git a/Sources/SpeziOnboarding/Consent/Views/SignatureView.swift b/Sources/SpeziOnboarding/Consent/Views/SignatureView.swift index a25b1fc..9b9b29b 100644 --- a/Sources/SpeziOnboarding/Consent/Views/SignatureView.swift +++ b/Sources/SpeziOnboarding/Consent/Views/SignatureView.swift @@ -81,7 +81,7 @@ public struct SignatureView: View { } #if !os(macOS) .onChange(of: isSigning) { - Task { @MainActor in + runOrScheduleOnMainActor { canUndo = undoManager?.canUndo ?? false } } diff --git a/Sources/SpeziOnboarding/OnboardingConsentView.swift b/Sources/SpeziOnboarding/OnboardingConsentView.swift index 7f05ff0..ab17fc8 100644 --- a/Sources/SpeziOnboarding/OnboardingConsentView.swift +++ b/Sources/SpeziOnboarding/OnboardingConsentView.swift @@ -12,6 +12,8 @@ import AppKit import Foundation import PDFKit +import Spezi +import SpeziFoundation import SpeziViews import SwiftUI @@ -21,32 +23,21 @@ import SwiftUI /// signed using a family and given name and a hand drawn signature. /// /// Furthermore, the view includes an export functionality, enabling users to share and store the signed consent form. -/// The exported consent form is automatically stored in the Spezi `Standard`, requiring the `Standard` to conform to the ``ConsentConstraint``. +/// The exported consent form PDF is received via the `action` closure on the ``OnboardingConsentView/init(markdown:action:title:currentDateInSignature:exportConfiguration:)``. /// /// The `OnboardingConsentView` builds on top of the SpeziOnboarding ``ConsentDocument`` /// by providing a more developer-friendly, convenient API with additional functionalities like the share consent option. /// -/// If you want to use multiple `OnboardingConsentView`, you can provide each with an identifier (see below). -/// The identifier allows to distinguish the consent forms in the `Standard`. -/// Any identifier is a string. We recommend storing and grouping consent document identifiers in an enum: -/// ```swift -/// enum DocumentIdentifiers { -/// static let first = "firstConsentDocument" -/// static let second = "secondConsentDocument" -/// } -/// ``` -/// /// ```swift /// OnboardingConsentView( /// markdown: { /// Data("This is a *markdown* **example**".utf8) /// }, -/// action: { +/// action: { exportedConsentPdf in /// // The action that should be performed once the user has provided their consent. +/// // Closure receives the exported consent PDF to persist or upload it. /// }, /// title: "Consent", // Configure the title of the consent view -/// identifier: DocumentIdentifiers.first, // Specify a unique identifier String, preferably bundled -/// // in an enum (see above). Only relevant if more than one OnboardingConsentView is needed. /// exportConfiguration: .init(paperSize: .usLetter), // Configure the properties of the exported consent form /// currentDateInSignature: true // Indicates if the consent signature should include the current date /// ) @@ -61,16 +52,14 @@ public struct OnboardingConsentView: View { } private let markdown: () async -> Data - private let action: () async -> Void + private let action: (_ document: PDFDocument) async throws -> Void private let title: LocalizedStringResource? - private let identifier: String - private let exportConfiguration: ConsentDocumentExportRepresentation.Configuration private let currentDateInSignature: Bool + private let exportConfiguration: ConsentDocumentExportRepresentation.Configuration private var backButtonHidden: Bool { viewState == .storing || (viewState == .export && !willShowShareSheet) } - - @Environment(OnboardingDataSource.self) private var onboardingDataSource + @State private var viewState: ConsentViewState = .base(.idle) @State private var willShowShareSheet = false @State private var showShareSheet = false @@ -91,7 +80,6 @@ public struct OnboardingConsentView: View { markdown: markdown, viewState: $viewState, exportConfiguration: exportConfiguration, - documentIdentifier: identifier, consentSignatureDate: currentDateInSignature ? .now : nil ) .padding(.bottom) @@ -119,12 +107,11 @@ public struct OnboardingConsentView: View { if case .exported(let consentExport) = viewState { if !willShowShareSheet { viewState = .storing - Task { @MainActor in - do { - // Stores the finished consent export representation in the Spezi `Standard`. - try await onboardingDataSource.store(consentExport) - await action() + Task { + do { + // Calls the passed `action` closure with the rendered consent PDF. + try await action(consentExport.render()) viewState = .base(.idle) } catch { // In case of error, go back to previous state. @@ -226,25 +213,22 @@ public struct OnboardingConsentView: View { /// Creates an `OnboardingConsentView` which provides a convenient onboarding view for visualizing, signing, and exporting a consent form. /// - Parameters: /// - markdown: The markdown content provided as an UTF8 encoded `Data` instance that can be provided asynchronously. - /// - action: The action that should be performed once the consent is given. + /// - action: The action that should be performed once the consent is given. Action is called with the exported consent document as a parameter. /// - title: The title of the view displayed at the top. Can be `nil`, meaning no title is displayed. - /// - identifier: A unique identifier or "name" for the consent form, helpful for distinguishing consent forms when storing in the `Standard`. - /// - exportConfiguration: Defines the properties of the exported consent form via ``ConsentDocumentExportRepresentation/Configuration``. /// - currentDateInSignature: Indicates if the consent document should include the current date in the signature field. Defaults to `true`. + /// - exportConfiguration: Defines the properties of the exported consent form via ``ConsentDocumentExportRepresentation/Configuration``. public init( markdown: @escaping () async -> Data, - action: @escaping () async -> Void, + action: @escaping (_ document: PDFDocument) async throws -> Void, title: LocalizedStringResource? = LocalizationDefaults.consentFormTitle, - identifier: String = ConsentDocumentExportRepresentation.Configuration.Defaults.documentIdentifier, - exportConfiguration: ConsentDocumentExportRepresentation.Configuration = .init(), - currentDateInSignature: Bool = true + currentDateInSignature: Bool = true, + exportConfiguration: ConsentDocumentExportRepresentation.Configuration = .init() ) { self.markdown = markdown - self.exportConfiguration = exportConfiguration - self.title = title self.action = action - self.identifier = identifier + self.title = title self.currentDateInSignature = currentDateInSignature + self.exportConfiguration = exportConfiguration } } @@ -257,7 +241,7 @@ public struct OnboardingConsentView: View { NavigationStack { OnboardingConsentView(markdown: { Data("This is a *markdown* **example**".utf8) - }, action: { + }, action: { _ in print("Next") }) } diff --git a/Sources/SpeziOnboarding/OnboardingDataSource.swift b/Sources/SpeziOnboarding/OnboardingDataSource.swift deleted file mode 100644 index 166924a..0000000 --- a/Sources/SpeziOnboarding/OnboardingDataSource.swift +++ /dev/null @@ -1,53 +0,0 @@ -// -// This source file is part of the Stanford Spezi open-source project -// -// SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) -// -// SPDX-License-Identifier: MIT -// - -@preconcurrency import PDFKit -import Spezi -import SwiftUI - - -/// Configuration for the Spezi Onboarding module. -/// -/// Make sure that the `Standard` in your Spezi Application conforms to the ``ConsentConstraint`` -/// protocol to store exported consent forms. -/// ```swift -/// actor ExampleStandard: Standard, ConsentConstraint { -/// func store(consent: consuming sending ConsentDocumentExportRepresentation) async throws -/// let pdf = try consent.render() -/// let documentIdentifier = consent.documentIdentifier -/// // ... -/// } -/// } -/// ``` -/// -/// Use the ``OnboardingDataSource/init()`` initializer to add the data source to your `Configuration`. -/// ```swift -/// class ExampleAppDelegate: SpeziAppDelegate { -/// override var configuration: Configuration { -/// Configuration(standard: ExampleStandard()) { -/// OnboardingDataSource() -/// } -/// } -/// } -/// ``` -public final class OnboardingDataSource: Module, EnvironmentAccessible { - @StandardActor var standard: any ConsentConstraint - - - public init() { } - - - /// Adds a new exported consent form representation ``ConsentDocumentExportRepresentation`` to the ``OnboardingDataSource``. - /// - /// - Parameters: - /// - consent: The exported consent form represented as ``ConsentDocumentExportRepresentation`` that should be added. - @MainActor - public func store(_ consent: consuming sending ConsentDocumentExportRepresentation) async throws { - try await standard.store(consent: consent) - } -} diff --git a/Sources/SpeziOnboarding/OnboardingIdentifiableViewModifier.swift b/Sources/SpeziOnboarding/OnboardingFlow/OnboardingIdentifiableViewModifier.swift similarity index 99% rename from Sources/SpeziOnboarding/OnboardingIdentifiableViewModifier.swift rename to Sources/SpeziOnboarding/OnboardingFlow/OnboardingIdentifiableViewModifier.swift index 1a61d5e..de87312 100644 --- a/Sources/SpeziOnboarding/OnboardingIdentifiableViewModifier.swift +++ b/Sources/SpeziOnboarding/OnboardingFlow/OnboardingIdentifiableViewModifier.swift @@ -57,7 +57,6 @@ extension View { /// } /// } /// ``` - @MainActor public func onboardingIdentifier(_ identifier: ID) -> ModifiedContent> { // For some reason, we need to explicitly spell the return type, otherwise the type will be `AnyView`. // Not sure how that happens, but it does with Xcode 16 toolchain. diff --git a/Sources/SpeziOnboarding/Resources/Localizable.xcstrings b/Sources/SpeziOnboarding/Resources/Localizable.xcstrings index b66c038..9194be0 100644 --- a/Sources/SpeziOnboarding/Resources/Localizable.xcstrings +++ b/Sources/SpeziOnboarding/Resources/Localizable.xcstrings @@ -2,71 +2,27 @@ "sourceLanguage" : "en", "strings" : { "Consent document could not be exported." : { - - }, - "CONSENT_ACTION" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ich Stimme Zu" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "I Consent" - } - } - } - }, - "CONSENT_EXPORT_ERROR_DESCRIPTION" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Die unterschriebene Einwilligung konnte nicht exportiert werden." - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Failed to export the signed consent form." - } - } - } - }, - "CONSENT_EXPORT_ERROR_FAILURE_REASON" : { - "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { "state" : "translated", - "value" : "Der Speicherplatz für das Exportieren der Einwilligung konnte nicht reserviert werden." - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "The system wasn't able to reserve the necessary memory for rendering the consent form." + "value" : "Einwilligung konnte nicht exportiert werden." } } } }, - "CONSENT_EXPORT_ERROR_RECOVERY_SUGGESTION" : { - "extractionState" : "stale", + "CONSENT_ACTION" : { "localizations" : { "de" : { "stringUnit" : { "state" : "translated", - "value" : "Bitte versuche es erneut oder starte die Applikation neu." + "value" : "Ich Stimme Zu" } }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "Please try again or restart the application." + "value" : "I Consent" } } } @@ -248,7 +204,14 @@ } }, "Please try exporting the consent document again." : { - + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bitte versucht das Dokument erneut zu exportieren." + } + } + } }, "SEQUENTIAL_ONBOARDING_NEXT" : { "localizations" : { @@ -267,7 +230,20 @@ } }, "SIGNATURE_DATE %@" : { - + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Unterschrift Datum %@" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Signature date %@" + } + } + } }, "SIGNATURE_FIELD" : { "localizations" : { @@ -318,7 +294,14 @@ } }, "The PDF generation from the consent document failed. " : { - + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Der PDF export des Einwilligungsdokuments ist fehlgeschlagen." + } + } + } } }, "version" : "1.0" diff --git a/Sources/SpeziOnboarding/SpeziOnboarding.docc/ObtainingUserConsent.md b/Sources/SpeziOnboarding/SpeziOnboarding.docc/ObtainingUserConsent.md index c9296ae..f4c5e9a 100644 --- a/Sources/SpeziOnboarding/SpeziOnboarding.docc/ObtainingUserConsent.md +++ b/Sources/SpeziOnboarding/SpeziOnboarding.docc/ObtainingUserConsent.md @@ -92,4 +92,3 @@ OnboardingConsentView( - ``ConsentViewState`` - ``ConsentDocumentExportRepresentation`` -- ``ConsentConstraint`` diff --git a/Sources/SpeziOnboarding/SpeziOnboarding.docc/SpeziOnboarding.md b/Sources/SpeziOnboarding/SpeziOnboarding.docc/SpeziOnboarding.md index 7507f18..9a29302 100644 --- a/Sources/SpeziOnboarding/SpeziOnboarding.docc/SpeziOnboarding.md +++ b/Sources/SpeziOnboarding/SpeziOnboarding.docc/SpeziOnboarding.md @@ -184,8 +184,3 @@ struct ConsentViewExample: View { - ``ConsentDocument`` - ``ConsentViewState`` - ``SignatureView`` - -### Data Flow - -- ``OnboardingDataSource`` -- ``ConsentConstraint`` diff --git a/Tests/SpeziOnboardingTests/SpeziOnboardingTests.swift b/Tests/SpeziOnboardingTests/SpeziOnboardingTests.swift index b1ea52a..57c4122 100644 --- a/Tests/SpeziOnboardingTests/SpeziOnboardingTests.swift +++ b/Tests/SpeziOnboardingTests/SpeziOnboardingTests.swift @@ -49,7 +49,6 @@ struct SpeziOnboardingTests { signature: .init(), name: PersonNameComponents(givenName: "Leland", familyName: "Stanford"), formattedSignatureDate: "01/23/25", - documentIdentifier: ConsentDocumentExportRepresentation.Configuration.Defaults.documentIdentifier, configuration: exportConfiguration ) #else @@ -58,7 +57,6 @@ struct SpeziOnboardingTests { signature: "Stanford", name: PersonNameComponents(givenName: "Leland", familyName: "Stanford"), formattedSignatureDate: "01/23/25", - documentIdentifier: ConsentDocumentExportRepresentation.Configuration.Defaults.documentIdentifier, configuration: exportConfiguration ) #endif diff --git a/Tests/UITests/TestApp/ExampleStandard.swift b/Tests/UITests/TestApp/ExampleStandard.swift index 6f278f6..50d6b65 100644 --- a/Tests/UITests/TestApp/ExampleStandard.swift +++ b/Tests/UITests/TestApp/ExampleStandard.swift @@ -8,63 +8,10 @@ import PDFKit import Spezi -import SpeziOnboarding -import SwiftUI - - -// An enum to hold identifier strings to identify two separate consent documents. -enum DocumentIdentifiers { - static let first = "firstConsentDocument" - static let second = "secondConsentDocument" -} /// An example `Standard` used for the configuration. actor ExampleStandard: Standard, EnvironmentAccessible { - @MainActor var firstConsentData: PDFDocument = .init() - @MainActor var secondConsentData: PDFDocument = .init() -} - -extension ExampleStandard: ConsentConstraint { - func store(consent: consuming sending ConsentDocumentExportRepresentation) async throws { - let documentIdentifier = consent.documentIdentifier - let pdf = try consent.render() - - try await self.store(document: pdf, for: documentIdentifier) - try? await Task.sleep(for: .seconds(0.5)) // Simulates storage delay - } - - @MainActor - func store(document pdf: sending PDFDocument, for documentIdentifier: String) throws { - if documentIdentifier == DocumentIdentifiers.first { - self.firstConsentData = pdf - } else if documentIdentifier == DocumentIdentifiers.second { - self.secondConsentData = pdf - } else { - preconditionFailure("Unexpected document identifier when persisting consent document: \(documentIdentifier)") - } - } - - @MainActor - func resetDocument(identifier: String) async throws { - if identifier == DocumentIdentifiers.first { - firstConsentData = .init() - } else if identifier == DocumentIdentifiers.second { - secondConsentData = .init() - } - } - - @MainActor - func loadConsentDocument(identifier: String) async throws -> PDFDocument? { - if identifier == DocumentIdentifiers.first { - return self.firstConsentData - } else if identifier == DocumentIdentifiers.second { - return self.secondConsentData - } - - // In case an invalid identifier is provided, return nil. - // The OnboardingConsentMarkdownRenderingView checks if the document - // is nil, and if so, displays an error. - return nil - } + @MainActor var firstConsentDocument: PDFDocument? + @MainActor var secondConsentDocument: PDFDocument? } diff --git a/Tests/UITests/TestApp/OnboardingTestsView.swift b/Tests/UITests/TestApp/OnboardingTestsView.swift index 27e85b8..5528bd4 100644 --- a/Tests/UITests/TestApp/OnboardingTestsView.swift +++ b/Tests/UITests/TestApp/OnboardingTestsView.swift @@ -27,25 +27,25 @@ struct OnboardingTestsView: View { OnboardingConsentMarkdownTestView( consentTitle: "First Consent", consentText: "This is the first *markdown* **example**", - documentIdentifier: DocumentIdentifiers.first + documentIdentifier: ConsentDocumentIdentifiers.first ) - OnboardingConsentMarkdownRenderingView( + OnboardingConsentMarkdownFinishedRenderedView( consentTitle: "First Consent", - documentIdentifier: DocumentIdentifiers.first + documentIdentifier: ConsentDocumentIdentifiers.first ) OnboardingConsentMarkdownTestView( consentTitle: "Second Consent", consentText: "This is the second *markdown* **example**", - documentIdentifier: DocumentIdentifiers.second + documentIdentifier: ConsentDocumentIdentifiers.second ) - .onboardingIdentifier(DocumentIdentifiers.second) - OnboardingConsentMarkdownRenderingView( + .onboardingIdentifier(ConsentDocumentIdentifiers.second) + OnboardingConsentMarkdownFinishedRenderedView( consentTitle: "Second Consent", - documentIdentifier: DocumentIdentifiers.second + documentIdentifier: ConsentDocumentIdentifiers.second ) - .onboardingIdentifier("\(DocumentIdentifiers.second)_rendering") + .onboardingIdentifier("\(ConsentDocumentIdentifiers.second)_rendering") OnboardingTestViewNotIdentifiable(text: "Leland").onboardingIdentifier("a") OnboardingTestViewNotIdentifiable(text: "Stanford").onboardingIdentifier("b") diff --git a/Tests/UITests/TestApp/TestAppDelegate.swift b/Tests/UITests/TestApp/TestAppDelegate.swift index 60f5dea..2418021 100644 --- a/Tests/UITests/TestApp/TestAppDelegate.swift +++ b/Tests/UITests/TestApp/TestAppDelegate.swift @@ -12,8 +12,6 @@ import SpeziOnboarding class TestAppDelegate: SpeziAppDelegate { override var configuration: Configuration { - Configuration(standard: ExampleStandard()) { - OnboardingDataSource() - } + Configuration(standard: ExampleStandard()) {} } } diff --git a/Tests/UITests/TestApp/Views/Consent/ConsentDocumentIdentifiers.swift b/Tests/UITests/TestApp/Views/Consent/ConsentDocumentIdentifiers.swift new file mode 100644 index 0000000..d648535 --- /dev/null +++ b/Tests/UITests/TestApp/Views/Consent/ConsentDocumentIdentifiers.swift @@ -0,0 +1,5 @@ +/// Example identifiers to uniquely identify two exported consent documents. +enum ConsentDocumentIdentifiers { + case first + case second +} diff --git a/Tests/UITests/TestApp/Views/Consent/OnboardingConsentMarkdownRenderingView.swift b/Tests/UITests/TestApp/Views/Consent/OnboardingConsentMarkdownFinishedRenderedView.swift similarity index 74% rename from Tests/UITests/TestApp/Views/Consent/OnboardingConsentMarkdownRenderingView.swift rename to Tests/UITests/TestApp/Views/Consent/OnboardingConsentMarkdownFinishedRenderedView.swift index dca0687..ef51de1 100644 --- a/Tests/UITests/TestApp/Views/Consent/OnboardingConsentMarkdownRenderingView.swift +++ b/Tests/UITests/TestApp/Views/Consent/OnboardingConsentMarkdownFinishedRenderedView.swift @@ -12,9 +12,9 @@ import SpeziViews import SwiftUI -struct OnboardingConsentMarkdownRenderingView: View { +struct OnboardingConsentMarkdownFinishedRenderedView: View { let consentTitle: String - let documentIdentifier: String + let documentIdentifier: ConsentDocumentIdentifiers @Environment(OnboardingNavigationPath.self) private var path @Environment(ExampleStandard.self) private var standard @@ -57,8 +57,13 @@ struct OnboardingConsentMarkdownRenderingView: View { .navigationBarTitleDisplayMode(.inline) #endif .task { - self.exportedConsent = try? await standard.loadConsentDocument(identifier: documentIdentifier) - try? await standard.resetDocument(identifier: documentIdentifier) + // Read and then clean up the respective exported consent document from the `ExampleStandard` + switch documentIdentifier { + case ConsentDocumentIdentifiers.first: + exportedConsent = standard.firstConsentDocument.take() + case ConsentDocumentIdentifiers.second: + exportedConsent = standard.secondConsentDocument.take() + } } } } @@ -66,13 +71,9 @@ struct OnboardingConsentMarkdownRenderingView: View { #if DEBUG #Preview { - let standard: OnboardingDataSource = .init() - - - OnboardingStack(startAtStep: OnboardingConsentMarkdownRenderingView.self) { + OnboardingStack(startAtStep: OnboardingConsentMarkdownFinishedRenderedView.self) { for onboardingView in OnboardingFlow.previewSimulatorViews { onboardingView - .environment(standard) } } } diff --git a/Tests/UITests/TestApp/Views/Consent/OnboardingConsentMarkdownTestView.swift b/Tests/UITests/TestApp/Views/Consent/OnboardingConsentMarkdownTestView.swift index 29bb3b2..04c2c7f 100644 --- a/Tests/UITests/TestApp/Views/Consent/OnboardingConsentMarkdownTestView.swift +++ b/Tests/UITests/TestApp/Views/Consent/OnboardingConsentMarkdownTestView.swift @@ -14,23 +14,30 @@ import SwiftUI struct OnboardingConsentMarkdownTestView: View { let consentTitle: String let consentText: String - let documentIdentifier: String + let documentIdentifier: ConsentDocumentIdentifiers @Environment(OnboardingNavigationPath.self) private var path - + @Environment(ExampleStandard.self) private var standard + var body: some View { OnboardingConsentView( markdown: { Data(consentText.utf8) }, - action: { + action: { exportedConsent in + // Store the exported consent form in the `ExampleStandard` + switch documentIdentifier { + case .first: standard.firstConsentDocument = exportedConsent + case .second: standard.secondConsentDocument = exportedConsent + } + + // Navigates to the next onboarding step path.nextStep() }, title: consentTitle.localized(), - identifier: documentIdentifier, - exportConfiguration: .init(paperSize: .dinA4, includingTimestamp: true), - currentDateInSignature: true + currentDateInSignature: true, + exportConfiguration: .init(paperSize: .dinA4, includingTimestamp: true) ) } } diff --git a/Tests/UITests/TestApp/Views/Helpers/OnboardingFlow+PreviewSimulator.swift b/Tests/UITests/TestApp/Views/Helpers/OnboardingFlow+PreviewSimulator.swift index a36b144..994a688 100644 --- a/Tests/UITests/TestApp/Views/Helpers/OnboardingFlow+PreviewSimulator.swift +++ b/Tests/UITests/TestApp/Views/Helpers/OnboardingFlow+PreviewSimulator.swift @@ -20,24 +20,24 @@ enum OnboardingFlow { OnboardingConsentMarkdownTestView( consentTitle: "Consent Document", consentText: "This is the first *markdown* **example**", - documentIdentifier: DocumentIdentifiers.first + documentIdentifier: ConsentDocumentIdentifiers.first ), - OnboardingConsentMarkdownRenderingView( + OnboardingConsentMarkdownFinishedRenderedView( consentTitle: "Consent Document", - documentIdentifier: DocumentIdentifiers.first + documentIdentifier: ConsentDocumentIdentifiers.first ), OnboardingConsentMarkdownTestView( consentTitle: "Consent Document", consentText: "This is the second *markdown* **example**", - documentIdentifier: DocumentIdentifiers.second + documentIdentifier: ConsentDocumentIdentifiers.second ) - .onboardingIdentifier(DocumentIdentifiers.second), - OnboardingConsentMarkdownRenderingView( + .onboardingIdentifier(ConsentDocumentIdentifiers.second), + OnboardingConsentMarkdownFinishedRenderedView( consentTitle: "Consent Document", - documentIdentifier: DocumentIdentifiers.second + documentIdentifier: ConsentDocumentIdentifiers.second ) - .onboardingIdentifier("\(DocumentIdentifiers.second)_rendering"), + .onboardingIdentifier("\(ConsentDocumentIdentifiers.second)_rendering"), OnboardingCustomTestView1(exampleArgument: "test"), OnboardingCustomTestView2(), diff --git a/Tests/UITests/TestApp/Views/OnboardingStartTestView.swift b/Tests/UITests/TestApp/Views/OnboardingStartTestView.swift index 01a60f4..3d808cf 100644 --- a/Tests/UITests/TestApp/Views/OnboardingStartTestView.swift +++ b/Tests/UITests/TestApp/Views/OnboardingStartTestView.swift @@ -37,7 +37,7 @@ struct OnboardingStartTestView: View { } Button { - path.append(OnboardingConsentMarkdownRenderingView.self) + path.append(OnboardingConsentMarkdownFinishedRenderedView.self) } label: { Text("Rendered Consent View (Markdown)") } diff --git a/Tests/UITests/UITests.xcodeproj/project.pbxproj b/Tests/UITests/UITests.xcodeproj/project.pbxproj index 1b6d1af..8a4efe5 100644 --- a/Tests/UITests/UITests.xcodeproj/project.pbxproj +++ b/Tests/UITests/UITests.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + 106D8B662D45761600D7637A /* ConsentDocumentIdentifiers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 106D8B652D45760C00D7637A /* ConsentDocumentIdentifiers.swift */; }; 2F61BDC329DD02D600D71D33 /* SpeziOnboarding in Frameworks */ = {isa = PBXBuildFile; productRef = 2F61BDC229DD02D600D71D33 /* SpeziOnboarding */; }; 2F61BDC929DD3CC000D71D33 /* OnboardingTestsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F61BDC829DD3CC000D71D33 /* OnboardingTestsView.swift */; }; 2F61BDCB29DDE76D00D71D33 /* SpeziOnboardingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F61BDCA29DDE76D00D71D33 /* SpeziOnboardingTests.swift */; }; @@ -27,7 +28,7 @@ 97C6AF7B2ACC89000060155B /* XCTestExtensions in Frameworks */ = {isa = PBXBuildFile; productRef = 97C6AF7A2ACC89000060155B /* XCTestExtensions */; }; 97C6AF7F2ACC94450060155B /* OnboardingFlow+PreviewSimulator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 97C6AF7E2ACC94450060155B /* OnboardingFlow+PreviewSimulator.swift */; }; A950C9C02C68AFAD0052FA6D /* XCUIApplication+Onboarding.swift in Sources */ = {isa = PBXBuildFile; fileRef = A950C9BF2C68AFA80052FA6D /* XCUIApplication+Onboarding.swift */; }; - C49959792C6C8C1F008E5256 /* OnboardingConsentMarkdownRenderingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C49959772C6C8C1F008E5256 /* OnboardingConsentMarkdownRenderingView.swift */; }; + C49959792C6C8C1F008E5256 /* OnboardingConsentMarkdownFinishedRenderedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C49959772C6C8C1F008E5256 /* OnboardingConsentMarkdownFinishedRenderedView.swift */; }; C499597A2C6C8C1F008E5256 /* OnboardingConsentMarkdownTestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C49959782C6C8C1F008E5256 /* OnboardingConsentMarkdownTestView.swift */; }; /* End PBXBuildFile section */ @@ -42,6 +43,7 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ + 106D8B652D45760C00D7637A /* ConsentDocumentIdentifiers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConsentDocumentIdentifiers.swift; sourceTree = ""; }; 2F61BDC129DD023E00D71D33 /* SpeziOnboarding */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = SpeziOnboarding; path = ../..; sourceTree = ""; }; 2F61BDC829DD3CC000D71D33 /* OnboardingTestsView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OnboardingTestsView.swift; sourceTree = ""; }; 2F61BDCA29DDE76D00D71D33 /* SpeziOnboardingTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SpeziOnboardingTests.swift; sourceTree = ""; }; @@ -64,7 +66,7 @@ 97C6AF782ACC88270060155B /* TestAppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestAppDelegate.swift; sourceTree = ""; }; 97C6AF7E2ACC94450060155B /* OnboardingFlow+PreviewSimulator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "OnboardingFlow+PreviewSimulator.swift"; sourceTree = ""; }; A950C9BF2C68AFA80052FA6D /* XCUIApplication+Onboarding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "XCUIApplication+Onboarding.swift"; sourceTree = ""; }; - C49959772C6C8C1F008E5256 /* OnboardingConsentMarkdownRenderingView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OnboardingConsentMarkdownRenderingView.swift; sourceTree = ""; }; + C49959772C6C8C1F008E5256 /* OnboardingConsentMarkdownFinishedRenderedView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OnboardingConsentMarkdownFinishedRenderedView.swift; sourceTree = ""; }; C49959782C6C8C1F008E5256 /* OnboardingConsentMarkdownTestView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OnboardingConsentMarkdownTestView.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -91,7 +93,8 @@ 10BD9F752D439C6700E9282B /* Consent */ = { isa = PBXGroup; children = ( - C49959772C6C8C1F008E5256 /* OnboardingConsentMarkdownRenderingView.swift */, + 106D8B652D45760C00D7637A /* ConsentDocumentIdentifiers.swift */, + C49959772C6C8C1F008E5256 /* OnboardingConsentMarkdownFinishedRenderedView.swift */, C49959782C6C8C1F008E5256 /* OnboardingConsentMarkdownTestView.swift */, ); path = Consent; @@ -315,10 +318,11 @@ files = ( 97C6AF792ACC88270060155B /* TestAppDelegate.swift in Sources */, 61F1697E2BCA888600D1622B /* OnboardingTestViewNotIdentifiable.swift in Sources */, + 106D8B662D45761600D7637A /* ConsentDocumentIdentifiers.swift in Sources */, C499597A2C6C8C1F008E5256 /* OnboardingConsentMarkdownTestView.swift in Sources */, 970D44552A6F119600756FE2 /* OnboardingConditionalTestView.swift in Sources */, 97C6AF772ACC86B70060155B /* ExampleStandard.swift in Sources */, - C49959792C6C8C1F008E5256 /* OnboardingConsentMarkdownRenderingView.swift in Sources */, + C49959792C6C8C1F008E5256 /* OnboardingConsentMarkdownFinishedRenderedView.swift in Sources */, 970D44532A6F0B1900756FE2 /* OnboardingStartTestView.swift in Sources */, 970D44512A6F04ED00756FE2 /* OnboardingSequentialTestView.swift in Sources */, 61D77B542BC83F0100E3165F /* OnboardingCustomToggleTestView.swift in Sources */, From 8f662fbbb79c5cda79273853755a0598e0b0f83e Mon Sep 17 00:00:00 2001 From: Philipp Zagar Date: Sat, 25 Jan 2025 21:46:49 +0100 Subject: [PATCH 20/24] linter --- ...View.swift => OnboardingConsentFinishedRenderedView.swift} | 4 ++-- ...MarkdownTestView.swift => OnboardingConsentTestView.swift} | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) rename Tests/UITests/TestApp/Views/Consent/{OnboardingConsentMarkdownFinishedRenderedView.swift => OnboardingConsentFinishedRenderedView.swift} (94%) rename Tests/UITests/TestApp/Views/Consent/{OnboardingConsentMarkdownTestView.swift => OnboardingConsentTestView.swift} (91%) diff --git a/Tests/UITests/TestApp/Views/Consent/OnboardingConsentMarkdownFinishedRenderedView.swift b/Tests/UITests/TestApp/Views/Consent/OnboardingConsentFinishedRenderedView.swift similarity index 94% rename from Tests/UITests/TestApp/Views/Consent/OnboardingConsentMarkdownFinishedRenderedView.swift rename to Tests/UITests/TestApp/Views/Consent/OnboardingConsentFinishedRenderedView.swift index ef51de1..648a600 100644 --- a/Tests/UITests/TestApp/Views/Consent/OnboardingConsentMarkdownFinishedRenderedView.swift +++ b/Tests/UITests/TestApp/Views/Consent/OnboardingConsentFinishedRenderedView.swift @@ -12,7 +12,7 @@ import SpeziViews import SwiftUI -struct OnboardingConsentMarkdownFinishedRenderedView: View { +struct OnboardingConsentFinishedRenderedView: View { let consentTitle: String let documentIdentifier: ConsentDocumentIdentifiers @@ -71,7 +71,7 @@ struct OnboardingConsentMarkdownFinishedRenderedView: View { #if DEBUG #Preview { - OnboardingStack(startAtStep: OnboardingConsentMarkdownFinishedRenderedView.self) { + OnboardingStack(startAtStep: OnboardingConsentFinishedRenderedView.self) { for onboardingView in OnboardingFlow.previewSimulatorViews { onboardingView } diff --git a/Tests/UITests/TestApp/Views/Consent/OnboardingConsentMarkdownTestView.swift b/Tests/UITests/TestApp/Views/Consent/OnboardingConsentTestView.swift similarity index 91% rename from Tests/UITests/TestApp/Views/Consent/OnboardingConsentMarkdownTestView.swift rename to Tests/UITests/TestApp/Views/Consent/OnboardingConsentTestView.swift index 04c2c7f..90f890c 100644 --- a/Tests/UITests/TestApp/Views/Consent/OnboardingConsentMarkdownTestView.swift +++ b/Tests/UITests/TestApp/Views/Consent/OnboardingConsentTestView.swift @@ -11,7 +11,7 @@ import SpeziViews import SwiftUI -struct OnboardingConsentMarkdownTestView: View { +struct OnboardingConsentTestView: View { let consentTitle: String let consentText: String let documentIdentifier: ConsentDocumentIdentifiers @@ -45,7 +45,7 @@ struct OnboardingConsentMarkdownTestView: View { #if DEBUG #Preview { - OnboardingStack(startAtStep: OnboardingConsentMarkdownTestView.self) { + OnboardingStack(startAtStep: OnboardingConsentTestView.self) { for onboardingView in OnboardingFlow.previewSimulatorViews { onboardingView } From aa24e2f22fbdb74a53cfd901ef57bc7a53653650 Mon Sep 17 00:00:00 2001 From: Philipp Zagar Date: Sat, 25 Jan 2025 21:51:54 +0100 Subject: [PATCH 21/24] linter --- Tests/UITests/TestApp/OnboardingTestsView.swift | 8 ++++---- .../Consent/ConsentDocumentIdentifiers.swift | 8 ++++++++ .../OnboardingFlow+PreviewSimulator.swift | 8 ++++---- .../TestApp/Views/OnboardingStartTestView.swift | 4 ++-- Tests/UITests/UITests.xcodeproj/project.pbxproj | 16 ++++++++-------- 5 files changed, 26 insertions(+), 18 deletions(-) diff --git a/Tests/UITests/TestApp/OnboardingTestsView.swift b/Tests/UITests/TestApp/OnboardingTestsView.swift index 5528bd4..075874b 100644 --- a/Tests/UITests/TestApp/OnboardingTestsView.swift +++ b/Tests/UITests/TestApp/OnboardingTestsView.swift @@ -24,24 +24,24 @@ struct OnboardingTestsView: View { OnboardingWelcomeTestView() OnboardingSequentialTestView() - OnboardingConsentMarkdownTestView( + OnboardingConsentTestView( consentTitle: "First Consent", consentText: "This is the first *markdown* **example**", documentIdentifier: ConsentDocumentIdentifiers.first ) - OnboardingConsentMarkdownFinishedRenderedView( + OnboardingConsentFinishedRenderedView( consentTitle: "First Consent", documentIdentifier: ConsentDocumentIdentifiers.first ) - OnboardingConsentMarkdownTestView( + OnboardingConsentTestView( consentTitle: "Second Consent", consentText: "This is the second *markdown* **example**", documentIdentifier: ConsentDocumentIdentifiers.second ) .onboardingIdentifier(ConsentDocumentIdentifiers.second) - OnboardingConsentMarkdownFinishedRenderedView( + OnboardingConsentFinishedRenderedView( consentTitle: "Second Consent", documentIdentifier: ConsentDocumentIdentifiers.second ) diff --git a/Tests/UITests/TestApp/Views/Consent/ConsentDocumentIdentifiers.swift b/Tests/UITests/TestApp/Views/Consent/ConsentDocumentIdentifiers.swift index d648535..0ff220c 100644 --- a/Tests/UITests/TestApp/Views/Consent/ConsentDocumentIdentifiers.swift +++ b/Tests/UITests/TestApp/Views/Consent/ConsentDocumentIdentifiers.swift @@ -1,3 +1,11 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + /// Example identifiers to uniquely identify two exported consent documents. enum ConsentDocumentIdentifiers { case first diff --git a/Tests/UITests/TestApp/Views/Helpers/OnboardingFlow+PreviewSimulator.swift b/Tests/UITests/TestApp/Views/Helpers/OnboardingFlow+PreviewSimulator.swift index 994a688..bc5705b 100644 --- a/Tests/UITests/TestApp/Views/Helpers/OnboardingFlow+PreviewSimulator.swift +++ b/Tests/UITests/TestApp/Views/Helpers/OnboardingFlow+PreviewSimulator.swift @@ -17,23 +17,23 @@ enum OnboardingFlow { OnboardingWelcomeTestView(), OnboardingSequentialTestView(), - OnboardingConsentMarkdownTestView( + OnboardingConsentTestView( consentTitle: "Consent Document", consentText: "This is the first *markdown* **example**", documentIdentifier: ConsentDocumentIdentifiers.first ), - OnboardingConsentMarkdownFinishedRenderedView( + OnboardingConsentFinishedRenderedView( consentTitle: "Consent Document", documentIdentifier: ConsentDocumentIdentifiers.first ), - OnboardingConsentMarkdownTestView( + OnboardingConsentTestView( consentTitle: "Consent Document", consentText: "This is the second *markdown* **example**", documentIdentifier: ConsentDocumentIdentifiers.second ) .onboardingIdentifier(ConsentDocumentIdentifiers.second), - OnboardingConsentMarkdownFinishedRenderedView( + OnboardingConsentFinishedRenderedView( consentTitle: "Consent Document", documentIdentifier: ConsentDocumentIdentifiers.second ) diff --git a/Tests/UITests/TestApp/Views/OnboardingStartTestView.swift b/Tests/UITests/TestApp/Views/OnboardingStartTestView.swift index 3d808cf..73afb5b 100644 --- a/Tests/UITests/TestApp/Views/OnboardingStartTestView.swift +++ b/Tests/UITests/TestApp/Views/OnboardingStartTestView.swift @@ -31,13 +31,13 @@ struct OnboardingStartTestView: View { } Button { - path.append(OnboardingConsentMarkdownTestView.self) + path.append(OnboardingConsentTestView.self) } label: { Text("Consent View (Markdown)") } Button { - path.append(OnboardingConsentMarkdownFinishedRenderedView.self) + path.append(OnboardingConsentFinishedRenderedView.self) } label: { Text("Rendered Consent View (Markdown)") } diff --git a/Tests/UITests/UITests.xcodeproj/project.pbxproj b/Tests/UITests/UITests.xcodeproj/project.pbxproj index 8a4efe5..f9bd544 100644 --- a/Tests/UITests/UITests.xcodeproj/project.pbxproj +++ b/Tests/UITests/UITests.xcodeproj/project.pbxproj @@ -8,6 +8,7 @@ /* Begin PBXBuildFile section */ 106D8B662D45761600D7637A /* ConsentDocumentIdentifiers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 106D8B652D45760C00D7637A /* ConsentDocumentIdentifiers.swift */; }; + 106D8B682D45851500D7637A /* OnboardingConsentFinishedRenderedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 106D8B672D45851500D7637A /* OnboardingConsentFinishedRenderedView.swift */; }; 2F61BDC329DD02D600D71D33 /* SpeziOnboarding in Frameworks */ = {isa = PBXBuildFile; productRef = 2F61BDC229DD02D600D71D33 /* SpeziOnboarding */; }; 2F61BDC929DD3CC000D71D33 /* OnboardingTestsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F61BDC829DD3CC000D71D33 /* OnboardingTestsView.swift */; }; 2F61BDCB29DDE76D00D71D33 /* SpeziOnboardingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F61BDCA29DDE76D00D71D33 /* SpeziOnboardingTests.swift */; }; @@ -28,8 +29,7 @@ 97C6AF7B2ACC89000060155B /* XCTestExtensions in Frameworks */ = {isa = PBXBuildFile; productRef = 97C6AF7A2ACC89000060155B /* XCTestExtensions */; }; 97C6AF7F2ACC94450060155B /* OnboardingFlow+PreviewSimulator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 97C6AF7E2ACC94450060155B /* OnboardingFlow+PreviewSimulator.swift */; }; A950C9C02C68AFAD0052FA6D /* XCUIApplication+Onboarding.swift in Sources */ = {isa = PBXBuildFile; fileRef = A950C9BF2C68AFA80052FA6D /* XCUIApplication+Onboarding.swift */; }; - C49959792C6C8C1F008E5256 /* OnboardingConsentMarkdownFinishedRenderedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C49959772C6C8C1F008E5256 /* OnboardingConsentMarkdownFinishedRenderedView.swift */; }; - C499597A2C6C8C1F008E5256 /* OnboardingConsentMarkdownTestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C49959782C6C8C1F008E5256 /* OnboardingConsentMarkdownTestView.swift */; }; + C499597A2C6C8C1F008E5256 /* OnboardingConsentTestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C49959782C6C8C1F008E5256 /* OnboardingConsentTestView.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -44,6 +44,7 @@ /* Begin PBXFileReference section */ 106D8B652D45760C00D7637A /* ConsentDocumentIdentifiers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConsentDocumentIdentifiers.swift; sourceTree = ""; }; + 106D8B672D45851500D7637A /* OnboardingConsentFinishedRenderedView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingConsentFinishedRenderedView.swift; sourceTree = ""; }; 2F61BDC129DD023E00D71D33 /* SpeziOnboarding */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = SpeziOnboarding; path = ../..; sourceTree = ""; }; 2F61BDC829DD3CC000D71D33 /* OnboardingTestsView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OnboardingTestsView.swift; sourceTree = ""; }; 2F61BDCA29DDE76D00D71D33 /* SpeziOnboardingTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SpeziOnboardingTests.swift; sourceTree = ""; }; @@ -66,8 +67,7 @@ 97C6AF782ACC88270060155B /* TestAppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestAppDelegate.swift; sourceTree = ""; }; 97C6AF7E2ACC94450060155B /* OnboardingFlow+PreviewSimulator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "OnboardingFlow+PreviewSimulator.swift"; sourceTree = ""; }; A950C9BF2C68AFA80052FA6D /* XCUIApplication+Onboarding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "XCUIApplication+Onboarding.swift"; sourceTree = ""; }; - C49959772C6C8C1F008E5256 /* OnboardingConsentMarkdownFinishedRenderedView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OnboardingConsentMarkdownFinishedRenderedView.swift; sourceTree = ""; }; - C49959782C6C8C1F008E5256 /* OnboardingConsentMarkdownTestView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OnboardingConsentMarkdownTestView.swift; sourceTree = ""; }; + C49959782C6C8C1F008E5256 /* OnboardingConsentTestView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OnboardingConsentTestView.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -93,9 +93,9 @@ 10BD9F752D439C6700E9282B /* Consent */ = { isa = PBXGroup; children = ( + C49959782C6C8C1F008E5256 /* OnboardingConsentTestView.swift */, + 106D8B672D45851500D7637A /* OnboardingConsentFinishedRenderedView.swift */, 106D8B652D45760C00D7637A /* ConsentDocumentIdentifiers.swift */, - C49959772C6C8C1F008E5256 /* OnboardingConsentMarkdownFinishedRenderedView.swift */, - C49959782C6C8C1F008E5256 /* OnboardingConsentMarkdownTestView.swift */, ); path = Consent; sourceTree = ""; @@ -319,10 +319,9 @@ 97C6AF792ACC88270060155B /* TestAppDelegate.swift in Sources */, 61F1697E2BCA888600D1622B /* OnboardingTestViewNotIdentifiable.swift in Sources */, 106D8B662D45761600D7637A /* ConsentDocumentIdentifiers.swift in Sources */, - C499597A2C6C8C1F008E5256 /* OnboardingConsentMarkdownTestView.swift in Sources */, + C499597A2C6C8C1F008E5256 /* OnboardingConsentTestView.swift in Sources */, 970D44552A6F119600756FE2 /* OnboardingConditionalTestView.swift in Sources */, 97C6AF772ACC86B70060155B /* ExampleStandard.swift in Sources */, - C49959792C6C8C1F008E5256 /* OnboardingConsentMarkdownFinishedRenderedView.swift in Sources */, 970D44532A6F0B1900756FE2 /* OnboardingStartTestView.swift in Sources */, 970D44512A6F04ED00756FE2 /* OnboardingSequentialTestView.swift in Sources */, 61D77B542BC83F0100E3165F /* OnboardingCustomToggleTestView.swift in Sources */, @@ -331,6 +330,7 @@ 97A8FF2E2A7444FC008CD91A /* OnboardingCustomTestView2.swift in Sources */, 2F61BDC929DD3CC000D71D33 /* OnboardingTestsView.swift in Sources */, 61040A1D2BAFA2F600EDD4EC /* OnboardingIdentifiableTestViewCustom.swift in Sources */, + 106D8B682D45851500D7637A /* OnboardingConsentFinishedRenderedView.swift in Sources */, 97C6AF7F2ACC94450060155B /* OnboardingFlow+PreviewSimulator.swift in Sources */, 970D444F2A6F048A00756FE2 /* OnboardingWelcomeTestView.swift in Sources */, 97A8FF312A74607F008CD91A /* CustomToggleView.swift in Sources */, From 8ea59c931622c6ac9a9650b74c370d2fd282ba7d Mon Sep 17 00:00:00 2001 From: Philipp Zagar Date: Sun, 26 Jan 2025 08:49:19 +0100 Subject: [PATCH 22/24] Fix undo manager --- .../SpeziOnboarding/Consent/ConsentViewState.swift | 14 -------------- .../Consent/Views/SignatureView.swift | 6 ++---- .../SpeziOnboarding/OnboardingConsentView.swift | 9 +++++---- 3 files changed, 7 insertions(+), 22 deletions(-) diff --git a/Sources/SpeziOnboarding/Consent/ConsentViewState.swift b/Sources/SpeziOnboarding/Consent/ConsentViewState.swift index f21f75d..3d47b88 100644 --- a/Sources/SpeziOnboarding/Consent/ConsentViewState.swift +++ b/Sources/SpeziOnboarding/Consent/ConsentViewState.swift @@ -39,18 +39,4 @@ public enum ConsentViewState: Equatable { case exported(representation: ConsentDocumentExportRepresentation) /// The `storing` state indicates that the ``ConsentDocument`` is currently being stored to the Standard. case storing - - - public static func == (lhs: ConsentViewState, rhs: ConsentViewState) -> Bool { - switch (lhs, rhs) { - case let (.base(lhsValue), .base(rhsValue)): lhsValue == rhsValue - case (.namesEntered, .namesEntered): true - case (.signing, .signing): true - case (.signed, .signed): true - case (.export, .export): true - case let (.exported(lhsValue), .exported(rhsValue)): lhsValue == rhsValue - case (.storing, .storing): true - default: false - } - } } diff --git a/Sources/SpeziOnboarding/Consent/Views/SignatureView.swift b/Sources/SpeziOnboarding/Consent/Views/SignatureView.swift index 9b9b29b..337dc01 100644 --- a/Sources/SpeziOnboarding/Consent/Views/SignatureView.swift +++ b/Sources/SpeziOnboarding/Consent/Views/SignatureView.swift @@ -80,10 +80,8 @@ public struct SignatureView: View { #endif } #if !os(macOS) - .onChange(of: isSigning) { - runOrScheduleOnMainActor { - canUndo = undoManager?.canUndo ?? false - } + .onChange(of: undoManager?.canUndo) { _, canUndo in + self.canUndo = canUndo ?? false } .transition(.opacity) .animation(.easeInOut, value: canUndo) diff --git a/Sources/SpeziOnboarding/OnboardingConsentView.swift b/Sources/SpeziOnboarding/OnboardingConsentView.swift index ab17fc8..21107c9 100644 --- a/Sources/SpeziOnboarding/OnboardingConsentView.swift +++ b/Sources/SpeziOnboarding/OnboardingConsentView.swift @@ -56,9 +56,6 @@ public struct OnboardingConsentView: View { private let title: LocalizedStringResource? private let currentDateInSignature: Bool private let exportConfiguration: ConsentDocumentExportRepresentation.Configuration - private var backButtonHidden: Bool { - viewState == .storing || (viewState == .export && !willShowShareSheet) - } @State private var viewState: ConsentViewState = .base(.idle) @State private var willShowShareSheet = false @@ -201,7 +198,11 @@ public struct OnboardingConsentView: View { } #endif } - + + private var backButtonHidden: Bool { + viewState == .storing || (viewState == .export && !willShowShareSheet) + } + private var actionButtonsEnabled: Bool { switch viewState { case .signing, .signed, .exported: true From 5a4ebae07cf6fe4dadc0d6f7b282a58486dfb789 Mon Sep 17 00:00:00 2001 From: Philipp Zagar Date: Sun, 26 Jan 2025 23:40:47 +0100 Subject: [PATCH 23/24] Fix undomanager, further state handelling improvements and fixes --- .../Consent/ConsentViewState.swift | 2 - .../Consent/Views/ConsentDocument.swift | 39 ++++++++------ .../Consent/Views/SignatureView.swift | 6 +++ .../OnboardingConsentView.swift | 52 +++++++++++++------ .../Consent/OnboardingConsentTestView.swift | 3 ++ .../XCUIApplication+Onboarding.swift | 9 ++++ 6 files changed, 76 insertions(+), 35 deletions(-) diff --git a/Sources/SpeziOnboarding/Consent/ConsentViewState.swift b/Sources/SpeziOnboarding/Consent/ConsentViewState.swift index 3d47b88..061ca12 100644 --- a/Sources/SpeziOnboarding/Consent/ConsentViewState.swift +++ b/Sources/SpeziOnboarding/Consent/ConsentViewState.swift @@ -37,6 +37,4 @@ public enum ConsentViewState: Equatable { /// The export representation creation (resulting in the ``ConsentViewState/exported(representation:)`` state) can be triggered /// via setting the ``ConsentViewState/export`` state of the ``ConsentDocument`` . case exported(representation: ConsentDocumentExportRepresentation) - /// The `storing` state indicates that the ``ConsentDocument`` is currently being stored to the Standard. - case storing } diff --git a/Sources/SpeziOnboarding/Consent/Views/ConsentDocument.swift b/Sources/SpeziOnboarding/Consent/Views/ConsentDocument.swift index d42ee64..5d7f366 100644 --- a/Sources/SpeziOnboarding/Consent/Views/ConsentDocument.swift +++ b/Sources/SpeziOnboarding/Consent/Views/ConsentDocument.swift @@ -80,11 +80,13 @@ public struct ConsentDocument: View { #endif } .disabled(inputFieldsDisabled) - .onChange(of: name) { + .onChange(of: name) { _, name in if !(name.givenName?.isEmpty ?? true) && !(name.familyName?.isEmpty ?? true) { viewState = .namesEntered } else { - viewState = .base(.idle) + withAnimation(.easeIn(duration: 0.2)) { + viewState = .base(.idle) + } // Reset all strokes if name fields are not complete anymore #if !os(macOS) signature.strokes.removeAll() @@ -135,7 +137,7 @@ public struct ConsentDocument: View { } .padding(.vertical, 4) .disabled(inputFieldsDisabled) - .onChange(of: signature) { + .onChange(of: signature) { _, signature in #if !os(macOS) let isSignatureEmpty = signature.strokes.isEmpty #else @@ -144,7 +146,11 @@ public struct ConsentDocument: View { if !(isSignatureEmpty || (name.givenName?.isEmpty ?? true) || (name.familyName?.isEmpty ?? true)) { viewState = .signed } else { - viewState = .namesEntered + if (name.givenName?.isEmpty ?? true) || (name.familyName?.isEmpty ?? true) { + viewState = .base(.idle) // Hide signature view if names not complete anymore + } else { + viewState = .namesEntered + } } } } @@ -166,14 +172,12 @@ public struct ConsentDocument: View { } .transition(.opacity) .animation(.easeInOut, value: viewState == .namesEntered) - .onChange(of: viewState) { + .task(id: viewState) { if case .export = viewState { - Task { - // Captures the current state of the document and transforms it to the `ConsentDocumentExportRepresentation` - viewState = .exported( - representation: await self.exportRepresentation - ) - } + // Captures the current state of the document and transforms it to the `ConsentDocumentExportRepresentation` + self.viewState = .exported( + representation: await self.exportRepresentation + ) } else if case .base(let baseViewState) = viewState, case .idle = baseViewState { // Reset view state to correct one after handling an error view state via `.viewStateAlert()` @@ -182,11 +186,11 @@ public struct ConsentDocument: View { #else let isSignatureEmpty = signature.isEmpty #endif - + if !isSignatureEmpty { - viewState = .signed - } else if !(name.givenName?.isEmpty ?? true) || !(name.familyName?.isEmpty ?? true) { - viewState = .namesEntered + self.viewState = .signed + } else if !(name.givenName?.isEmpty ?? true) && !(name.familyName?.isEmpty ?? true) { + self.viewState = .namesEntered } } } @@ -194,7 +198,10 @@ public struct ConsentDocument: View { } private var inputFieldsDisabled: Bool { - viewState == .base(.processing) || viewState == .export || viewState == .storing + switch viewState { + case .base(.processing), .export, .exported: true + default: false + } } var formattedConsentSignatureDate: String? { diff --git a/Sources/SpeziOnboarding/Consent/Views/SignatureView.swift b/Sources/SpeziOnboarding/Consent/Views/SignatureView.swift index 337dc01..d7fcb43 100644 --- a/Sources/SpeziOnboarding/Consent/Views/SignatureView.swift +++ b/Sources/SpeziOnboarding/Consent/Views/SignatureView.swift @@ -80,6 +80,12 @@ public struct SignatureView: View { #endif } #if !os(macOS) + .task { + // Crucial to reset the `UndoManager` between different `ConsentView`s in the `OnboardingStack`. + // Otherwise, actions are often not picked up + undoManager?.removeAllActions() + canUndo = false + } .onChange(of: undoManager?.canUndo) { _, canUndo in self.canUndo = canUndo ?? false } diff --git a/Sources/SpeziOnboarding/OnboardingConsentView.swift b/Sources/SpeziOnboarding/OnboardingConsentView.swift index 21107c9..1114f37 100644 --- a/Sources/SpeziOnboarding/OnboardingConsentView.swift +++ b/Sources/SpeziOnboarding/OnboardingConsentView.swift @@ -13,7 +13,6 @@ import AppKit import Foundation import PDFKit import Spezi -import SpeziFoundation import SpeziViews import SwiftUI @@ -52,7 +51,7 @@ public struct OnboardingConsentView: View { } private let markdown: () async -> Data - private let action: (_ document: PDFDocument) async throws -> Void + private let action: @MainActor (_ document: PDFDocument) async throws -> Void private let title: LocalizedStringResource? private let currentDateInSignature: Bool private let exportConfiguration: ConsentDocumentExportRepresentation.Configuration @@ -84,35 +83,38 @@ public struct OnboardingConsentView: View { actionView: { Button( action: { - viewState = .export + withAnimation(.easeOut(duration: 0.2)) { + viewState = .export // Triggers the export process + } }, label: { Text("CONSENT_ACTION", bundle: .module) .frame(maxWidth: .infinity, minHeight: 38) - .processingOverlay(isProcessing: viewState == .storing || (viewState == .export && !willShowShareSheet)) + .processingOverlay(isProcessing: backButtonHidden) } ) .buttonStyle(.borderedProminent) .disabled(!actionButtonsEnabled) - .animation(.easeInOut, value: actionButtonsEnabled) + .animation(.easeInOut(duration: 0.2), value: actionButtonsEnabled) .id("ActionButton") } ) .scrollDisabled($viewState.signing.wrappedValue) .navigationBarBackButtonHidden(backButtonHidden) - .onChange(of: viewState) { + .task(id: viewState) { if case .exported(let consentExport) = viewState { if !willShowShareSheet { - viewState = .storing + do { + // Pass the rendered consent form to the `action` closure + try await action(consentExport.render()) - Task { - do { - // Calls the passed `action` closure with the rendered consent PDF. - try await action(consentExport.render()) - viewState = .base(.idle) - } catch { + withAnimation(.easeIn(duration: 0.2)) { + self.viewState = .base(.idle) + } + } catch { + withAnimation(.easeIn(duration: 0.2)) { // In case of error, go back to previous state. - viewState = .base(.error(AnyLocalizedError(error: error))) + self.viewState = .base(.error(AnyLocalizedError(error: error))) } } } else { @@ -159,6 +161,11 @@ public struct OnboardingConsentView: View { .task { willShowShareSheet = false } + .onDisappear { + withAnimation(.easeIn(duration: 0.2)) { + self.viewState = .base(.idle) + } + } #endif } else { ProgressView() @@ -170,6 +177,12 @@ public struct OnboardingConsentView: View { } else { ProgressView() .padding() + .task { + // This is required as only the "Markup" action from the ShareSheet misses to dismiss the share sheet again + if !willShowShareSheet { + showShareSheet = false + } + } } } #if os(macOS) @@ -200,12 +213,17 @@ public struct OnboardingConsentView: View { } private var backButtonHidden: Bool { - viewState == .storing || (viewState == .export && !willShowShareSheet) + let exportStates = switch viewState { + case .export, .exported: true + default: false + } + + return exportStates && !willShowShareSheet } private var actionButtonsEnabled: Bool { switch viewState { - case .signing, .signed, .exported: true + case .signing, .signed: true default: false } } @@ -220,7 +238,7 @@ public struct OnboardingConsentView: View { /// - exportConfiguration: Defines the properties of the exported consent form via ``ConsentDocumentExportRepresentation/Configuration``. public init( markdown: @escaping () async -> Data, - action: @escaping (_ document: PDFDocument) async throws -> Void, + action: @escaping @MainActor (_ document: PDFDocument) async throws -> Void, title: LocalizedStringResource? = LocalizationDefaults.consentFormTitle, currentDateInSignature: Bool = true, exportConfiguration: ConsentDocumentExportRepresentation.Configuration = .init() diff --git a/Tests/UITests/TestApp/Views/Consent/OnboardingConsentTestView.swift b/Tests/UITests/TestApp/Views/Consent/OnboardingConsentTestView.swift index 90f890c..dfb0fa1 100644 --- a/Tests/UITests/TestApp/Views/Consent/OnboardingConsentTestView.swift +++ b/Tests/UITests/TestApp/Views/Consent/OnboardingConsentTestView.swift @@ -32,6 +32,9 @@ struct OnboardingConsentTestView: View { case .second: standard.secondConsentDocument = exportedConsent } + // Simulate storage / upload delay of consent form + try await Task.sleep(until: .now + .seconds(0.5)) + // Navigates to the next onboarding step path.nextStep() }, diff --git a/Tests/UITests/TestAppUITests/XCUIApplication+Onboarding.swift b/Tests/UITests/TestAppUITests/XCUIApplication+Onboarding.swift index 4abdf11..70351ed 100644 --- a/Tests/UITests/TestAppUITests/XCUIApplication+Onboarding.swift +++ b/Tests/UITests/TestAppUITests/XCUIApplication+Onboarding.swift @@ -79,14 +79,23 @@ extension XCUIApplication { XCTAssert(staticTexts["Name: Leland Stanford"].waitForExistence(timeout: 2)) #if !os(macOS) + XCTAssert(buttons["Undo"].waitForExistence(timeout: 2.0)) + XCTAssertFalse(buttons["Undo"].isEnabled) + staticTexts["Name: Leland Stanford"].swipeRight() XCTAssert(buttons["Undo"].waitForExistence(timeout: 2.0)) XCTAssertTrue(buttons["Undo"].isEnabled) buttons["Undo"].tap() + XCTAssert(buttons["Undo"].waitForExistence(timeout: 2.0)) + XCTAssertFalse(buttons["Undo"].isEnabled) + XCTAssert(scrollViews["Signature Field"].waitForExistence(timeout: 2)) scrollViews["Signature Field"].swipeRight() + + XCTAssert(buttons["Undo"].waitForExistence(timeout: 2.0)) + XCTAssertTrue(buttons["Undo"].isEnabled) #else XCTAssert(textFields["Signature Field"].waitForExistence(timeout: 2)) try textFields["Signature Field"].enter(value: "Leland Stanford") From bfb2528e2f666c3e62c3bd0be6101e5d72aab0e7 Mon Sep 17 00:00:00 2001 From: Philipp Zagar Date: Wed, 29 Jan 2025 00:34:30 +0100 Subject: [PATCH 24/24] Final improvements --- README.md | 7 +- .../Consent/ConsentViewState.swift | 2 +- .../Export/ConsentDocument+Export.swift | 1 + ...tDocumentExportRepresentation+Render.swift | 2 +- .../ConsentDocumentExportRepresentation.swift | 2 - .../Consent/Views/ConsentDocument.swift | 8 +-- .../Views/OnboardingConsentView+Error.swift | 1 - .../OnboardingConsentView+ShareSheet.swift | 2 +- .../Consent/Views/SignatureView.swift | 3 +- .../OnboardingConsentView.swift | 21 +++--- .../ObtainingUserConsent.md | 64 +++++++------------ 11 files changed, 50 insertions(+), 63 deletions(-) diff --git a/README.md b/README.md index f79d38d..123f573 100644 --- a/README.md +++ b/README.md @@ -126,7 +126,7 @@ struct SequentialOnboardingViewExample: View { The [`OnboardingConsentView`](https://swiftpackageindex.com/stanfordspezi/spezionboarding/documentation/spezionboarding/onboardingconsentview) can be used to allow your users to read and agree to a document, e.g., a consent document for a research study or a terms and conditions document for an app. The document can be signed using a family and given name and a hand-drawn signature. The signed consent form can then be exported and shared as a PDF file. -The following example demonstrates how the [`OnboardingConsentView`](https://swiftpackageindex.com/stanfordspezi/spezionboarding/documentation/spezionboarding/onboardingconsentview) shown above is constructed by providing markdown content encoded as a [UTF8](https://www.swift.org/blog/utf8-string/) [`Data`](https://developer.apple.com/documentation/foundation/data) instance (which may be provided asynchronously), an action that should be performed once the consent has been given, as well as a configuration defining the properties of the exported consent form. +The following example demonstrates how the [`OnboardingConsentView`](https://swiftpackageindex.com/stanfordspezi/spezionboarding/documentation/spezionboarding/onboardingconsentview) shown above is constructed by providing markdown content encoded as a [UTF8](https://www.swift.org/blog/utf8-string/) [`Data`](https://developer.apple.com/documentation/foundation/data) instance (which may be provided asynchronously), an action that should be performed once the consent has been given (which receives the exported consent form as a PDF), as well as a configuration defining the properties of the exported consent form. ```swift import SpeziOnboarding @@ -139,8 +139,9 @@ struct ConsentViewExample: View { markdown: { Data("This is a *markdown* **example**".utf8) }, - action: { - // Action to perform once the user has given their consent + action: { exportedConsentPdf in + // Action to perform once the user has given their consent. + // Closure receives the exported consent PDF to persist or upload it. }, exportConfiguration: .init(paperSize: .usLetter), // Configure the properties of the exported consent form currentDateInSignature: true // Indicates if the consent signature should include the current date. diff --git a/Sources/SpeziOnboarding/Consent/ConsentViewState.swift b/Sources/SpeziOnboarding/Consent/ConsentViewState.swift index 061ca12..c73e6cf 100644 --- a/Sources/SpeziOnboarding/Consent/ConsentViewState.swift +++ b/Sources/SpeziOnboarding/Consent/ConsentViewState.swift @@ -11,7 +11,7 @@ import SpeziViews import SwiftUI -/// The `ConsentViewState` indicates in what state the ``ConsentDocument`` currently is. +/// The ``ConsentViewState`` indicates in what state the ``ConsentDocument`` currently is. /// /// It can be used to observe and control the behavior of the ``ConsentDocument``, especially in regards /// to the export functionality. diff --git a/Sources/SpeziOnboarding/Consent/Export/ConsentDocument+Export.swift b/Sources/SpeziOnboarding/Consent/Export/ConsentDocument+Export.swift index 37a6f8b..37b15fd 100644 --- a/Sources/SpeziOnboarding/Consent/Export/ConsentDocument+Export.swift +++ b/Sources/SpeziOnboarding/Consent/Export/ConsentDocument+Export.swift @@ -11,6 +11,7 @@ import PencilKit import SwiftUI import TPPDF + /// Extension of `ConsentDocument` enabling the export of the signed consent page. extension ConsentDocument { /// Creates the export representation of the ``ConsentDocument`` including all necessary content. diff --git a/Sources/SpeziOnboarding/Consent/Export/ConsentDocumentExportRepresentation+Render.swift b/Sources/SpeziOnboarding/Consent/Export/ConsentDocumentExportRepresentation+Render.swift index e8f4a4b..ac47ecd 100644 --- a/Sources/SpeziOnboarding/Consent/Export/ConsentDocumentExportRepresentation+Render.swift +++ b/Sources/SpeziOnboarding/Consent/Export/ConsentDocumentExportRepresentation+Render.swift @@ -12,7 +12,7 @@ import SwiftUI import TPPDF -/// Extension of `ConsentDocumentExportRepresentation` enabling the export of the signed consent page. +/// Extension of ``ConsentDocumentExportRepresentation`` enabling the export of the signed consent page as a PDF. extension ConsentDocumentExportRepresentation { /// Generates a `PDFAttributedText` containing the timestamp of the time at which the PDF was exported. private var renderedTimeStamp: PDFAttributedText? { diff --git a/Sources/SpeziOnboarding/Consent/Export/ConsentDocumentExportRepresentation.swift b/Sources/SpeziOnboarding/Consent/Export/ConsentDocumentExportRepresentation.swift index 0d2f6da..627e43c 100644 --- a/Sources/SpeziOnboarding/Consent/Export/ConsentDocumentExportRepresentation.swift +++ b/Sources/SpeziOnboarding/Consent/Export/ConsentDocumentExportRepresentation.swift @@ -6,8 +6,6 @@ // SPDX-License-Identifier: MIT // -@preconcurrency import PDFKit -import PencilKit import SwiftUI diff --git a/Sources/SpeziOnboarding/Consent/Views/ConsentDocument.swift b/Sources/SpeziOnboarding/Consent/Views/ConsentDocument.swift index 5d7f366..a82c8d4 100644 --- a/Sources/SpeziOnboarding/Consent/Views/ConsentDocument.swift +++ b/Sources/SpeziOnboarding/Consent/Views/ConsentDocument.swift @@ -18,15 +18,15 @@ import SwiftUI /// Allows the display markdown-based consent documents that can be signed using a family and given name and a hand drawn signature. /// In addition, it enables the export of the signed form as a PDF document. /// -/// To observe and control the current state of the `ConsentDocument`, the view requires passing down a ``ConsentViewState`` as a SwiftUI `Binding` in the +/// To observe and control the current state of the ``ConsentDocument``, the `View` requires passing down a ``ConsentViewState`` as a SwiftUI `Binding` in the /// ``init(markdown:viewState:givenNameTitle:givenNamePlaceholder:familyNameTitle:familyNamePlaceholder:exportConfiguration:consentSignatureDate:consentSignatureDateFormatter:)`` initializer. /// /// This `Binding` can then be used to trigger the creation of the export representation of the consent form via setting the state to ``ConsentViewState/export``. /// After the export representation completes, the ``ConsentDocumentExportRepresentation`` is accessible via the associated value of the view state in ``ConsentViewState/exported(representation:)``. /// The ``ConsentDocumentExportRepresentation`` can then be rendered to a PDF via ``ConsentDocumentExportRepresentation/render()``. -/// Other possible states of the `ConsentDocument` are the SpeziViews `ViewState`'s accessible via the associated value in ``ConsentViewState/base(_:)``. -/// In addition, the view provides information about the signing progress via the ``ConsentViewState/signing`` and ``ConsentViewState/signed`` states, -/// as well as the ``ConsentViewState/storing`` state that indicates the current storage process of the exported document. +/// +/// Other possible states of the ``ConsentDocument`` are the SpeziViews `ViewState`'s accessible via the associated value in ``ConsentViewState/base(_:)``. +/// In addition, the view provides information about the signing progress via the ``ConsentViewState/signing`` and ``ConsentViewState/signed`` states. /// /// ```swift /// // Enables observing the view state of the consent document diff --git a/Sources/SpeziOnboarding/Consent/Views/OnboardingConsentView+Error.swift b/Sources/SpeziOnboarding/Consent/Views/OnboardingConsentView+Error.swift index a91bdd0..aa23b6f 100644 --- a/Sources/SpeziOnboarding/Consent/Views/OnboardingConsentView+Error.swift +++ b/Sources/SpeziOnboarding/Consent/Views/OnboardingConsentView+Error.swift @@ -11,7 +11,6 @@ import Foundation extension OnboardingConsentView { enum Error: LocalizedError { - /// Indicates that the local model file is not found. case consentExportError diff --git a/Sources/SpeziOnboarding/Consent/Views/OnboardingConsentView+ShareSheet.swift b/Sources/SpeziOnboarding/Consent/Views/OnboardingConsentView+ShareSheet.swift index 1fecf3e..8f6c144 100644 --- a/Sources/SpeziOnboarding/Consent/Views/OnboardingConsentView+ShareSheet.swift +++ b/Sources/SpeziOnboarding/Consent/Views/OnboardingConsentView+ShareSheet.swift @@ -42,11 +42,11 @@ extension OnboardingConsentView { func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {} } #else + @MainActor // On non-macOS SDKs, `UIViewControllerRepresentable` enforces the `MainActor` isolation struct ShareSheet { let sharedItem: PDFDocument - @MainActor func show() { // Note: Need to write down the PDF to storage as in-memory PDFs are not recognized properly let temporaryPath = FileManager.default.temporaryDirectory.appendingPathComponent( diff --git a/Sources/SpeziOnboarding/Consent/Views/SignatureView.swift b/Sources/SpeziOnboarding/Consent/Views/SignatureView.swift index d7fcb43..9f224de 100644 --- a/Sources/SpeziOnboarding/Consent/Views/SignatureView.swift +++ b/Sources/SpeziOnboarding/Consent/Views/SignatureView.swift @@ -23,7 +23,8 @@ import SwiftUI /// SignatureView( /// signature: $signature, /// isSigning: $isSigning, -/// name: name +/// name: name, +/// formattedDate: "01/23/25" /// ) /// ``` public struct SignatureView: View { diff --git a/Sources/SpeziOnboarding/OnboardingConsentView.swift b/Sources/SpeziOnboarding/OnboardingConsentView.swift index 1114f37..a5f1fda 100644 --- a/Sources/SpeziOnboarding/OnboardingConsentView.swift +++ b/Sources/SpeziOnboarding/OnboardingConsentView.swift @@ -18,13 +18,13 @@ import SwiftUI /// Onboarding view to display markdown-based consent documents that can be signed and exported. /// -/// The `OnboardingConsentView` provides a convenient onboarding view for the display of markdown-based documents that can be +/// The ``OnboardingConsentView`` provides a convenient onboarding `View` for the display of markdown-based documents that can be /// signed using a family and given name and a hand drawn signature. /// -/// Furthermore, the view includes an export functionality, enabling users to share and store the signed consent form. +/// Furthermore, the `View` includes an export functionality, enabling users to share and store the signed consent form. /// The exported consent form PDF is received via the `action` closure on the ``OnboardingConsentView/init(markdown:action:title:currentDateInSignature:exportConfiguration:)``. /// -/// The `OnboardingConsentView` builds on top of the SpeziOnboarding ``ConsentDocument`` +/// The ``OnboardingConsentView`` builds on top of the SpeziOnboarding ``ConsentDocument`` /// by providing a more developer-friendly, convenient API with additional functionalities like the share consent option. /// /// ```swift @@ -51,7 +51,7 @@ public struct OnboardingConsentView: View { } private let markdown: () async -> Data - private let action: @MainActor (_ document: PDFDocument) async throws -> Void + private let action: (_ document: PDFDocument) async throws -> Void private let title: LocalizedStringResource? private let currentDateInSignature: Bool private let exportConfiguration: ConsentDocumentExportRepresentation.Configuration @@ -106,7 +106,8 @@ public struct OnboardingConsentView: View { if !willShowShareSheet { do { // Pass the rendered consent form to the `action` closure - try await action(consentExport.render()) + nonisolated(unsafe) let pdf = try consentExport.render() + try await action(pdf) withAnimation(.easeIn(duration: 0.2)) { self.viewState = .base(.idle) @@ -163,7 +164,7 @@ public struct OnboardingConsentView: View { } .onDisappear { withAnimation(.easeIn(duration: 0.2)) { - self.viewState = .base(.idle) + self.viewState = .signed } } #endif @@ -193,13 +194,17 @@ public struct OnboardingConsentView: View { let shareSheet = ShareSheet(sharedItem: consentPdf) shareSheet.show() + willShowShareSheet = false showShareSheet = false + + withAnimation(.easeIn(duration: 0.2)) { + self.viewState = .signed + } } else { viewState = .base(.error(Error.consentExportError)) } } } - // `NSSharingServicePicker` doesn't provide a completion handler as `UIActivityViewController` does, // therefore necessitating the deletion of the temporary file on disappearing. .onDisappear { @@ -238,7 +243,7 @@ public struct OnboardingConsentView: View { /// - exportConfiguration: Defines the properties of the exported consent form via ``ConsentDocumentExportRepresentation/Configuration``. public init( markdown: @escaping () async -> Data, - action: @escaping @MainActor (_ document: PDFDocument) async throws -> Void, + action: @escaping (_ document: PDFDocument) async throws -> Void, title: LocalizedStringResource? = LocalizationDefaults.consentFormTitle, currentDateInSignature: Bool = true, exportConfiguration: ConsentDocumentExportRepresentation.Configuration = .init() diff --git a/Sources/SpeziOnboarding/SpeziOnboarding.docc/ObtainingUserConsent.md b/Sources/SpeziOnboarding/SpeziOnboarding.docc/ObtainingUserConsent.md index f4c5e9a..ca40f58 100644 --- a/Sources/SpeziOnboarding/SpeziOnboarding.docc/ObtainingUserConsent.md +++ b/Sources/SpeziOnboarding/SpeziOnboarding.docc/ObtainingUserConsent.md @@ -18,17 +18,18 @@ The ``OnboardingConsentView`` can allow users to read and agree to a document, e @Image(source: "ConsentView.png") -The following example demonstrates how the ``OnboardingConsentView`` shown above is constructed by providing a header, markdown content encoded as a [UTF8](https://www.swift.org/blog/utf8-string/) [`Data`](https://developer.apple.com/documentation/foundation/data) instance (which may be provided asynchronously), and an action that should be performed once the consent has been given. +The following example demonstrates how the ``OnboardingConsentView`` shown above is constructed by providing a header, markdown content encoded as a [UTF8](https://www.swift.org/blog/utf8-string/) [`Data`](https://developer.apple.com/documentation/foundation/data) instance (which may be provided asynchronously), an action that should be performed once the consent has been given (which receives the exported consent form as a PDF), as well as a configuration defining the properties of the exported consent form. ```swift OnboardingConsentView( markdown: { Data("This is a *markdown* **example**".utf8) }, - action: { - // Action to perform once the user has given their consent + action: { exportedConsentPdf in + // Action to perform once the user has given their consent. + // Closure receives the exported consent PDF to persist or upload it. }, - identifier: "MyFirstConsentForm", // Specify an optional unique identifier for the consent form, helpful for distinguishing consent forms when storing. + title: "Consent", // Configure the title of the consent view exportConfiguration: .init(paperSize: .usLetter), // Configure the properties of the exported consent form. currentDateInSignature: true // Indicates if the consent signature should include the current date. ) @@ -37,49 +38,30 @@ OnboardingConsentView( ### Using multiple consent forms If you want to show multiple consent documents to the user, that need to be signed separately, you can add multiple instances of ``OnboardingConsentView``. -In that case, it is important that you provide each instance with an unique document identifier String, to distinguish the two consent documents when they are stored. Consider the example code below. +If used within the ``OnboardingStack``, it is important to specify a unique `View/onboardingIdentifier(:identifier)` for each ``OnboardingConsentView``. -First, define an enum which holds a document identifier String for each of the two (or more) documents. We recommend using an enum to hold the -identifier strings to avoid having to write them explicitly throughout your App (e.g., in the ``OnboardingConsentView`` and the Spezi `Standard`). ```swift -enum DocumentIdentifiers { - static let first = "firstConsentDocument" - static let second = "secondConsentDocument" +OnboardingStack { + OnboardingConsentView( + markdown: { Data("This is a *markdown* **example**".utf8) }, + action: { firstConsentPdf in + // Store or share the first signed consent form. + // Use the `OnboardingNavigationPath` from the SwiftUI `@Environment` to navigate to the next `OnboardingConsentView`. + } + ) + .onboardingIdentifier("firstConsentView") // Set an identifier (String) for the `View`, to distinguish it from other `View`s of the same type. + + OnboardingConsentView( + markdown: { Data("This is a *markdown* **example**".utf8) }, + action: { secondConsentPdf in + // Store or share the second signed consent form. + } + ) + .onboardingIdentifier("secondConsentView"), // Set an identifier for the `View`, to distinguish it from other `View`s of the same type. } ``` -Next, use the identifier to instantiate two consent views with separate documents. -Note, that you will also have to set the "onboardingIdentifier", so that Spezi can distinguish the views. We recommend that you simply reuse your document identifier for the onboardingIdentifier. - -```swift -OnboardingConsentView( - markdown: { - Data("This is a *markdown* **example**".utf8) - }, - action: { - // Action to perform once the user has given their consent - }, - identifier: DocumentIdentifiers.first, // Specify an optional unique identifier for the consent form, helpful for distinguishing consent forms when storing. - exportConfiguration: .init(paperSize: .usLetter), // Configure the properties of the exported consent form. - currentDateInSignature: true // Indicates if the consent signature should include the current date. -) - .onboardingIdentifier(DocumentIdentifiers.first) // Set an identifier (String) for the view, to distinguish it from other views of the same type. - -OnboardingConsentView( - markdown: { - Data("This is a *markdown* **example**".utf8) - }, - action: { - // Action to perform once the user has given their consent - }, - identifier: DocumentIdentifiers.second, // Specify an optional unique identifier for the consent form, helpful for distinguishing consent forms when storing. - exportConfiguration: .init(paperSize: .usLetter), // Configure the properties of the exported consent form. - currentDateInSignature: false // Indicates if the consent signature should include the current date. -) - .onboardingIdentifier(DocumentIdentifiers.second), // Set an identifier for the view, to distinguish it from other views of the same type. -``` - ## Topics ### Views