From b02f0da9230ff9790584031fb6b5cbe098ff130e Mon Sep 17 00:00:00 2001 From: Paul Schmiedmayer Date: Fri, 16 Dec 2022 20:53:07 -0800 Subject: [PATCH] Consent View (#28) --- Sources/Onboarding/ConsentView.swift | 138 ++++++++++++++++++ Sources/Onboarding/OnboardingView.swift | 4 +- .../Resources/de.lproj/Localizable.strings | 7 + .../Resources/en.lproj/Localizable.strings | 7 + Sources/Onboarding/SignatureView.swift | 99 +++++++++++++ .../OnboardingTests/OnboardingTestsView.swift | 21 ++- .../TestAppUITests/HealthKitTests.swift | 12 +- .../Helper/XCTTestCase+HealthDataType.swift | 62 +++++++- .../TestAppUITests/OnboardingTests.swift | 51 ++++++- .../UITests.xcodeproj/TestApp.xctestplan | 3 + 10 files changed, 385 insertions(+), 19 deletions(-) create mode 100644 Sources/Onboarding/ConsentView.swift create mode 100644 Sources/Onboarding/SignatureView.swift diff --git a/Sources/Onboarding/ConsentView.swift b/Sources/Onboarding/ConsentView.swift new file mode 100644 index 00000000..028d150a --- /dev/null +++ b/Sources/Onboarding/ConsentView.swift @@ -0,0 +1,138 @@ +// +// This source file is part of the CardinalKit open-source project +// +// SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import PencilKit +import SwiftUI +import Views + + +/// The ``ConsentView`` allows the display of markdown-based documents that can be signed using a family and given name and a hand drawn signature. +/// +/// The ``ConsentView`` provides a convenience initalizer with a provided action view using an ``OnboardingActionsView`` (``ConsentView/init(header:asyncMarkdown:footer:action:)``) +/// or a more customized ``ConsentView/init(contentView:actionView:)`` initializer with a custom-provided content and action view. +/// +/// ``` +/// ConsentView( +/// asyncMarkdown: { +/// Data("This is a *markdown* **example**".utf8) +/// }, +/// action: { +/// // The action that should be performed once the user has providided their consent. +/// } +/// ) +/// ``` +public struct ConsentView: View { + private let contentView: ContentView + private let action: Action + @State private var name = PersonNameComponents() + @State private var showSignatureView = false + @State private var isSigning = false + @State private var signature = PKDrawing() + + + public var body: some View { + ScrollViewReader { proxy in + OnboardingView( + contentView: { + contentView + }, + actionView: { + VStack { + Divider() + NameFields(name: $name) + if showSignatureView { + Divider() + SignatureView(signature: $signature, isSigning: $isSigning, name: name) + .padding(.vertical, 4) + } + Divider() + action + .disabled(buttonDisabled) + .animation(.easeInOut, value: buttonDisabled) + .id("ActionButton") + .onChange(of: showSignatureView) { _ in + proxy.scrollTo("ActionButton") + } + } + .transition(.opacity) + .animation(.easeInOut, value: showSignatureView) + } + ) + .scrollDisabled(isSigning) + } + } + + var buttonDisabled: Bool { + let showSignatureView = !(name.givenName?.isEmpty ?? true) && !(name.familyName?.isEmpty ?? true) + if !self.showSignatureView && showSignatureView { + Task { @MainActor in + self.showSignatureView = showSignatureView + } + } + + return signature.strokes.isEmpty || (name.givenName?.isEmpty ?? true) || (name.familyName?.isEmpty ?? true) + } + + + /// Creates a ``ConsentView`` with a provided action view using an``OnboardingActionsView`` and renders a markdown view. + /// - Parameters: + /// - header: The header view will be displayed above the markdown content. + /// - asyncMarkdown: The markdown content provided as an UTF8 encoded `Data` instance that can be provided asynchronously. + /// - footer: The footer view will be displayed above the markdown content. + /// - action: The action that should be performed once the consent has been given. + public init( + @ViewBuilder header: () -> (some View) = { EmptyView() }, + asyncMarkdown: @escaping () async -> Data, + @ViewBuilder footer: () -> (some View) = { EmptyView() }, + action: @escaping () -> Void + ) where ContentView == MarkdownView, Action == OnboardingActionsView { + self.init( + contentView: { + MarkdownView( + asyncMarkdown: asyncMarkdown, + header: { AnyView(header()) }, + footer: { AnyView(footer()) } + ) + }, + actionView: { + OnboardingActionsView(String(localized: "CONSENT_ACTION", bundle: .module)) { + action() + } + } + ) + } + + /// Creates a ``ConsentView`` with a custom-provided action view. + /// - Parameters: + /// - contentView: The content view providing context about the consent view. + /// - actionView: The action view that should be displayed under the name and signature boxes. + public init( + @ViewBuilder contentView: () -> (ContentView), + @ViewBuilder actionView: () -> (Action) + ) { + self.contentView = contentView() + self.action = actionView() + } +} + + +struct ConsentView_Previews: PreviewProvider { + static var previews: some View { + NavigationStack { + ConsentView( + asyncMarkdown: { + Data("This is a *markdown* **example**".utf8) + }, + action: { + print("Next step ...") + } + ) + .navigationTitle("Consent") + } + } +} diff --git a/Sources/Onboarding/OnboardingView.swift b/Sources/Onboarding/OnboardingView.swift index 0cf86429..d313a63b 100644 --- a/Sources/Onboarding/OnboardingView.swift +++ b/Sources/Onboarding/OnboardingView.swift @@ -74,9 +74,9 @@ public struct OnboardingView TitleView, + @ViewBuilder titleView: () -> TitleView = { EmptyView() }, @ViewBuilder contentView: () -> ContentView, - @ViewBuilder actionView: () -> ActionView? = { nil } + @ViewBuilder actionView: () -> ActionView ) { self.titleView = titleView() self.contentView = contentView() diff --git a/Sources/Onboarding/Resources/de.lproj/Localizable.strings b/Sources/Onboarding/Resources/de.lproj/Localizable.strings index 69e2257c..1eaf2aa7 100644 --- a/Sources/Onboarding/Resources/de.lproj/Localizable.strings +++ b/Sources/Onboarding/Resources/de.lproj/Localizable.strings @@ -6,6 +6,13 @@ // SPDX-License-Identifier: MIT // +// MARK: Consent View +"CONSENT_ACTION" = "Ich Stimme Zu"; + // MARK: Sequential Onboarding "SEQUENTIAL_ONBOARDING_NEXT" = "Nächster Schritt"; + + +// MARK: Signature View +"SIGNATURE_VIEW_UNDO" = "Rückgängig Machen"; diff --git a/Sources/Onboarding/Resources/en.lproj/Localizable.strings b/Sources/Onboarding/Resources/en.lproj/Localizable.strings index 0bad905e..d6e89bc5 100644 --- a/Sources/Onboarding/Resources/en.lproj/Localizable.strings +++ b/Sources/Onboarding/Resources/en.lproj/Localizable.strings @@ -6,6 +6,13 @@ // SPDX-License-Identifier: MIT // +// MARK: Consent View +"CONSENT_ACTION" = "I Consent"; + // MARK: Sequential Onboarding "SEQUENTIAL_ONBOARDING_NEXT" = "Next"; + + +// MARK: Signature View +"SIGNATURE_VIEW_UNDO" = "Undo"; diff --git a/Sources/Onboarding/SignatureView.swift b/Sources/Onboarding/SignatureView.swift new file mode 100644 index 00000000..ad97171e --- /dev/null +++ b/Sources/Onboarding/SignatureView.swift @@ -0,0 +1,99 @@ +// +// This source file is part of the CardinalKit open-source project +// +// SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import PencilKit +import SwiftUI +import Views + + +/// A ``SignatureView`` enables the collection of signatures using a view that allows freeform signatures using a finger and the Apple Pencil. +/// +/// Use SwiftUI `Bindings` to obtain information like the content of the signature and if the user is currently signing: +/// ``` +/// @State var signature = PKDrawing() +/// @State var isSigning = false +/// +/// +/// SignatureView( +/// signature: $signature, +/// isSigning: $isSigning, +/// name: name +/// ) +/// ``` +public struct SignatureView: View { + @Environment(\.undoManager) private var undoManager + @Binding private var signature: PKDrawing + @Binding private var isSigning: Bool + @State private var canUndo = false + private let name: PersonNameComponents + private let lineOffset: CGFloat + + + public var body: some View { + VStack { + ZStack(alignment: .bottomLeading) { + Color(.secondarySystemBackground) + Rectangle() + .fill(.secondary) + .frame(maxWidth: .infinity, maxHeight: 1) + .padding(.horizontal, 20) + .padding(.bottom, lineOffset) + Text("X") + .font(.title2) + .foregroundColor(.secondary) + .padding(.horizontal, 20) + .padding(.bottom, lineOffset + 2) + Text(name.formatted(.name(style: .long))) + .font(.subheadline) + .foregroundColor(.secondary) + .padding(.horizontal, 20) + .padding(.bottom, lineOffset - 18) + CanvasView(drawing: $signature, isDrawing: $isSigning, showToolPicker: .constant(false)) + } + .frame(height: 120) + Button(String(localized: "SIGNATURE_VIEW_UNDO", bundle: .module)) { + undoManager?.undo() + canUndo = undoManager?.canUndo ?? false + } + .disabled(!canUndo) + } + .onChange(of: isSigning) { _ in + Task { @MainActor in + canUndo = undoManager?.canUndo ?? false + } + } + .transition(.opacity) + .animation(.easeInOut, value: canUndo) + } + + + /// Creates a new instance of an ``SignatureView``. + /// - Parameters: + /// - signature: A `Binding` containing the current signature as an `PKDrawing`. + /// - isSigning: A `Binding` indicating if the user is currently signing. + /// - name: The name that is deplayed 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(PKDrawing()), + isSigning: Binding = .constant(false), + name: PersonNameComponents = PersonNameComponents(), + lineOffset: CGFloat = 30 + ) { + self._signature = signature + self._isSigning = isSigning + self.name = name + self.lineOffset = lineOffset + } +} + + +struct SignatureView_Previews: PreviewProvider { + static var previews: some View { + SignatureView() + } +} diff --git a/Tests/UITests/TestApp/OnboardingTests/OnboardingTestsView.swift b/Tests/UITests/TestApp/OnboardingTests/OnboardingTestsView.swift index 7fea52ed..a649be69 100644 --- a/Tests/UITests/TestApp/OnboardingTests/OnboardingTestsView.swift +++ b/Tests/UITests/TestApp/OnboardingTests/OnboardingTestsView.swift @@ -13,6 +13,7 @@ import SwiftUI struct OnboardingTestsView: View { enum OnboardingStep: String, CaseIterable, Codable { + case consentView = "Consent View" case onboardingView = "Onboarding View" case sequentialOnboarding = "Sequential Onboarding" } @@ -28,6 +29,8 @@ struct OnboardingTestsView: View { .navigationTitle("Onboarding") .navigationDestination(for: OnboardingStep.self) { onboardingStep in switch onboardingStep { + case .consentView: + consentView case .onboardingView: onboardingView case .sequentialOnboarding: @@ -36,6 +39,22 @@ struct OnboardingTestsView: View { } } + + private var consentView: some View { + ConsentView( + header: { + OnboardingTitleView(title: "Consent", subtitle: "Version 1.0") + }, + asyncMarkdown: { + Data("This is a *markdown* **example**".utf8) + }, + action: { + path.append(OnboardingStep.onboardingView) + } + ) + .navigationBarTitleDisplayMode(.inline) + } + private var onboardingView: some View { OnboardingView( title: "Welcome", @@ -64,7 +83,7 @@ struct OnboardingTestsView: View { ], actionText: "Continue" ) { - path.append(OnboardingStep.onboardingView) + path.append(OnboardingStep.consentView) } .navigationBarTitleDisplayMode(.inline) } diff --git a/Tests/UITests/TestAppUITests/HealthKitTests.swift b/Tests/UITests/TestAppUITests/HealthKitTests.swift index e8dfa002..7e64c769 100644 --- a/Tests/UITests/TestAppUITests/HealthKitTests.swift +++ b/Tests/UITests/TestAppUITests/HealthKitTests.swift @@ -52,7 +52,7 @@ final class HealthKitTests: TestAppUITests { try exitAppAndOpenHealth(.electrocardiograms) - sleep(1) + sleep(2) XCTAssertEqual( HealthDataType.numberOfHKTypeNames(in: app), @@ -61,7 +61,7 @@ final class HealthKitTests: TestAppUITests { try exitAppAndOpenHealth(.steps) - sleep(1) + sleep(2) XCTAssertEqual( HealthDataType.numberOfHKTypeNames(in: app), @@ -70,7 +70,7 @@ final class HealthKitTests: TestAppUITests { try exitAppAndOpenHealth(.pushes) - sleep(1) + sleep(2) XCTAssertEqual( HealthDataType.numberOfHKTypeNames(in: app), @@ -79,7 +79,7 @@ final class HealthKitTests: TestAppUITests { try exitAppAndOpenHealth(.restingHeartRate) - sleep(1) + sleep(2) XCTAssertEqual( HealthDataType.numberOfHKTypeNames(in: app), @@ -88,7 +88,7 @@ final class HealthKitTests: TestAppUITests { try exitAppAndOpenHealth(.activeEnergy) - sleep(1) + sleep(2) XCTAssertEqual( HealthDataType.numberOfHKTypeNames(in: app), @@ -97,7 +97,7 @@ final class HealthKitTests: TestAppUITests { app.buttons["Trigger data source collection"].tap() - sleep(1) + sleep(2) XCTAssertEqual( HealthDataType.numberOfHKTypeNames(in: app), diff --git a/Tests/UITests/TestAppUITests/Helper/XCTTestCase+HealthDataType.swift b/Tests/UITests/TestAppUITests/Helper/XCTTestCase+HealthDataType.swift index 6d8af03f..5408f4c7 100644 --- a/Tests/UITests/TestAppUITests/Helper/XCTTestCase+HealthDataType.swift +++ b/Tests/UITests/TestAppUITests/Helper/XCTTestCase+HealthDataType.swift @@ -99,6 +99,14 @@ enum HealthDataType: String { elementStaticText.tap() return } + + healthApp.firstMatch.swipeDown(velocity: .slow) + elementStaticText = healthApp.buttons.element(matching: elementStaticTextPredicate).firstMatch + if elementStaticText.waitForExistence(timeout: 10) { + elementStaticText.tap() + return + } + XCTFail("Failed to find element in category: \(healthApp.staticTexts.allElementsBoundByIndex)") throw XCTestError(.failureWhileWaiting) } @@ -148,14 +156,7 @@ extension XCTestCase { healthApp.activate() if healthApp.staticTexts["Welcome to Health"].waitForExistence(timeout: 2) { - XCTAssertTrue(healthApp.staticTexts["Continue"].waitForExistence(timeout: 2)) - healthApp.staticTexts["Continue"].tap() - XCTAssertTrue(healthApp.staticTexts["Continue"].waitForExistence(timeout: 2)) - healthApp.staticTexts["Continue"].tap() - XCTAssertTrue(healthApp.tables.buttons["Next"].waitForExistence(timeout: 2)) - healthApp.tables.buttons["Next"].tap() - XCTAssertTrue(healthApp.staticTexts["Continue"].waitForExistence(timeout: 30)) - healthApp.staticTexts["Continue"].tap() + handleWelcomeToHealth() } guard healthApp.tabBars["Tab Bar"].buttons["Browse"].waitForExistence(timeout: 3) else { @@ -190,4 +191,49 @@ extension XCTestCase { let testApp = XCUIApplication() testApp.activate() } + + + private func handleWelcomeToHealth(alreadyRecursive: Bool = false) { + let healthApp = XCUIApplication(bundleIdentifier: "com.apple.Health") + + if healthApp.staticTexts["Welcome to Health"].waitForExistence(timeout: 2) { + XCTAssertTrue(healthApp.staticTexts["Continue"].waitForExistence(timeout: 2)) + healthApp.staticTexts["Continue"].tap() + + XCTAssertTrue(healthApp.staticTexts["Continue"].waitForExistence(timeout: 2)) + healthApp.staticTexts["Continue"].tap() + + XCTAssertTrue(healthApp.tables.buttons["Next"].waitForExistence(timeout: 2)) + healthApp.tables.buttons["Next"].tap() + + // Sometimes the HealthApp fails to advance to the next step here. + // Go back and try again. + if !healthApp.staticTexts["Continue"].waitForExistence(timeout: 30) { + // Go one step back. + healthApp.navigationBars["WDBuddyFlowUserInfoView"].buttons["Back"].tap() + + XCTAssertTrue(healthApp.staticTexts["Continue"].waitForExistence(timeout: 2)) + healthApp.staticTexts["Continue"].tap() + + // Check if the Next button exists or of the view is still in a loading process. + if healthApp.tables.buttons["Next"].waitForExistence(timeout: 2) { + healthApp.tables.buttons["Next"].tap() + } + + // Continue button still doesn't exist, go for terminating the app. + if !healthApp.staticTexts["Continue"].waitForExistence(timeout: 30) { + if alreadyRecursive { + XCTFail("Even the recursive process did fail. Terminate the process.") + } + + healthApp.terminate() + healthApp.activate() + handleWelcomeToHealth(alreadyRecursive: true) + return + } + } + + healthApp.staticTexts["Continue"].tap() + } + } } diff --git a/Tests/UITests/TestAppUITests/OnboardingTests.swift b/Tests/UITests/TestAppUITests/OnboardingTests.swift index dfe45476..b6f9ae77 100644 --- a/Tests/UITests/TestAppUITests/OnboardingTests.swift +++ b/Tests/UITests/TestAppUITests/OnboardingTests.swift @@ -10,6 +10,53 @@ import XCTest final class OnboardingTests: TestAppUITests { + func testOnboardingConsent() throws { + let app = XCUIApplication() + app.launch() + + app.collectionViews.buttons["OnboardingTests"].tap() + app.collectionViews.buttons["Consent View"].tap() + + XCTAssert(app.staticTexts["Consent"].exists) + XCTAssert(app.staticTexts["Version 1.0"].exists) + XCTAssert(app.staticTexts["This is a markdown example"].exists) + + XCTAssertFalse(app.staticTexts["Leland Stanford"].exists) + XCTAssertFalse(app.staticTexts["X"].exists) + + hitConsentButton(app) + + #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 + + app.enter(value: "Leland", in: "Enter your given name ...") + app.enter(value: "Stanford", in: "Enter your family name ...") + + hitConsentButton(app) + + app.staticTexts["Leland Stanford"].swipeRight() + app.buttons["Undo"].tap() + + hitConsentButton(app) + + app.staticTexts["X"].swipeRight() + + hitConsentButton(app) + + XCTAssert(app.staticTexts["Welcome"].exists) + XCTAssert(app.staticTexts["CardinalKit UI Tests"].exists) + } + + private func hitConsentButton(_ app: XCUIApplication) { + if app.staticTexts["This is a markdown example"].isHittable { + app.staticTexts["This is a markdown example"].swipeUp() + } else { + print("Can not scroll down.") + } + app.buttons["I Consent"].tap() + } + func testOnboardingView() throws { let app = XCUIApplication() app.launch() @@ -71,7 +118,7 @@ final class OnboardingTests: TestAppUITests { XCTAssert(app.staticTexts["Third thing to know"].exists) app.buttons["Continue"].tap() - XCTAssert(app.staticTexts["Welcome"].exists) - XCTAssert(app.staticTexts["CardinalKit UI Tests"].exists) + XCTAssert(app.staticTexts["Consent"].exists) + XCTAssert(app.staticTexts["Version 1.0"].exists) } } diff --git a/Tests/UITests/UITests.xcodeproj/TestApp.xctestplan b/Tests/UITests/UITests.xcodeproj/TestApp.xctestplan index bb50c691..6895d37c 100644 --- a/Tests/UITests/UITests.xcodeproj/TestApp.xctestplan +++ b/Tests/UITests/UITests.xcodeproj/TestApp.xctestplan @@ -71,6 +71,9 @@ }, "testTargets" : [ { + "skippedTests" : [ + "HealthKitTests" + ], "target" : { "containerPath" : "container:UITests.xcodeproj", "identifier" : "2F6D13AB28F5F386007C25D6",