diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index e4d8f89..6220480 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -1,7 +1,7 @@ # -# This source file is part of the Stanford Spezi open-source project +# This source file is part of the Stanford Spezi open source project # -# SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) +# SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) # # SPDX-License-Identifier: MIT # @@ -24,14 +24,16 @@ jobs: include: - buildConfig: Debug artifactname: SpeziOnboarding-iOS.xcresult + resultBundle: SpeziOnboarding-iOS.xcresult - buildConfig: Release artifactname: SpeziOnboarding-iOS-Release.xcresult + resultBundle: SpeziOnboarding-iOS-Release.xcresult with: runsonlabels: '["macOS", "self-hosted"]' scheme: SpeziOnboarding buildConfig: ${{ matrix.buildConfig }} + resultBundle: ${{ matrix.resultBundle }} artifactname: ${{ matrix.artifactname }} - resultBundle: ${{ matrix.artifactname }} buildandtest_visionos: name: Build and Test Swift Package visionOS uses: StanfordSpezi/.github/.github/workflows/xcodebuild-or-fastlane.yml@v2 @@ -40,15 +42,36 @@ jobs: include: - buildConfig: Debug artifactname: SpeziOnboarding-visionOS.xcresult + resultBundle: SpeziOnboarding-visionOS.xcresult - buildConfig: Release artifactname: SpeziOnboarding-visionOS-Release.xcresult + resultBundle: SpeziOnboarding-visionOS-Release.xcresult with: runsonlabels: '["macOS", "self-hosted"]' + scheme: SpeziOnboarding destination: 'platform=visionOS Simulator,name=Apple Vision Pro' + buildConfig: ${{ matrix.buildConfig }} + resultBundle: ${{ matrix.resultBundle }} + artifactname: ${{ matrix.artifactname }} + buildandtest_macos: + name: Build and Test Swift Package macOS + uses: StanfordSpezi/.github/.github/workflows/xcodebuild-or-fastlane.yml@v2 + strategy: + matrix: + include: + - buildConfig: Debug + artifactname: SpeziOnboarding-macOS.xcresult + resultBundle: SpeziOnboarding-macOS.xcresult + - buildConfig: Release + artifactname: SpeziOnboarding-macOS-Release.xcresult + resultBundle: SpeziOnboarding-macOS-Release.xcresult + with: + runsonlabels: '["macOS", "self-hosted"]' scheme: SpeziOnboarding + destination: 'platform=macOS,arch=arm64' buildConfig: ${{ matrix.buildConfig }} + resultBundle: ${{ matrix.resultBundle }} artifactname: ${{ matrix.artifactname }} - resultBundle: ${{ matrix.artifactname }} buildandtestuitests_ios: name: Build and Test UI Tests iOS uses: StanfordSpezi/.github/.github/workflows/xcodebuild-or-fastlane.yml@v2 @@ -56,34 +79,38 @@ jobs: matrix: include: - buildConfig: Debug + resultBundle: TestApp-iOS.xcresult artifactname: TestApp-iOS.xcresult - buildConfig: Release + resultBundle: TestApp-iOS-Release.xcresult artifactname: TestApp-iOS-Release.xcresult with: runsonlabels: '["macOS", "self-hosted"]' path: 'Tests/UITests' scheme: TestApp buildConfig: ${{ matrix.buildConfig }} + resultBundle: ${{ matrix.resultBundle }} artifactname: ${{ matrix.artifactname }} - resultBundle: ${{ matrix.artifactname }} buildandtestuitests_ipad: - name: Build and Test UI Tests iPad + name: Build and Test UI Tests iPadOS uses: StanfordSpezi/.github/.github/workflows/xcodebuild-or-fastlane.yml@v2 strategy: matrix: include: - buildConfig: Debug + resultBundle: TestApp-iPad.xcresult artifactname: TestApp-iPad.xcresult - buildConfig: Release + resultBundle: TestApp-iPad-Release.xcresult artifactname: TestApp-iPad-Release.xcresult with: runsonlabels: '["macOS", "self-hosted"]' - destination: 'platform=iOS Simulator,name=iPad Air (5th generation)' path: 'Tests/UITests' scheme: TestApp + destination: 'platform=iOS Simulator,name=iPad Air (5th generation)' buildConfig: ${{ matrix.buildConfig }} + resultBundle: ${{ matrix.resultBundle }} artifactname: ${{ matrix.artifactname }} - resultBundle: ${{ matrix.artifactname }} buildandtestuitests_visionos: name: Build and Test UI Tests visionOS uses: StanfordSpezi/.github/.github/workflows/xcodebuild-or-fastlane.yml@v2 @@ -91,20 +118,22 @@ jobs: matrix: include: - buildConfig: Debug + resultBundle: TestApp-visionOS.xcresult artifactname: TestApp-visionOS.xcresult - buildConfig: Release + resultBundle: TestApp-visionOS-Release.xcresult artifactname: TestApp-visionOS-Release.xcresult with: runsonlabels: '["macOS", "self-hosted"]' - destination: 'platform=visionOS Simulator,name=Apple Vision Pro' path: 'Tests/UITests' scheme: TestApp + destination: 'platform=visionOS Simulator,name=Apple Vision Pro' buildConfig: ${{ matrix.buildConfig }} + resultBundle: ${{ matrix.resultBundle }} artifactname: ${{ matrix.artifactname }} - resultBundle: ${{ matrix.artifactname }} uploadcoveragereport: name: Upload Coverage Report - needs: [buildandtest_ios, buildandtest_visionos, buildandtestuitests_ios, buildandtestuitests_ipad, buildandtestuitests_visionos] + needs: [buildandtest_ios, buildandtest_visionos, buildandtest_macos, buildandtestuitests_ios, buildandtestuitests_ipad, buildandtestuitests_visionos] uses: StanfordSpezi/.github/.github/workflows/create-and-upload-coverage-report.yml@v2 with: - coveragereports: SpeziOnboarding-iOS.xcresult SpeziOnboarding-visionOS.xcresult TestApp-iOS.xcresult TestApp-iPad.xcresult TestApp-visionOS.xcresult + coveragereports: 'SpeziOnboarding-iOS.xcresult SpeziOnboarding-visionOS.xcresult SpeziOnboarding-macOS.xcresult TestApp-iOS.xcresult TestApp-iPad.xcresult TestApp-visionOS.xcresult' \ No newline at end of file diff --git a/Package.swift b/Package.swift index a1f25ab..f425582 100644 --- a/Package.swift +++ b/Package.swift @@ -16,14 +16,15 @@ let package = Package( defaultLocalization: "en", platforms: [ .iOS(.v17), - .visionOS(.v1) + .visionOS(.v1), + .macOS(.v14) ], products: [ .library(name: "SpeziOnboarding", targets: ["SpeziOnboarding"]) ], dependencies: [ - .package(url: "https://github.com/StanfordSpezi/Spezi", from: "1.2.0"), - .package(url: "https://github.com/StanfordSpezi/SpeziViews", from: "1.3.0") + .package(url: "https://github.com/StanfordSpezi/Spezi", from: "1.2.1"), + .package(url: "https://github.com/StanfordSpezi/SpeziViews", from: "1.3.1") ], targets: [ .target( diff --git a/Sources/SpeziOnboarding/ConsentView/ConsentDocument+Export.swift b/Sources/SpeziOnboarding/ConsentView/ConsentDocument+Export.swift new file mode 100644 index 0000000..c18d198 --- /dev/null +++ b/Sources/SpeziOnboarding/ConsentView/ConsentDocument+Export.swift @@ -0,0 +1,142 @@ +// +// 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 + + +/// Extension of `ConsentDocument` enabling the export of the signed consent page. +extension ConsentDocument { + #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. + private var blackInkSignatureImage: UIImage { + var updatedDrawing = PKDrawing() + + for stroke in signature.strokes { + let blackStroke = PKStroke( + ink: PKInk(stroke.ink.inkType, color: colorScheme == .light ? .black : .white), + path: stroke.path, + transform: stroke.transform, + mask: stroke.mask + ) + + updatedDrawing.strokes.append(blackStroke) + } + + #if os(iOS) + let scale = UIScreen.main.scale + #else + let scale = 3.0 // retina scale is default + #endif + + return updatedDrawing.image( + from: .init(x: 0, y: 0, width: signatureSize.width, height: signatureSize.height), + scale: scale + ) + } + #endif + + + /// Creates a representation of the consent form that is ready to be exported via the SwiftUI `ImageRenderer`. + /// + /// - Parameters: + /// - markdown: The markdown consent content as an `AttributedString`. + /// + /// - Returns: A SwiftUI `View` representation of the consent content and signature. + /// + /// - Note: This function avoids the use of asynchronous operations. + /// Asynchronous tasks are incompatible with SwiftUI's `ImageRenderer`, + /// which expects all rendering processes to be synchronous. + private func exportBody(markdown: AttributedString) -> some View { + VStack { + if exportConfiguration.includingTimestamp { + HStack { + Spacer() + + Text("EXPORTED_TAG", bundle: .module) + + Text(verbatim: ": \(DateFormatter.localizedString(from: Date(), dateStyle: .medium, timeStyle: .short))") + } + .font(.caption) + .padding() + } + + OnboardingTitleView(title: exportConfiguration.consentTitle) + + Text(markdown) + .padding() + + Spacer() + + ZStack(alignment: .bottomLeading) { + SignatureViewBackground(name: name, backgroundColor: .clear) + + #if !os(macOS) + Image(uiImage: blackInkSignatureImage) + #else + Text(signature) + .padding(.bottom, 32) + .padding(.leading, 46) + .font(.custom("Snell Roundhand", size: 24)) + #endif + } + #if !os(macOS) + .frame(width: signatureSize.width, height: signatureSize.height) + #else + .padding(.horizontal, 100) + #endif + } + } + + /// Exports the signed consent form as a `PDFDocument` via the SwiftUI `ImageRenderer`. + /// + /// Renders the `PDFDocument` according to the specified ``ConsentDocument/ExportConfiguration``. + /// + /// - Returns: The exported consent form in PDF format as a PDFKit `PDFDocument` + @MainActor + func export() async -> PDFDocument? { + let markdown = await asyncMarkdown() + + let markdownString = (try? AttributedString( + markdown: markdown, + options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace) + )) ?? AttributedString(String(localized: "MARKDOWN_LOADING_ERROR", bundle: .module)) + + let renderer = ImageRenderer(content: exportBody(markdown: markdownString)) + let paperSize = CGSize( + width: exportConfiguration.paperSize.dimensions.width, + height: exportConfiguration.paperSize.dimensions.height + ) + renderer.proposedSize = .init(paperSize) + + return await withCheckedContinuation { continuation in + renderer.render { _, context in + var box = CGRect(origin: .zero, size: paperSize) + + /// Create in-memory `CGContext` that stores the PDF + guard let mutableData = CFDataCreateMutable(kCFAllocatorDefault, 0), + let consumer = CGDataConsumer(data: mutableData), + let pdf = CGContext(consumer: consumer, mediaBox: &box, nil) else { + continuation.resume(returning: nil) + return + } + + pdf.beginPDFPage(nil) + pdf.translateBy(x: 0, y: 0) + + context(pdf) + + pdf.endPDFPage() + pdf.closePDF() + + continuation.resume(returning: PDFDocument(data: mutableData as Data)) + } + } + } +} diff --git a/Sources/SpeziOnboarding/ConsentView/ConsentDocument.swift b/Sources/SpeziOnboarding/ConsentView/ConsentDocument.swift index 40d0c3a..f40b0c0 100644 --- a/Sources/SpeziOnboarding/ConsentView/ConsentDocument.swift +++ b/Sources/SpeziOnboarding/ConsentView/ConsentDocument.swift @@ -7,7 +7,6 @@ // import Foundation -import PDFKit import PencilKit import SpeziPersonalInfo import SpeziViews @@ -42,36 +41,36 @@ public struct ConsentDocument: View { /// The maximum width such that the drawing canvas fits onto the PDF. static let maxWidthDrawing: CGFloat = 550 - private let asyncMarkdown: () async -> Data + let asyncMarkdown: () async -> Data private let givenNameTitle: LocalizedStringResource private let givenNamePlaceholder: LocalizedStringResource private let familyNameTitle: LocalizedStringResource private let familyNamePlaceholder: LocalizedStringResource - private let exportConfiguration: ExportConfiguration + let exportConfiguration: ExportConfiguration - @Environment(\.colorScheme) private var colorScheme - @State private var name = PersonNameComponents() - @State private var signature = PKDrawing() - @State private var signatureSize: CGSize = .zero + @Environment(\.colorScheme) var colorScheme + @State var name = PersonNameComponents() + #if !os(macOS) + @State var signature = PKDrawing() + #else + @State var signature = String() + #endif + @State var signatureSize: CGSize = .zero @Binding private var viewState: ConsentViewState private var nameView: some View { VStack { Divider() - Grid(horizontalSpacing: 15) { - NameFieldRow(name: $name, for: \.givenName) { - Text(givenNameTitle) - } label: { - Text(givenNamePlaceholder) - } - Divider() - .gridCellUnsizedAxes(.horizontal) - NameFieldRow(name: $name, for: \.familyName) { - Text(familyNameTitle) - } label: { - Text(familyNamePlaceholder) + Group { + #if !os(macOS) + nameInputView + #else + // Need to wrap the `NameFieldRow` from SpeziViews into a SwiftUI `Form, otherwise the Label is omitted + Form { + nameInputView } + #endif } .disabled(inputFieldsDisabled) .onChange(of: name) { @@ -79,8 +78,12 @@ public struct ConsentDocument: View { viewState = .namesEntered } else { viewState = .base(.idle) - /// Reset all strokes if name fields are not complete anymore + // Reset all strokes if name fields are not complete anymore + #if !os(macOS) signature.strokes.removeAll() + #else + signature.removeAll() + #endif } } @@ -88,12 +91,40 @@ public struct ConsentDocument: View { } } + private var nameInputView: some View { + Grid(horizontalSpacing: 15) { + NameFieldRow(name: $name, for: \.givenName) { + Text(givenNameTitle) + } label: { + Text(givenNamePlaceholder) + } + Divider() + .gridCellUnsizedAxes(.horizontal) + NameFieldRow(name: $name, for: \.familyName) { + Text(familyNameTitle) + } label: { + Text(familyNamePlaceholder) + } + } + } + private var signatureView: some View { - SignatureView(signature: $signature, isSigning: $viewState.signing, canvasSize: $signatureSize, name: name) + Group { + #if !os(macOS) + SignatureView(signature: $signature, isSigning: $viewState.signing, canvasSize: $signatureSize, name: name) + #else + SignatureView(signature: $signature, name: name) + #endif + } .padding(.vertical, 4) .disabled(inputFieldsDisabled) .onChange(of: signature) { - if !(signature.strokes.isEmpty || (name.givenName?.isEmpty ?? true) || (name.familyName?.isEmpty ?? true)) { + #if !os(macOS) + let isSignatureEmpty = signature.strokes.isEmpty + #else + let isSignatureEmpty = signature.isEmpty + #endif + if !(isSignatureEmpty || (name.givenName?.isEmpty ?? true) || (name.familyName?.isEmpty ?? true)) { viewState = .signed } else { viewState = .namesEntered @@ -114,7 +145,7 @@ public struct ConsentDocument: View { signatureView } } - .frame(maxWidth: Self.maxWidthDrawing) // we limit the max view size so it fits on the PDF + .frame(maxWidth: Self.maxWidthDrawing) // Limit the max view size so it fits on the PDF } .transition(.opacity) .animation(.easeInOut, value: viewState == .namesEntered) @@ -129,15 +160,21 @@ public struct ConsentDocument: View { } } else if case .base(let baseViewState) = viewState, case .idle = baseViewState { - /// Reset view state to correct one after handling an error view state via `.viewStateAlert()` - if !signature.strokes.isEmpty { + // Reset view state to correct one after handling an error view state via `.viewStateAlert()` + #if !os(macOS) + let isSignatureEmpty = signature.strokes.isEmpty + #else + let isSignatureEmpty = signature.isEmpty + #endif + + if !isSignatureEmpty { viewState = .signed - } else if !((name.givenName?.isEmpty ?? true) || (name.familyName?.isEmpty ?? true)) { + } else if !(name.givenName?.isEmpty ?? true) || !(name.familyName?.isEmpty ?? true) { viewState = .namesEntered } } } - .viewStateAlert(state: $viewState.base) + .viewStateAlert(state: $viewState.base) } private var inputFieldsDisabled: Bool { @@ -177,123 +214,6 @@ public struct ConsentDocument: View { } -/// Extension of `ConsentDocument` enabling the export of the signed consent page. -extension ConsentDocument { - /// 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. - private var blackInkSignatureImage: UIImage { - var updatedDrawing = PKDrawing() - - for stroke in signature.strokes { - let blackStroke = PKStroke( - ink: PKInk(stroke.ink.inkType, color: colorScheme == .light ? .black : .white), - path: stroke.path, - transform: stroke.transform, - mask: stroke.mask - ) - - updatedDrawing.strokes.append(blackStroke) - } - - #if os(iOS) - let scale = UIScreen.main.scale - #else - let scale = 3.0 // retina scale is default - #endif - - return updatedDrawing.image( - from: .init(x: 0, y: 0, width: signatureSize.width, height: signatureSize.height), - scale: scale - ) - } - - /// Exports the signed consent form as a `PDFDocument` via the SwiftUI `ImageRenderer`. - /// - /// Renders the `PDFDocument` according to the specified ``ConsentDocument/ExportConfiguration``. - /// - /// - Returns: The exported consent form in PDF format as a PDFKit `PDFDocument` - @MainActor - private func export() async -> PDFDocument? { - let markdown = await asyncMarkdown() - - let markdownString = (try? AttributedString( - markdown: markdown, - options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace) - )) ?? AttributedString(String(localized: "MARKDOWN_LOADING_ERROR", bundle: .module)) - - let renderer = ImageRenderer(content: exportBody(markdown: markdownString)) - let paperSize = CGSize( - width: exportConfiguration.paperSize.dimensions.width, - height: exportConfiguration.paperSize.dimensions.height - ) - renderer.proposedSize = .init(paperSize) - - return await withCheckedContinuation { continuation in - renderer.render { _, context in - var box = CGRect(origin: .zero, size: paperSize) - - /// Create in-memory `CGContext` that stores the PDF - guard let mutableData = CFDataCreateMutable(kCFAllocatorDefault, 0), - let consumer = CGDataConsumer(data: mutableData), - let pdf = CGContext(consumer: consumer, mediaBox: &box, nil) else { - continuation.resume(returning: nil) - return - } - - pdf.beginPDFPage(nil) - pdf.translateBy(x: 0, y: 0) - - context(pdf) - - pdf.endPDFPage() - pdf.closePDF() - - continuation.resume(returning: PDFDocument(data: mutableData as Data)) - } - } - } - - /// Creates a representation of the consent form that is ready to be exported via the SwiftUI `ImageRenderer`. - /// - /// - Parameters: - /// - markdown: The markdown consent content as an `AttributedString`. - /// - /// - Returns: A SwiftUI `View` representation of the consent content and signature. - /// - /// - Note: This function avoids the use of asynchronous operations. - /// Asynchronous tasks are incompatible with SwiftUI's `ImageRenderer`, - /// which expects all rendering processes to be synchronous. - private func exportBody(markdown: AttributedString) -> some View { - VStack { - if exportConfiguration.includingTimestamp { - HStack { - Spacer() - - Text("EXPORTED_TAG", bundle: .module) - + Text(verbatim: ": \(DateFormatter.localizedString(from: Date(), dateStyle: .medium, timeStyle: .short))") - } - .font(.caption) - .padding() - } - - OnboardingTitleView(title: exportConfiguration.consentTitle) - - Text(markdown) - .padding() - - Spacer() - - ZStack(alignment: .bottomLeading) { - SignatureViewBackground(name: name, backgroundColor: .clear) - - Image(uiImage: blackInkSignatureImage) - } - .frame(width: signatureSize.width, height: signatureSize.height) - } - } -} - - #if DEBUG struct ConsentDocument_Previews: PreviewProvider { @State private static var viewState: ConsentViewState = .base(.idle) diff --git a/Sources/SpeziOnboarding/ConsentView/OnboardingConsentView+ShareSheet.swift b/Sources/SpeziOnboarding/ConsentView/OnboardingConsentView+ShareSheet.swift index 2207769..e84f1e7 100644 --- a/Sources/SpeziOnboarding/ConsentView/OnboardingConsentView+ShareSheet.swift +++ b/Sources/SpeziOnboarding/ConsentView/OnboardingConsentView+ShareSheet.swift @@ -8,16 +8,21 @@ import PDFKit import SwiftUI +#if os(macOS) +import AppKit +#else import UIKit +#endif extension OnboardingConsentView { + #if !os(macOS) struct ShareSheet: UIViewControllerRepresentable { let sharedItem: PDFDocument func makeUIViewController(context: Context) -> UIActivityViewController { - /// Note: Need to write down the PDF to storage as in-memory PDFs are not recognized properly + // Note: Need to write down the PDF to storage as in-memory PDFs are not recognized properly let temporaryPath = FileManager.default.temporaryDirectory.appendingPathComponent( LocalizedStringResource("FILE_NAME_EXPORTED_CONSENT_FORM", bundle: .atURL(from: .module)).localizedString() + ".pdf" ) @@ -36,4 +41,25 @@ extension OnboardingConsentView { func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {} } + #else + struct ShareSheet { + let sharedItem: PDFDocument + + + 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( + LocalizedStringResource("FILE_NAME_EXPORTED_CONSENT_FORM", bundle: .atURL(from: .module)).localizedString() + ".pdf" + ) + try? sharedItem.dataRepresentation()?.write(to: temporaryPath) + + let sharingServicePicker = NSSharingServicePicker(items: [temporaryPath]) + + // Present the sharing service picker + if let keyWindow = NSApp.keyWindow, let contentView = keyWindow.contentView { + sharingServicePicker.show(relativeTo: contentView.bounds, of: contentView, preferredEdge: .minY) + } + } + } + #endif } diff --git a/Sources/SpeziOnboarding/OnboardingConsentView.swift b/Sources/SpeziOnboarding/OnboardingConsentView.swift index f5a4066..98f1276 100644 --- a/Sources/SpeziOnboarding/OnboardingConsentView.swift +++ b/Sources/SpeziOnboarding/OnboardingConsentView.swift @@ -102,7 +102,7 @@ public struct OnboardingConsentView: View { } } .toolbar { - ToolbarItem(placement: .topBarTrailing) { + ToolbarItem(placement: .primaryAction) { Button(action: { viewState = .export willShowShareSheet = true @@ -118,18 +118,39 @@ public struct OnboardingConsentView: View { } } .sheet(isPresented: $showShareSheet) { - switch viewState { - case .exported(let exportedConsentDocumented): + if case .exported(let exportedConsentDocumented) = viewState { + #if !os(macOS) ShareSheet(sharedItem: exportedConsentDocumented) .presentationDetents([.medium]) .task { willShowShareSheet = false } - default: + #endif + } else { ProgressView() .padding() } } + #if os(macOS) + .onChange(of: showShareSheet) { _, isPresented in + if isPresented, + case .exported(let exportedConsentDocumented) = viewState { + let shareSheet = ShareSheet(sharedItem: exportedConsentDocumented) + shareSheet.show() + + showShareSheet = false + } + } + // `NSSharingServicePicker` doesn't provide a completion handler as `UIActivityViewController` does, + // therefore necessitating the deletion of the temporary file on disappearing. + .onDisappear { + try? FileManager.default.removeItem( + at: FileManager.default.temporaryDirectory.appendingPathComponent( + LocalizedStringResource("FILE_NAME_EXPORTED_CONSENT_FORM", bundle: .atURL(from: .module)).localizedString() + ".pdf" + ) + ) + } + #endif } private var actionButtonsEnabled: Bool { diff --git a/Sources/SpeziOnboarding/SequentialOnboardingView.swift b/Sources/SpeziOnboarding/SequentialOnboardingView.swift index 50a254a..bbed39f 100644 --- a/Sources/SpeziOnboarding/SequentialOnboardingView.swift +++ b/Sources/SpeziOnboarding/SequentialOnboardingView.swift @@ -288,7 +288,11 @@ public struct SequentialOnboardingView: View { .padding(.bottom, 12) .background { RoundedRectangle(cornerRadius: 16) + #if !os(macOS) .fill(Color(.systemGroupedBackground)) + #else + .fill(Color(.windowBackgroundColor)) + #endif } } } diff --git a/Sources/SpeziOnboarding/SignatureView.swift b/Sources/SpeziOnboarding/SignatureView.swift index 91eaa7b..c98f99a 100644 --- a/Sources/SpeziOnboarding/SignatureView.swift +++ b/Sources/SpeziOnboarding/SignatureView.swift @@ -26,11 +26,15 @@ import SwiftUI /// ) /// ``` public struct SignatureView: View { + #if !os(macOS) @Environment(\.undoManager) private var undoManager @Binding private var signature: PKDrawing - @Binding private var isSigning: Bool @Binding private var canvasSize: CGSize + @Binding private var isSigning: Bool @State private var canUndo = false + #else + @Binding private var signature: String + #endif private let name: PersonNameComponents private let lineOffset: CGFloat @@ -39,7 +43,8 @@ public struct SignatureView: View { VStack { ZStack(alignment: .bottomLeading) { SignatureViewBackground(name: name, lineOffset: lineOffset) - + + #if !os(macOS) CanvasView(drawing: $signature, isDrawing: $isSigning, showToolPicker: .constant(false)) .accessibilityLabel(Text("SIGNATURE_FIELD", bundle: .module)) .accessibilityAddTraits(.allowsDirectInteraction) @@ -47,8 +52,13 @@ public struct SignatureView: View { // for some reason, the preference won't update on visionOS if placed in a parent view self.canvasSize = size } + #else + signatureTextField + #endif } .frame(height: 120) + + #if !os(macOS) Button( action: { undoManager?.undo() @@ -59,7 +69,9 @@ public struct SignatureView: View { } ) .disabled(!canUndo) + #endif } + #if !os(macOS) .onChange(of: isSigning) { Task { @MainActor in canUndo = undoManager?.canUndo ?? false @@ -67,9 +79,27 @@ public struct SignatureView: View { } .transition(.opacity) .animation(.easeInOut, value: canUndo) + #endif } + #if os(macOS) + private var signatureTextField: some View { + TextField(text: $signature) { + Text("SIGNATURE_FIELD", bundle: .module) + } + .accessibilityLabel(Text("SIGNATURE_FIELD", bundle: .module)) + .accessibilityAddTraits(.allowsDirectInteraction) + .font(.custom("Snell Roundhand", size: 32)) + .textFieldStyle(PlainTextFieldStyle()) + .background(Color.clear) + .padding(.bottom, lineOffset + 2) + .padding(.leading, 46) + .padding(.trailing, 24) + } + #endif + + #if !os(macOS) /// Creates a new instance of an ``SignatureView``. /// - Parameters: /// - signature: A `Binding` containing the current signature as an `PKDrawing`. @@ -90,6 +120,22 @@ public struct SignatureView: View { self.name = name self.lineOffset = lineOffset } + #else + /// Creates a new instance of an ``SignatureView``. + /// - Parameters: + /// - 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( + signature: Binding = .constant(String()), + name: PersonNameComponents = PersonNameComponents(), + lineOffset: CGFloat = 30 + ) { + self._signature = signature + self.name = name + self.lineOffset = lineOffset + } + #endif } diff --git a/Sources/SpeziOnboarding/SignatureViewBackground.swift b/Sources/SpeziOnboarding/SignatureViewBackground.swift index 680e9cd..79d072a 100644 --- a/Sources/SpeziOnboarding/SignatureViewBackground.swift +++ b/Sources/SpeziOnboarding/SignatureViewBackground.swift @@ -13,11 +13,19 @@ import SwiftUI struct SignatureViewBackground: View { private let name: PersonNameComponents private let lineOffset: CGFloat + #if !os(macOS) private let backgroundColor: UIColor + #else + private let backgroundColor: NSColor + #endif var body: some View { + #if !os(macOS) Color(uiColor: backgroundColor) + #else + Color(nsColor: backgroundColor) + #endif Rectangle() .fill(.secondary) .frame(maxWidth: .infinity, maxHeight: 1) @@ -46,6 +54,7 @@ struct SignatureViewBackground: View { /// - 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. /// - backgroundColor: The color of the background of the signature canvas. + #if !os(macOS) init( name: PersonNameComponents = PersonNameComponents(), lineOffset: CGFloat = 30, @@ -55,4 +64,15 @@ struct SignatureViewBackground: View { self.lineOffset = lineOffset self.backgroundColor = backgroundColor } + #else + init( + name: PersonNameComponents = PersonNameComponents(), + lineOffset: CGFloat = 30, + backgroundColor: NSColor = .secondarySystemFill + ) { + self.name = name + self.lineOffset = lineOffset + self.backgroundColor = backgroundColor + } + #endif } diff --git a/Tests/UITests/TestApp/TestApp.swift b/Tests/UITests/TestApp/TestApp.swift index 6eadac0..23884a5 100644 --- a/Tests/UITests/TestApp/TestApp.swift +++ b/Tests/UITests/TestApp/TestApp.swift @@ -12,7 +12,7 @@ import SwiftUI @main struct UITestsApp: App { - @UIApplicationDelegateAdaptor(TestAppDelegate.self) var appDelegate + @ApplicationDelegateAdaptor(TestAppDelegate.self) var appDelegate @State var onboardingFlowComplete = false diff --git a/Tests/UITests/TestApp/Views/OnboardingConsentMarkdownRenderingView.swift b/Tests/UITests/TestApp/Views/OnboardingConsentMarkdownRenderingView.swift index 8e31019..bb63f9b 100644 --- a/Tests/UITests/TestApp/Views/OnboardingConsentMarkdownRenderingView.swift +++ b/Tests/UITests/TestApp/Views/OnboardingConsentMarkdownRenderingView.swift @@ -49,13 +49,15 @@ struct OnboardingConsentMarkdownRenderingView: View { } .buttonStyle(.borderedProminent) } - .padding() - .navigationBarTitleDisplayMode(.inline) - .task { - self.exportedConsent = try? await standard.loadConsent() - // Reset OnboardingDataSource - await standard.store(consent: .init()) - } + .padding() + #if !os(macOS) + .navigationBarTitleDisplayMode(.inline) + #endif + .task { + self.exportedConsent = try? await standard.loadConsent() + // Reset OnboardingDataSource + await standard.store(consent: .init()) + } } } diff --git a/Tests/UITests/TestApp/Views/OnboardingSequentialTestView.swift b/Tests/UITests/TestApp/Views/OnboardingSequentialTestView.swift index 26fc22b..028d607 100644 --- a/Tests/UITests/TestApp/Views/OnboardingSequentialTestView.swift +++ b/Tests/UITests/TestApp/Views/OnboardingSequentialTestView.swift @@ -29,7 +29,9 @@ struct OnboardingSequentialTestView: View { ) { path.nextStep() } - .navigationBarTitleDisplayMode(.inline) + #if !os(macOS) + .navigationBarTitleDisplayMode(.inline) + #endif } } diff --git a/Tests/UITests/TestApp/Views/OnboardingWelcomeTestView.swift b/Tests/UITests/TestApp/Views/OnboardingWelcomeTestView.swift index 5723b50..72011c9 100644 --- a/Tests/UITests/TestApp/Views/OnboardingWelcomeTestView.swift +++ b/Tests/UITests/TestApp/Views/OnboardingWelcomeTestView.swift @@ -31,7 +31,9 @@ struct OnboardingWelcomeTestView: View { path.nextStep() } ) - .navigationBarTitleDisplayMode(.inline) + #if !os(macOS) + .navigationBarTitleDisplayMode(.inline) + #endif } } diff --git a/Tests/UITests/TestAppUITests/SpeziOnboardingTests.swift b/Tests/UITests/TestAppUITests/SpeziOnboardingTests.swift index ca2b59f..bcad0b1 100644 --- a/Tests/UITests/TestAppUITests/SpeziOnboardingTests.swift +++ b/Tests/UITests/TestAppUITests/SpeziOnboardingTests.swift @@ -56,10 +56,16 @@ final class OnboardingTests: XCTestCase { // swiftlint:disable:this type_body_le try app.textFields["Enter your last name ..."].enter(value: "Stanford") XCTAssert(app.staticTexts["Name: Leland Stanford"].waitForExistence(timeout: 2)) - app.staticTexts["Name: Leland Stanford"].firstMatch.swipeUp() - XCTAssert(app.buttons["I Consent"].waitForExistence(timeout: 2)) - app.buttons["I Consent"].tap() + #if !os(macOS) + XCTAssert(app.scrollViews["Signature Field"].waitForExistence(timeout: 2)) + app.scrollViews["Signature Field"].swipeRight() + #else + XCTAssert(app.textFields["Signature Field"].waitForExistence(timeout: 2)) + try app.textFields["Signature Field"].enter(value: "Leland Stanford") + #endif + + hitConsentButton(app) // Check if the consent export was successful XCTAssert(app.staticTexts["Consent PDF rendering exists"].waitForExistence(timeout: 2)) @@ -166,14 +172,19 @@ final class OnboardingTests: XCTestCase { // swiftlint:disable:this type_body_le hitConsentButton(app) XCTAssert(app.staticTexts["Name: Leland Stanford"].waitForExistence(timeout: 2)) + + #if !os(macOS) app.staticTexts["Name: Leland Stanford"].swipeRight() + XCTAssert(app.buttons["Undo"].waitForExistence(timeout: 2)) app.buttons["Undo"].tap() - hitConsentButton(app) - XCTAssert(app.scrollViews["Signature Field"].waitForExistence(timeout: 2)) app.scrollViews["Signature Field"].swipeRight() + #else + XCTAssert(app.textFields["Signature Field"].waitForExistence(timeout: 2)) + try app.textFields["Signature Field"].enter(value: "Leland Stanford") + #endif hitConsentButton(app) @@ -219,20 +230,21 @@ final class OnboardingTests: XCTestCase { // swiftlint:disable:this type_body_le hitConsentButton(app) XCTAssert(app.staticTexts["Name: Leland Stanford"].waitForExistence(timeout: 2)) - app.staticTexts["Name: Leland Stanford"].swipeRight() - XCTAssert(app.buttons["Undo"].waitForExistence(timeout: 2)) - app.buttons["Undo"].tap() - hitConsentButton(app) - + #if !os(macOS) XCTAssert(app.scrollViews["Signature Field"].waitForExistence(timeout: 2)) app.scrollViews["Signature Field"].swipeRight() + #else + XCTAssert(app.textFields["Signature Field"].waitForExistence(timeout: 2)) + try app.textFields["Signature Field"].enter(value: "Leland Stanford") + #endif hitConsentButton(app) XCTAssert(app.staticTexts["Consent PDF rendering exists"].waitForExistence(timeout: 2)) } + #if !os(macOS) // Only test export on non macOS platforms func testOnboardingConsentPDFExport() throws { // swiftlint:disable:this function_body_length let app = XCUIApplication() let filesApp = XCUIApplication(bundleIdentifier: "com.apple.DocumentsApp") @@ -252,6 +264,8 @@ final class OnboardingTests: XCTestCase { // swiftlint:disable:this type_body_le XCTAssert(app.staticTexts["Last Name"].waitForExistence(timeout: 2)) try app.textFields["Enter your last name ..."].enter(value: "Stanford") + XCTAssert(app.staticTexts["Name: Leland Stanford"].waitForExistence(timeout: 2)) + XCTAssert(app.scrollViews["Signature Field"].waitForExistence(timeout: 2)) app.scrollViews["Signature Field"].swipeRight() @@ -315,11 +329,11 @@ final class OnboardingTests: XCTestCase { // swiftlint:disable:this type_body_le sleep(3) // Wait until file is opened -#if os(visionOS) + #if os(visionOS) let fileView = XCUIApplication(bundleIdentifier: "com.apple.MRQuickLook") -#else + #else let fileView = filesApp -#endif + #endif // Check if PDF contains consent title, name, and markdown message for searchString in ["Spezi Consent", "This is a markdown example", "Leland Stanford"] { @@ -327,12 +341,13 @@ final class OnboardingTests: XCTestCase { // swiftlint:disable:this type_body_le XCTAssert(fileView.otherElements.containing(predicate).firstMatch.waitForExistence(timeout: 2)) } -#if os(iOS) + #if os(iOS) // Close File XCTAssert(fileView.buttons["Done"].waitForExistence(timeout: 2)) fileView.buttons["Done"].tap() -#endif + #endif } + #endif func testOnboardingCustomViews() throws { let app = XCUIApplication() diff --git a/Tests/UITests/UITests.xcodeproj/project.pbxproj b/Tests/UITests/UITests.xcodeproj/project.pbxproj index cf3baf7..9a73faa 100644 --- a/Tests/UITests/UITests.xcodeproj/project.pbxproj +++ b/Tests/UITests/UITests.xcodeproj/project.pbxproj @@ -373,12 +373,14 @@ GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; IPHONEOS_DEPLOYMENT_TARGET = 17.0; + MACOSX_DEPLOYMENT_TARGET = 14.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + XROS_DEPLOYMENT_TARGET = 1.0; }; name = Debug; }; @@ -428,12 +430,14 @@ GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; IPHONEOS_DEPLOYMENT_TARGET = 17.0; + MACOSX_DEPLOYMENT_TARGET = 14.0; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; SDKROOT = iphoneos; SWIFT_COMPILATION_MODE = wholemodule; SWIFT_OPTIMIZATION_LEVEL = "-O"; VALIDATE_PRODUCT = YES; + XROS_DEPLOYMENT_TARGET = 1.0; }; name = Release; }; @@ -445,7 +449,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = ""; - DEVELOPMENT_TEAM = WL9Z9UXPKF; + DEVELOPMENT_TEAM = 637867499T; ENABLE_PREVIEWS = YES; ENABLE_TESTING_SEARCH_PATHS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -461,9 +465,9 @@ MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = edu.stanford.spezi.onboarding.testapp; PRODUCT_NAME = "$(TARGET_NAME)"; - SUPPORTED_PLATFORMS = "iphoneos iphonesimulator xros xrsimulator"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; SUPPORTS_MACCATALYST = NO; - SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = YES; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_STRICT_CONCURRENCY = complete; SWIFT_VERSION = 5.0; @@ -479,7 +483,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = ""; - DEVELOPMENT_TEAM = WL9Z9UXPKF; + DEVELOPMENT_TEAM = 637867499T; ENABLE_PREVIEWS = YES; ENABLE_TESTING_SEARCH_PATHS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -495,9 +499,9 @@ MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = edu.stanford.spezi.onboarding.testapp; PRODUCT_NAME = "$(TARGET_NAME)"; - SUPPORTED_PLATFORMS = "iphoneos iphonesimulator xros xrsimulator"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; SUPPORTS_MACCATALYST = NO; - SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = YES; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_STRICT_CONCURRENCY = complete; SWIFT_VERSION = 5.0; @@ -518,9 +522,9 @@ PRODUCT_BUNDLE_IDENTIFIER = edu.stanford.spezi.onboarding.testappuitests; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE = ""; - SUPPORTED_PLATFORMS = "iphoneos iphonesimulator xros xrsimulator"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; SUPPORTS_MACCATALYST = NO; - SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = YES; SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; @@ -542,9 +546,9 @@ PRODUCT_BUNDLE_IDENTIFIER = edu.stanford.spezi.onboarding.testappuitests; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE = ""; - SUPPORTED_PLATFORMS = "iphoneos iphonesimulator xros xrsimulator"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; SUPPORTS_MACCATALYST = NO; - SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = YES; SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2,7"; @@ -604,12 +608,14 @@ GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; IPHONEOS_DEPLOYMENT_TARGET = 17.0; + MACOSX_DEPLOYMENT_TARGET = 14.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; SWIFT_ACTIVE_COMPILATION_CONDITIONS = TEST; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + XROS_DEPLOYMENT_TARGET = 1.0; }; name = Test; }; @@ -621,7 +627,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = ""; - DEVELOPMENT_TEAM = WL9Z9UXPKF; + DEVELOPMENT_TEAM = 637867499T; ENABLE_PREVIEWS = YES; ENABLE_TESTING_SEARCH_PATHS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -637,9 +643,9 @@ MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = edu.stanford.spezi.onboarding.testapp; PRODUCT_NAME = "$(TARGET_NAME)"; - SUPPORTED_PLATFORMS = "iphoneos iphonesimulator xros xrsimulator"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; SUPPORTS_MACCATALYST = NO; - SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = YES; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_STRICT_CONCURRENCY = complete; SWIFT_VERSION = 5.0; @@ -660,9 +666,9 @@ PRODUCT_BUNDLE_IDENTIFIER = edu.stanford.spezi.onboarding.testappuitests; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE = ""; - SUPPORTED_PLATFORMS = "iphoneos iphonesimulator xros xrsimulator"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; SUPPORTS_MACCATALYST = NO; - SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = YES; SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0;