carekit-apple / CareKit

CareKit is an open source software framework for creating apps that help people better understand and manage their health.
https://www.researchandcare.org
Other
2.4k stars 445 forks source link

How do you save an CareKit OCKOutcomeValue for an array of ResearchKit ORKChoiceQuestionResults #680

Closed InfuriatingYetti closed 1 year ago

InfuriatingYetti commented 1 year ago

Good day!

I have been following the WWDC 21 hosted by @erik-apple and it's been really great! I had a question and wasn't sure where else would be a good place as I believe it is a CareKit issue (not 100% and still fairly new here). I see that Erik helps out on here and was hoping he, or someone, could help with something pertaining to CareKit in Part 2 of the WWDC.

I have successfully created two ResearchKit ORKTextChoice questions (.singleChoice ) and I'm trying to pass them to CareKit like Erik does with the ORKScaleQuestionResult (.scaleAnswer). Example from my code:

       ORKValuePickerAnswerFormat.choiceAnswerFormat(
        with: .singleChoice,    
        textChoices: Choices

I was able to modify Erik's code from WWDC to extract the answers:

       static func extractAnswersFromChoiceQuestions(
       _ result: ORKTaskResult) -> [OCKOutcomeValue]? {

        guard
            let choiceResults = result.results?
                .compactMap({ $0 as? ORKStepResult })
                .first(where: { $0.identifier == ChoiceFormIdentifier }),

                let scale2Results = choiceResults
                .results?.compactMap({ $0 as? ORKChoiceQuestionResult }),

                let choiceAnswer = scale2Results
                .first(where: { $0.identifier == ChoiceQuestion1Step })?
                .choiceAnswers,

                let choiceAnswer2 = scale2Results
                .first(where: { $0.identifier == ChoiceQuestion2Step })?
                .choiceAnswers

Note: After running the app and submitting the ORKTextChoice answer responses in the simulator, when I go back over to Xcode I can see the correct answer choice when I hover over both "choiceAnswer" and "choiceAnswer2". I take as meaning the code works for extracting the answer choices. This can be seen in the output terminal as well:

Printing description of choiceAnswer: ([NSCoding & NSCopying & NSObject]) choiceAnswer = 1 value { [0] = 0x99d102272eb2af4b Int64(3) } Printing description of ((__NSCFNumber *)0x99d102272eb2af4b): 3

...where "3" at the very end is the value from the ORKTextChoice "choiceAnswer".

In Erik's tutorial the next step is to pass the ResearchKit ORKScaleQuestionResult to a CareKit OCKOutcomeValue using the following code:

       var painValue = OCKOutcomeValue(Double(truncating: painAnswer))
        painValue.kind = checkInPainItemIdentifier

        var sleepValue = OCKOutcomeValue(Double(truncating: sleepAnswer))
        sleepValue.kind = checkInSleepItemIdentifier

I have tried for days to pass the ResearchKit ORKTextChoice to CareKit and Xcode corrected the code to what you see below but still crashes with Thread 1: signal SIGABRT on the following line of code:

var Value2 = OCKOutcomeValue(NSNumber(pointer: choiceAnswer) as! OCKOutcomeValueUnderlyingType) Value.kind = Question1Step

I have a feeling that the error has something to do with this part of the above code: OCKOutcomeValue(NSNumber(pointer: and/or as! OCKOutcomeValueUnderlyingType) as I get the terminal error: Could not cast value of type 'NSConcreteValue' (0x15e0dcf38) to 'CareKitStore.OCKOutcomeValueUnderlyingType' (0x15e0d9ad0)

As mentioned I have been searching for days and haven't found anything helpful. I am hoping that someone may have some guidance and I would be very grateful! :)

Thank you!

PS - I was trying to be concise, so if more explanation or code is needed, please let me know!

gavirawson-apple commented 1 year ago

In ResearchKit, each step in a survey will generate a particular result type. The result type in the WWDC sample app is different than the one you're dealing with here, so there will be a few extra steps needed to convert that to an OCKOutcomeValue.

The first step is to extract the selected text choices from the result. You're just about there in the snippet above.

 let selectedChoices = results
    .first { $0.identifier == ChoiceQuestion1Step }?
    .choiceAnswers as! [ORKTextChoice]

The next step is to convert each selected choice to an outcome value.

let outcomesValues = selectedChoices
    .map { OCKOutcomeValue($0.value as! String) }

Let me know if that works for you!

InfuriatingYetti commented 1 year ago

HI Gavi!

Thank you for getting back to me (it really means a lot!!), you're from the WWDC 2020 What's New in CareKit - so awesome, it's a pleasure and thank you for the insight! I'll give this a try and see what happens.

Out of curiosity, does the suggestion you provided use the string value (seen below: "None", "Slight", "Moderate", "Severe") or number value associated (ie. value: 0,1,2,3) with the ORKTextChoice questions (.singleChoice )? I'm sorry if I wasn't direct or as I didn't specifically say that, what I was looking to do is use the value of the response for insights, so in the example above "3" would be what I want to track.

For example, when looking at the answer choices:

        Choices = [
        ORKTextChoice(text: "None", value: 0 as NSNumber),
        ORKTextChoice(text: "Slight", value: 1 as NSNumber),
        ORKTextChoice(text: "Moderate", value: 2 as NSNumber),
        ORKTextChoice(text: "Severe", value: 3 as NSNumber),`

....if the person taking the assessment selects "Severe", value: 3, I want the "3" to be what is extracted from the results to be persisted to the CareKit Store for creating insights. Is that what your suggestion will do? Is it possible to do this??

Thank you!

gavirawson-apple commented 1 year ago

Ah I see! Yep that's possible as well. If the value is an NSNumber make sure to convert it to a double before storing it as an outcome value, as NSNumber doesn't conform to OCKOutcomeValueUnderlyingType. This is the current list of types that can be stored as outcome values:

/// Any value that can be persisted to `OCKStore` must conform to this protocol.
public protocol OCKOutcomeValueUnderlyingType: Codable {}

extension Int: OCKOutcomeValueUnderlyingType {}
extension Double: OCKOutcomeValueUnderlyingType {}
extension Bool: OCKOutcomeValueUnderlyingType {}
extension String: OCKOutcomeValueUnderlyingType {}
extension Data: OCKOutcomeValueUnderlyingType {}
extension Date: OCKOutcomeValueUnderlyingType {}

To convert an NSNumber to a double, you can write Double(truncating: nsNumberValue)

InfuriatingYetti commented 1 year ago

I hope I'm doing this right, I tried:

var choiceValue = OCKOutcomeValue(Double(truncating: choiceAnswer))

This gave me the line error of: Cannot convert value of type '[any NSCoding & NSCopying & NSObjectProtocol]' to expected argument type 'NSNumber'

I feel that I might not be doing something right...

gavirawson-apple commented 1 year ago

You're very close! It's a tricky process. The value should be cast to the same type that was used when initially creating the ORKTask with text choices. So if you created the text choices like so using NSNumbers:

ORKTextChoice(text: "None", value: 0 as NSNumber)

Then you'll need to convert the values to NSNumbers when they appear in the ORKTaskResult:

 let selectedChoices = results
    .first { $0.identifier == ChoiceQuestion1Step }?
    .choiceAnswers as! [ORKTextChoice]

let outcomesValues = selectedChoices
    // Convert the choice values to `NSNumber`s
    .map { $0.value as! NSNumber }
    // Convert the choice values to CareKit outcome values
    .map { OCKOutcomeValue(Double(truncating: $0)) }
InfuriatingYetti commented 1 year ago

This is great, and you're right it is tricky! I feel like I'm so close I can smell it ! :) 44d3c46b-c507-4c10-b49c-d836c081658f_text

This part isn’t throwing an error:

                let choiceAnswer = scale2Results
                .first(where: { $0.identifier == choiceQuestion1Step })?
                .choiceAnswers as? [ORKTextChoice],

I'm getting some errors at this part, I tried integrating what you suggested (I think correctly):

    let outcomesValues = selectedChoices
    // Convert the choice values to `NSNumber`s
    .map { $0.value as! NSNumber }
    // Convert the choice values to CareKit outcome values

as:

static func extractAnswersFromChoice(
    _ result: ORKTaskResult) -> [OCKOutcomeValue]? {

        guard

            let choiceResults = result.results?
                // Convert the choice values to `NSNumber`s
                   .map { $0.value as! NSNumber }
                   // Convert the choice values to CareKit outcome values
                   .map { OCKOutcomeValue(Double(truncating: $0)) },

Errors:

In all it looks like:

   //
   //Surveys.swift
   //Recover

    import CareKitStore
    import ResearchKit

    struct Choice {

        static let choiceIdentifier = “choice”
        static let choiceFormIdentifier = “choice.form"
        static let ChoiceQuestion1Step = "checkin.form.choicequestion1"

    static func Choice() -> ORKTask {

        let Choices = [
            ORKTextChoice(text: "None", value: 0 as NSNumber),
            ORKTextChoice(text: "Slight", value: 1 as NSNumber),
            ORKTextChoice(text: "Moderate", value: 2 as NSNumber),
            ORKTextChoice(text: "Severe", value: 3 as NSNumber),

 let choiceAnswerFormat =  ORKValuePickerAnswerFormat.choiceAnswerFormat(
            with: .singleChoice,    
            textChoices: Choices
        )

    let choiceStep1 = ORKFormItem(
      identifier: ChoiceQuestion1Step,
      text: "Question Here",
      answerFormat: choiceAnswerFormat
        )
    choiceStep1.isOptional = false

    static func extractAnswersFromChoice(
        _ result: ORKTaskResult) -> [OCKOutcomeValue]? {

            guard
                //the part you suggested here:
                let choiceResults = result.results?
                    // Convert the choice values to `NSNumber`s
                       .map { $0.value as! NSNumber }
                       // Convert the choice values to CareKit outcome values
                       .map { OCKOutcomeValue(Double(truncating: $0)) },

    //part you suggested here
    let choiceAnswer = scale2Results
                    .first(where: { $0.identifier == ChoiceQuestion1Step })?
                    .choiceAnswers as? [ORKTextChoice]

     else {
                assertionFailure("Failed to extract answers from check in survey!")
                return nil
            }

            var choiceValue = OCKOutcomeValue(NSNumber(pointer: choiceAnswer) as! OCKOutcomeValueUnderlyingType)
            choiceValue.kind = ChoiceQuestion1Step

    return [choiceValue]

I also tried:

  static func extractAnswersFromChoiceSurvey(
        _ result: ORKTaskResult) -> [OCKOutcomeValue]? {

            guard
                let choiceResults = result.results?
                    .compactMap({ $0 as? ORKStepResult })
                    .first(where: { $0.identifier == choiceFormIdentifier }),

                    let scale2Results = choiceResults
                    .results?.compactMap({ $0 as? ORKChoiceQuestionResult }),

                    let choiceAnswer = scale2Results
                    .first(where: { $0.identifier == ChoiceQuestion1Step })?
                    .choiceAnswers as? [ORKTextChoice]

            else {
                assertionFailure("Failed to extract answers from check in survey!")
                return nil
            }

            let outcomesValues = choiceAnswer
                .map { OCKOutcomeValue($0.value as! String) }

            var choiceValue = OCKOutcomeValue(NSNumber(pointer:  outcomesValues) as! OCKOutcomeValueUnderlyingType)
            choiceValue.kind = ChoiceQuestion1Step

This worked, as in it complied and ran the app; however, in the end it crashed and gave the error Thread 1: Fatal error: Failed to extract answers from check in survey!

Am I still on the right track...I apologize if it is something obvious!

Thank you!

InfuriatingYetti commented 1 year ago

Hi @gavirawson-apple!

I wanted to add to the above post. I have Aspergers and sometimes struggle explaining things concisely and in ways others understand. I'm not sure if the last post made sense, and I think I have the code added in all of the right places now and am getting some errors with the solutions provided (not sure about the last post, I think the code from the last post may not have been accurate).

In the photos below I start from where I have just added the suggestion, I show the error, correct it, and lastly (on no. 4) show the error from trying it out:

1.

Screenshot 2023-02-11 at 2 47 00 PM

2.

Screenshot 2023-02-11 at 2 47 10 PM
  1. Screenshot 2023-02-11 at 2 47 22 PM

This is after I correct errors seen in #1-3 and run the app in the simulator, select ORKTextChoice(text: "Mild", value: 2 as NSNumber), and click done: 4.

Screenshot 2023-02-11 at 2 53 47 PM Screenshot 2023-02-11 at 2 47 36 PM

Is it possible that it has something to do with the CareFeedView Controller as well?? I'm not getting an error there, and this is what it looks like:

Screenshot 2023-02-11 at 3 05 41 PM

Thank you!!

PS - I have added the full code below of the Surveys.swift file to compliment the photos, in case it helps for big picture context:

//
//Surveys.swift
//

import CareKitStore
import ResearchKit

struct Surveys {

private init() {}

// MARK: Onboarding

static func onboardingSurvey() -> ORKTask {

    // The Welcome Instruction step.
    let welcomeInstructionStep = ORKInstructionStep(
        identifier: "onboarding.welcome"
    )

    welcomeInstructionStep.title = "Welcome!"
    welcomeInstructionStep.detailText = "Thank you for joining our study. Tap Next to learn more before signing up."
    welcomeInstructionStep.image = UIImage(named: "welcome-image")
    welcomeInstructionStep.imageContentMode = .scaleAspectFill

    // The Informed Consent Instruction step.
    let studyOverviewInstructionStep = ORKInstructionStep(
        identifier: "onboarding.overview"
    )

    studyOverviewInstructionStep.title = "Before You Join"
    studyOverviewInstructionStep.iconImage = UIImage(systemName: "checkmark.seal.fill")

    let heartBodyItem = ORKBodyItem(
        text: "The study will ask you to share some of your health data.",
        detailText: nil,
        image: UIImage(systemName: "heart.fill"),
        learnMoreItem: nil,
        bodyItemStyle: .image
    )

    let completeTasksBodyItem = ORKBodyItem(
        text: "You will be asked to complete various tasks over the duration of the study.",
        detailText: nil,
        image: UIImage(systemName: "checkmark.circle.fill"),
        learnMoreItem: nil,
        bodyItemStyle: .image
    )

    let signatureBodyItem = ORKBodyItem(
        text: "Before joining, we will ask you to sign an informed consent document.",
        detailText: nil,
        image: UIImage(systemName: "signature"),
        learnMoreItem: nil,
        bodyItemStyle: .image
    )

    let secureDataBodyItem = ORKBodyItem(
        text: "Your data is kept private and secure.",
        detailText: nil,
        image: UIImage(systemName: "lock.fill"),
        learnMoreItem: nil,
        bodyItemStyle: .image
    )

    studyOverviewInstructionStep.bodyItems = [
        heartBodyItem,
        completeTasksBodyItem,
        signatureBodyItem,
        secureDataBodyItem
    ]

    // The Signature step (using WebView).
    let webViewStep = ORKWebViewStep(
        identifier: "onboarding.signatureCapture",
        html: informedConsentHTML
    )

    webViewStep.showSignatureAfterContent = true

    // The Request Permissions step.
    let healthKitTypesToWrite: Set<HKSampleType> = [
        HKObjectType.quantityType(forIdentifier: .bodyMassIndex)!,
        HKObjectType.quantityType(forIdentifier: .activeEnergyBurned)!,
        HKObjectType.workoutType()
    ]

    let healthKitTypesToRead: Set<HKObjectType> = [
        HKObjectType.characteristicType(forIdentifier: .dateOfBirth)!,
        HKObjectType.workoutType(),
        HKObjectType.quantityType(forIdentifier: .appleStandTime)!,
        HKObjectType.quantityType(forIdentifier: .appleExerciseTime)!
    ]

    let healthKitPermissionType = ORKHealthKitPermissionType(
        sampleTypesToWrite: healthKitTypesToWrite,
        objectTypesToRead: healthKitTypesToRead
    )

    let notificationsPermissionType = ORKNotificationPermissionType(
        authorizationOptions: [.alert, .badge, .sound]
    )

    let motionPermissionType = ORKMotionActivityPermissionType()

    let requestPermissionsStep = ORKRequestPermissionsStep(
        identifier: "onboarding.requestPermissionsStep",
        permissionTypes: [
            healthKitPermissionType,
            notificationsPermissionType,
            motionPermissionType
        ]
    )

    requestPermissionsStep.title = "Health Data Request"
    requestPermissionsStep.text = "Please review the health data types below and enable sharing to contribute to the study."

    // Completion Step
    let completionStep = ORKCompletionStep(
        identifier: "onboarding.completionStep"
    )

    completionStep.title = "Enrollment Complete"
    completionStep.text = "Thank you for enrolling in this study. Your participation will contribute to meaningful research!"

    let surveyTask = ORKOrderedTask(
        identifier: "onboard",
        steps: [
            welcomeInstructionStep,
            studyOverviewInstructionStep,
            webViewStep,
            requestPermissionsStep,
            completionStep
        ]
    )

    return surveyTask
}

// MARK: Check-in Survey

static let checkInIdentifier = "checkin"
static let checkInFormIdentifier = "checkin.form"
static let checkInPainItemIdentifier = "checkin.form.pain"
static let checkInSleepItemIdentifier = "checkin.form.sleep"
static let checkInSleepItemIdentifier2 = "checkin.form.sleep2"
static let TextChoiceQuestionStep = "checkin.form.sleep3"

static func checkInSurvey() -> ORKTask {

    let painAnswerFormat = ORKAnswerFormat.scale(
        withMaximumValue: 10,
        minimumValue: 1,
        defaultValue: 0,
        step: 1,
        vertical: false,
        maximumValueDescription: "Very painful",
        minimumValueDescription: "No pain"
    )

    let sleepAnswerFormat = ORKAnswerFormat.scale(
        withMaximumValue: 12,
        minimumValue: 0,
        defaultValue: 0,
        step: 1,
        vertical: false,
        maximumValueDescription: nil,
        minimumValueDescription: nil
    )

    let sleepAnswerFormat2 = ORKAnswerFormat.scale(
        withMaximumValue: 12,
        minimumValue: 0,
        defaultValue: 0,
        step: 1,
        vertical: false,
        maximumValueDescription: nil,
        minimumValueDescription: nil
    )

    let painItem = ORKFormItem(
        identifier: checkInPainItemIdentifier,
        text: "How would you rate your pain?",
        answerFormat: painAnswerFormat
    )
    painItem.isOptional = false

    let sleepItem = ORKFormItem(
        identifier: checkInSleepItemIdentifier,
        text: "How many hours of sleep did you get last night?",
        answerFormat: sleepAnswerFormat
    )
    sleepItem.isOptional = false

    let sleepItem2 = ORKFormItem(
        identifier: checkInSleepItemIdentifier2,
        text: "How many hours of sleep did you get last night2?",
        answerFormat: sleepAnswerFormat2
    )
    sleepItem2.isOptional = false

    let formStep = ORKFormStep(
        identifier: checkInFormIdentifier,
        title: "Check In",
        text: "Please answer the following questions."
    )
    formStep.formItems = [painItem, sleepItem, sleepItem2]
    formStep.isOptional = false

    let surveyTask = ORKOrderedTask(
        identifier: checkInIdentifier,
        steps: [formStep]
    )

    return surveyTask
}

static func extractAnswersFromCheckInSurvey(
    _ result: ORKTaskResult) -> [OCKOutcomeValue]? {

        guard
            let response = result.results?
                .compactMap({ $0 as? ORKStepResult })
                .first(where: { $0.identifier == checkInFormIdentifier }),

                let scaleResults = response
                .results?.compactMap({ $0 as? ORKScaleQuestionResult }),

                let painAnswer = scaleResults
                .first(where: { $0.identifier == checkInPainItemIdentifier })?
                .scaleAnswer,

                let sleepAnswer = scaleResults
                .first(where: { $0.identifier == checkInSleepItemIdentifier })?
                .scaleAnswer

        else {
            assertionFailure("Failed to extract answers from check in survey!")
            return nil
        }

        var painValue = OCKOutcomeValue(Double(truncating: painAnswer))
        painValue.kind = checkInPainItemIdentifier

        var sleepValue = OCKOutcomeValue(Double(truncating: sleepAnswer))
        sleepValue.kind = checkInSleepItemIdentifier

        return [painValue, sleepValue]
    }

// MARK: Choice Survey

static let choiceIdentifier = "choice"
static let choiceFormIdentifier = "choice.form"
static let ChoiceQuestion1Step = "checkin.form.choicequestion1"
static let ChoiceQuestionStep = "checkin.form.Choice"

static func Choices() -> ORKTask {

    let Choices = [
        ORKTextChoice(text: "None", value: 0 as NSNumber),
        ORKTextChoice(text: "Slight", value: 1 as NSNumber),
        ORKTextChoice(text: "Mild", value: 2 as NSNumber),
        ORKTextChoice(text: "Severe", value: 3 as NSNumber),

    ]
    let choiceAnswerFormat =  ORKAnswerFormat.choiceAnswerFormat(
        with: .singleChoice,
        textChoices: Choices
    )

    let choiceStep = ORKFormItem(
        identifier: ChoiceQuestion1Step,
        text: "Text question goes here",
        answerFormat: choiceAnswerFormat
    )
    choiceStep.isOptional = false

    let formStep = ORKFormStep(
        identifier: choiceFormIdentifier,
        title: "Choice Question",
        text: "Instructions for questions"
    )
    formStep.formItems = [choiceStep]
    formStep.isOptional = false

    let choiceTask = ORKOrderedTask(
        identifier: choiceIdentifier,
        steps: [formStep]
    )

    return choiceTask
} 

static func extractAnswersFromChoiceSurvey(
    _ result: ORKTaskResult) -> [OCKOutcomeValue]? {

        guard
            let choiceResults = result.results?
                .compactMap({ $0 as? ORKStepResult })
                .first(where: { $0.identifier == choiceFormIdentifier }),

                let scale2Results = choiceResults
                .results?.compactMap({ $0 as? ORKChoiceQuestionResult }),
//old
           /*     let choiceAnswer = scale2Results
                .first(where: { $0.identifier == ChoiceQuestion1Step })?
                .choiceAnswers
            */
                let choiceAnswer = scale2Results
                .first(where: { $0.identifier == ChoiceQuestion1Step })?
                .choiceAnswers as? [ORKTextChoice]

        else {
            assertionFailure("Failed to extract answers from check in survey!")
            return nil
        }

    //old
     /*         var choiceValue = OCKOutcomeValue(NSNumber(pointer:  choiceAnswer) as! OCKOutcomeValueUnderlyingType)
              choiceValue.kind = ChoiceQuestion1Step
       */
              let choiceValue = choiceAnswer
                  // Convert the choice values to `NSNumber`s
                  .map { $0.value as! NSNumber }
                  // Convert the choice values to CareKit outcome values
                  .map { OCKOutcomeValue(Double(truncating: $0)) }

              var choiceValue2 = OCKOutcomeValue(NSNumber(pointer:  choiceValue) as! OCKOutcomeValueUnderlyingType)
                       choiceValue2.kind = ChoiceQuestion1Step

              return [choiceValue2]
          }

// MARK: Range of Motion.

static func rangeOfMotionCheck() -> ORKTask {

    let rangeOfMotionOrderedTask = ORKOrderedTask.kneeRangeOfMotionTask(
        withIdentifier: "rangeOfMotionTask",
        limbOption: .left,
        intendedUseDescription: nil,
        options: [.excludeConclusion]
    )

    let completionStep = ORKCompletionStep(identifier: "rom.completion")
    completionStep.title = "All done!"
    completionStep.detailText = "We know the road to recovery can be tough. Keep up the good work!"

    rangeOfMotionOrderedTask.appendSteps([completionStep])

    return rangeOfMotionOrderedTask
}

static func extractRangeOfMotionOutcome(
    _ result: ORKTaskResult) -> [OCKOutcomeValue]? {

        guard let motionResult = result.results?
            .compactMap({ $0 as? ORKStepResult })
            .compactMap({ $0.results })
            .flatMap({ $0 })
            .compactMap({ $0 as? ORKRangeOfMotionResult })
            .first else {

            assertionFailure("Failed to parse range of motion result")
            return nil
        }

        var range = OCKOutcomeValue(motionResult.range)
        range.kind = #keyPath(ORKRangeOfMotionResult.range)

        return [range]
    }

// MARK: 3D Knee Model

static func kneeModel() -> ORKTask {

    let instructionStep = ORKInstructionStep(
        identifier: "insights.instructionStep"
    )
    instructionStep.title = "Your Injury Visualized"
    instructionStep.detailText = "A 3D model will be presented to give you better insights on your specific injury."
    instructionStep.iconImage = UIImage(systemName: "bandage")

    let modelManager = ORKUSDZModelManager(usdzFileName: "toy_robot_vintage")

    let kneeModelStep = ORK3DModelStep(
        identifier: "insights.kneeModel",
        modelManager: modelManager
    )

    let kneeModelTask = ORKOrderedTask(
        identifier: "insights",
        steps: [instructionStep, kneeModelStep]
    )

    return kneeModelTask

    }
}
gavirawson-apple commented 1 year ago

No worries at all! All of this info is super helpful and helps me run the issue locally to help us solve it. It looks like I gave you the wrong info earlier, apologies!

Turns out the result object contains a list of NSNumbers rather than a list of ORKTextChoices. Try this out and let me know if it works for you:


static func extractAnswersFromChoiceSurvey(
    _ result: ORKTaskResult) -> [OCKOutcomeValue]?
{

    guard
        let choiceResult = result
            .results?
            .compactMap({ $0 as? ORKStepResult })
            .first(where: { $0.identifier == choiceFormIdentifier }),

        let scale2Results = choiceResult
            .results?
            .compactMap({ $0 as? ORKChoiceQuestionResult }),

        let selectedChoices = scale2Results
            .first(where: { $0.identifier == ChoiceQuestion1Step })?
            // This line was the key!
            .choiceAnswers as? [NSNumber]
    else {
        assertionFailure("Failed to extract answers from check in survey!")
        return nil
    }

    // Convert the choice values to CareKit outcome values
    let outcomeValues = selectedChoices
        .map { OCKOutcomeValue(Double(truncating: $0)) }

    return outcomeValues
}
InfuriatingYetti commented 1 year ago

Hi Gavi!

I appreciate the encouragement - I'm almost there! The code is working well too :) I tried Print to see the selection value in the debugger terminal ("3.0" on the bottom right) and it's showing - Thank you!!

Screenshot 2023-02-14 at 1 52 38 AM

The last piece is using kind in order to add the value show the selection on the CareCard (like in the WWDC):

// Convert the choice values to CareKit outcome values
          let outcomeValues = selectedChoices
              .map { OCKOutcomeValue(Double(truncating: $0)) }
               outcomeValues.kind = ChoiceQuestion1Step  <---this line ###

          //      var choiceValue = OCKOutcomeValue(Double(truncating: outcomeValues))
            //    choiceValue.kind = ChoiceQuestionStep` 
Screenshot 2023-02-14 at 1 50 41 AM

It keeps throwing an error regardless of how I try doing in both the Survey.swift and CareFeedViewController.swft. One such error (which I think is the main culprit, I can't figure out how to use kind in this context with ORKTextChoice). For example, I get errors like this: Value of type '[OCKOutcomeValue]' has no member 'kind'.

Screenshot 2023-02-14 at 1 48 44 AM

Is there a way to to use the kind with your extractAnswersFromChoiceSurvey code from above? I tried adding kind in different ways/places (indicated with the //, like // outcomeValues.kind = ChoiceQuestion1Step on line 352 above) where I thought it may work based on the WW.

Thank you!!!

PS - Below is Surveys.swift & CareFeedViewController.swift

For reference, this is the Surveys.swift file now:

import CareKitStore
import ResearchKit

struct Surveys {

private init() {}

// MARK: Onboarding

static func onboardingSurvey() -> ORKTask {

    // The Welcome Instruction step.
    let welcomeInstructionStep = ORKInstructionStep(
        identifier: "onboarding.welcome"
    )

    welcomeInstructionStep.title = "Welcome!"
    welcomeInstructionStep.detailText = "Thank you for joining our study. Tap Next to learn more before signing up."
    welcomeInstructionStep.image = UIImage(named: "welcome-image")
    welcomeInstructionStep.imageContentMode = .scaleAspectFill

    // The Informed Consent Instruction step.
    let studyOverviewInstructionStep = ORKInstructionStep(
        identifier: "onboarding.overview"
    )

    studyOverviewInstructionStep.title = "Before You Join"
    studyOverviewInstructionStep.iconImage = UIImage(systemName: "checkmark.seal.fill")

    let heartBodyItem = ORKBodyItem(
        text: "The study will ask you to share some of your health data.",
        detailText: nil,
        image: UIImage(systemName: "heart.fill"),
        learnMoreItem: nil,
        bodyItemStyle: .image
    )

    let completeTasksBodyItem = ORKBodyItem(
        text: "You will be asked to complete various tasks over the duration of the study.",
        detailText: nil,
        image: UIImage(systemName: "checkmark.circle.fill"),
        learnMoreItem: nil,
        bodyItemStyle: .image
    )

    let signatureBodyItem = ORKBodyItem(
        text: "Before joining, we will ask you to sign an informed consent document.",
        detailText: nil,
        image: UIImage(systemName: "signature"),
        learnMoreItem: nil,
        bodyItemStyle: .image
    )

    let secureDataBodyItem = ORKBodyItem(
        text: "Your data is kept private and secure.",
        detailText: nil,
        image: UIImage(systemName: "lock.fill"),
        learnMoreItem: nil,
        bodyItemStyle: .image
    )

    studyOverviewInstructionStep.bodyItems = [
        heartBodyItem,
        completeTasksBodyItem,
        signatureBodyItem,
        secureDataBodyItem
    ]

    // The Signature step (using WebView).
    let webViewStep = ORKWebViewStep(
        identifier: "onboarding.signatureCapture",
        html: informedConsentHTML
    )

    webViewStep.showSignatureAfterContent = true

    // The Request Permissions step.
    let healthKitTypesToWrite: Set<HKSampleType> = [
        HKObjectType.quantityType(forIdentifier: .bodyMassIndex)!,
        HKObjectType.quantityType(forIdentifier: .activeEnergyBurned)!,
        HKObjectType.workoutType()
    ]

    let healthKitTypesToRead: Set<HKObjectType> = [
        HKObjectType.characteristicType(forIdentifier: .dateOfBirth)!,
        HKObjectType.workoutType(),
        HKObjectType.quantityType(forIdentifier: .appleStandTime)!,
        HKObjectType.quantityType(forIdentifier: .appleExerciseTime)!
    ]

    let healthKitPermissionType = ORKHealthKitPermissionType(
        sampleTypesToWrite: healthKitTypesToWrite,
        objectTypesToRead: healthKitTypesToRead
    )

    let notificationsPermissionType = ORKNotificationPermissionType(
        authorizationOptions: [.alert, .badge, .sound]
    )

    let motionPermissionType = ORKMotionActivityPermissionType()

    let requestPermissionsStep = ORKRequestPermissionsStep(
        identifier: "onboarding.requestPermissionsStep",
        permissionTypes: [
            healthKitPermissionType,
            notificationsPermissionType,
            motionPermissionType
        ]
    )

    requestPermissionsStep.title = "Health Data Request"
    requestPermissionsStep.text = "Please review the health data types below and enable sharing to contribute to the study."

    // Completion Step
    let completionStep = ORKCompletionStep(
        identifier: "onboarding.completionStep"
    )

    completionStep.title = "Enrollment Complete"
    completionStep.text = "Thank you for enrolling in this study. Your participation will contribute to meaningful research!"

    let surveyTask = ORKOrderedTask(
        identifier: "onboard",
        steps: [
            welcomeInstructionStep,
            studyOverviewInstructionStep,
            webViewStep,
            requestPermissionsStep,
            completionStep
        ]
    )

    return surveyTask
}

// MARK: Check-in Survey

static let checkInIdentifier = "checkin"
static let checkInFormIdentifier = "checkin.form"
static let checkInPainItemIdentifier = "checkin.form.pain"
static let checkInSleepItemIdentifier = "checkin.form.sleep"
static let checkInSleepItemIdentifier2 = "checkin.form.sleep2"
static let TextChoiceQuestionStep = "checkin.form.sleep3"

static func checkInSurvey() -> ORKTask {

    let painAnswerFormat = ORKAnswerFormat.scale(
        withMaximumValue: 10,
        minimumValue: 1,
        defaultValue: 0,
        step: 1,
        vertical: false,
        maximumValueDescription: "Very painful",
        minimumValueDescription: "No pain"
    )

    let sleepAnswerFormat = ORKAnswerFormat.scale(
        withMaximumValue: 12,
        minimumValue: 0,
        defaultValue: 0,
        step: 1,
        vertical: false,
        maximumValueDescription: nil,
        minimumValueDescription: nil
    )

    let sleepAnswerFormat2 = ORKAnswerFormat.scale(
        withMaximumValue: 12,
        minimumValue: 0,
        defaultValue: 0,
        step: 1,
        vertical: false,
        maximumValueDescription: nil,
        minimumValueDescription: nil
    )

    let painItem = ORKFormItem(
        identifier: checkInPainItemIdentifier,
        text: "How would you rate your pain?",
        answerFormat: painAnswerFormat
    )
    painItem.isOptional = false

    let sleepItem = ORKFormItem(
        identifier: checkInSleepItemIdentifier,
        text: "How many hours of sleep did you get last night?",
        answerFormat: sleepAnswerFormat
    )
    sleepItem.isOptional = false

    let sleepItem2 = ORKFormItem(
        identifier: checkInSleepItemIdentifier2,
        text: "How many hours of sleep did you get last night2?",
        answerFormat: sleepAnswerFormat2
    )
    sleepItem2.isOptional = false

    let formStep = ORKFormStep(
        identifier: checkInFormIdentifier,
        title: "Check In",
        text: "Please answer the following questions."
    )
    formStep.formItems = [painItem, sleepItem, sleepItem2]
    formStep.isOptional = false

    let surveyTask = ORKOrderedTask(
        identifier: checkInIdentifier,
        steps: [formStep]
    )

    return surveyTask
}

static func extractAnswersFromCheckInSurvey(
    _ result: ORKTaskResult) -> [OCKOutcomeValue]? {

        guard
            let response = result.results?
                .compactMap({ $0 as? ORKStepResult })
                .first(where: { $0.identifier == checkInFormIdentifier }),

                let scaleResults = response
                .results?.compactMap({ $0 as? ORKScaleQuestionResult }),

                let painAnswer = scaleResults
                .first(where: { $0.identifier == checkInPainItemIdentifier })?
                .scaleAnswer,

                let sleepAnswer = scaleResults
                .first(where: { $0.identifier == checkInSleepItemIdentifier })?
                .scaleAnswer

        else {
            assertionFailure("Failed to extract answers from check in survey!")
            return nil
        }

        var painValue = OCKOutcomeValue(Double(truncating: painAnswer))
        painValue.kind = checkInPainItemIdentifier

        var sleepValue = OCKOutcomeValue(Double(truncating: sleepAnswer))
        sleepValue.kind = checkInSleepItemIdentifier

        return [painValue, sleepValue]
    }

// MARK: Choice Survey

static let choiceIdentifier = "choice"
static let choiceFormIdentifier = "choice.form"
static let ChoiceQuestion1Step = "checkin.form.choicequestion1"
static let ChoiceQuestionStep = "checkin.form.Choice"

static func Choices() -> ORKTask {

    let Choices = [
        ORKTextChoice(text: "None", value: 0 as NSNumber),
        ORKTextChoice(text: "Slight", value: 1 as NSNumber),
        ORKTextChoice(text: "Mild", value: 2 as NSNumber),
        ORKTextChoice(text: "Severe", value: 3 as NSNumber),

    ]
    let choiceAnswerFormat =  ORKAnswerFormat.choiceAnswerFormat(
        with: .singleChoice,
        textChoices: Choices
    )

    let choiceStep = ORKFormItem(
        identifier: ChoiceQuestion1Step,
        text: "Text question goes here",
        answerFormat: choiceAnswerFormat
    )
    choiceStep.isOptional = false

    let formStep = ORKFormStep(
        identifier: choiceFormIdentifier,
        title: "Choice Question",
        text: "Instructions for questions"
    )
    formStep.formItems = [choiceStep]
    formStep.isOptional = false

    let choiceTask = ORKOrderedTask(
        identifier: choiceIdentifier,
        steps: [formStep]
    )

    return choiceTask
} 

static func extractAnswersFromChoiceSurvey(
    _ result: ORKTaskResult) -> [OCKOutcomeValue]? {

        guard
            let choiceResults = result.results?
                .compactMap({ $0 as? ORKStepResult })
                .first(where: { $0.identifier == choiceFormIdentifier }),

                let scale2Results = choiceResults
                .results?
                .compactMap({ $0 as? ORKChoiceQuestionResult }),

                let selectedChoices = scale2Results
                            .first(where: { $0.identifier == ChoiceQuestion1Step })?
                            // This line was the key!
                            .choiceAnswers as? [NSNumber]

        else {
            assertionFailure("Failed to extract answers from check in survey!")
            return nil
        }

        // Convert the choice values to CareKit outcome values
            let outcomeValues = selectedChoices
                .map { OCKOutcomeValue(Double(truncating: $0)) }
            //    outcomeValues.kind = ChoiceQuestion1Step

      //      var choiceValue = OCKOutcomeValue(Double(truncating: outcomeValues))
        //    choiceValue.kind = ChoiceQuestionStep

            print(outcomeValues)
            return outcomeValues
        }

// MARK: Range of Motion.

static func rangeOfMotionCheck() -> ORKTask {

    let rangeOfMotionOrderedTask = ORKOrderedTask.kneeRangeOfMotionTask(
        withIdentifier: "rangeOfMotionTask",
        limbOption: .left,
        intendedUseDescription: nil,
        options: [.excludeConclusion]
    )

    let completionStep = ORKCompletionStep(identifier: "rom.completion")
    completionStep.title = "All done!"
    completionStep.detailText = "We know the road to recovery can be tough. Keep up the good work!"

    rangeOfMotionOrderedTask.appendSteps([completionStep])

    return rangeOfMotionOrderedTask
}

static func extractRangeOfMotionOutcome(
    _ result: ORKTaskResult) -> [OCKOutcomeValue]? {

        guard let motionResult = result.results?
            .compactMap({ $0 as? ORKStepResult })
            .compactMap({ $0.results })
            .flatMap({ $0 })
            .compactMap({ $0 as? ORKRangeOfMotionResult })
            .first else {

            assertionFailure("Failed to parse range of motion result")
            return nil
        }

        var range = OCKOutcomeValue(motionResult.range)
        range.kind = #keyPath(ORKRangeOfMotionResult.range)

        return [range]
    }

// MARK: 3D Knee Model

static func kneeModel() -> ORKTask {

    let instructionStep = ORKInstructionStep(
        identifier: "insights.instructionStep"
    )
    instructionStep.title = "Your Injury Visualized"
    instructionStep.detailText = "A 3D model will be presented to give you better insights on your specific injury."
    instructionStep.iconImage = UIImage(systemName: "bandage")

    let modelManager = ORKUSDZModelManager(usdzFileName: "toy_robot_vintage")

    let kneeModelStep = ORK3DModelStep(
        identifier: "insights.kneeModel",
        modelManager: modelManager
    )

    let kneeModelTask = ORKOrderedTask(
        identifier: "insights",
        steps: [instructionStep, kneeModelStep]
    )

    return kneeModelTask       
   }
}

From the CareFeedViewController.swift

import CareKit
import CareKitStore
import CareKitUI
import ResearchKit
import UIKit
import os.log

final class CareFeedViewController: OCKDailyPageViewController,
                                OCKSurveyTaskViewControllerDelegate {

override func dailyPageViewController(
    _ dailyPageViewController: OCKDailyPageViewController,
    prepare listViewController: OCKListViewController,
    for date: Date) {

    checkIfOnboardingIsComplete { isOnboarded in

        guard isOnboarded else {

            let onboardCard = OCKSurveyTaskViewController(
                taskID: TaskIDs.onboarding,
                eventQuery: OCKEventQuery(for: date),
                storeManager: self.storeManager,
                survey: Surveys.onboardingSurvey(),
                extractOutcome: { _ in [OCKOutcomeValue(Date())] }
            )

            onboardCard.surveyDelegate = self

            listViewController.appendViewController(
                onboardCard,
                animated: false
            )

            return
        }

        let isFuture = Calendar.current.compare(
            date,
            to: Date(),
            toGranularity: .day) == .orderedDescending

        self.fetchTasks(on: date) { tasks in
            tasks.compactMap {

                let card = self.taskViewController(for: $0, on: date)
                card?.view.isUserInteractionEnabled = !isFuture
                card?.view.alpha = isFuture ? 0.4 : 1.0

                return card

            }.forEach {
                listViewController.appendViewController($0, animated: false)
            }
        }
    }
}

private func checkIfOnboardingIsComplete(_ completion: @escaping (Bool) -> Void) {

    var query = OCKOutcomeQuery()
    query.taskIDs = [TaskIDs.onboarding]

    storeManager.store.fetchAnyOutcomes(
        query: query,
        callbackQueue: .main) { result in

        switch result {

        case .failure:
            Logger.feed.error("Failed to fetch onboarding outcomes!")
            completion(false)

        case let .success(outcomes):
            completion(!outcomes.isEmpty)
        }
    }
}

private func fetchTasks(
    on date: Date,
    completion: @escaping([OCKAnyTask]) -> Void) {

    var query = OCKTaskQuery(for: date)
    query.excludesTasksWithNoEvents = true

    storeManager.store.fetchAnyTasks(
        query: query,
        callbackQueue: .main) { result in

        switch result {

        case .failure:
            Logger.feed.error("Failed to fetch tasks for date \(date)")
            completion([])

        case let .success(tasks):
            completion(tasks)
        }
    }
}

private func taskViewController(
    for task: OCKAnyTask,
    on date: Date) -> UIViewController? {

    switch task.id {

    case TaskIDs.checkIn:
        let survey = OCKSurveyTaskViewController(
            task: task,
            eventQuery: OCKEventQuery(for: date),
            storeManager: storeManager,
            survey: Surveys.checkInSurvey(),
            viewSynchronizer: SurveyViewSynchronizer(),
            extractOutcome: Surveys.extractAnswersFromCheckInSurvey
        )
        survey.surveyDelegate = self

        return survey

    case TaskIDs.choice:
        let survey = OCKSurveyTaskViewController(
            task: task,
            eventQuery: OCKEventQuery(for: date),
            storeManager: storeManager,
            survey: Surveys.Choices(),
            viewSynchronizer: SurveyViewSynchronizer(),
            extractOutcome: Surveys.extractAnswersFromChoiceSurvey
        )
        survey.surveyDelegate = self

        return survey

    case TaskIDs.rangeOfMotionCheck:
        let survey = OCKSurveyTaskViewController(
            task: task,
            eventQuery: OCKEventQuery(for: date),
            storeManager: storeManager,
            survey: Surveys.rangeOfMotionCheck(),
            extractOutcome: Surveys.extractRangeOfMotionOutcome
        )
        survey.surveyDelegate = self

        return survey

    default:
        return nil
    }
}

 func checkIfChoiceIsComplete(_ completion: @escaping (Bool) -> Void) {

    var query = OCKOutcomeQuery()
    query.taskIDs = [TaskIDs.choice]

    storeManager.store.fetchAnyOutcomes(
        query: query,
        callbackQueue: .main) { result in

        switch result {

        case .failure:
            Logger.feed.error("Failed to fetch onboarding outcomes!")
            completion(false)

        case let .success(outcomes):
            completion(!outcomes.isEmpty)
        }
    }
}

// MARK: SurveyTaskViewControllerDelegate

func surveyTask(
    viewController: OCKSurveyTaskViewController,
    for task: OCKAnyTask,
    didFinish result: Result<ORKTaskViewControllerFinishReason, Error>) {

    if case let .success(reason) = result, reason == .completed {
        reload()
    }
}

func surveyTask(
    viewController: OCKSurveyTaskViewController,
    shouldAllowDeletingOutcomeForEvent event: OCKAnyEvent) -> Bool {

    event.scheduleEvent.start >= Calendar.current.startOfDay(for: Date())
  }
}

final class SurveyViewSynchronizer: OCKSurveyTaskViewSynchronizer {

 override func updateView(
    _ view: OCKInstructionsTaskView,
    context: OCKSynchronizationContext<OCKTaskEvents>) {

    super.updateView(view, context: context)

    if let event = context.viewModel.first?.first, event.outcome != nil {
        view.instructionsLabel.isHidden = false

        let pain = event.answer(kind: Surveys.checkInPainItemIdentifier)
        let sleep = event.answer(kind: Surveys.checkInSleepItemIdentifier)
        let choice = event.answer(kind: Surveys.ChoiceQuestion1Step)

        view.instructionsLabel.text = """
            Pain: \(Int(pain))
            Sleep: \(Int(sleep)) hours
            Choice: \(Int(choice))
            """
    } else {
        view.instructionsLabel.isHidden = true
    }
  }
}
gavirawson-apple commented 1 year ago

The error you're seeing here is happening because outcomeValues is an array of outcome values, but the kind property is only available on each individual outcome value. We can modify the snippet a bit and set the kind when we create the array of outcome values:

static func extractAnswersFromChoiceSurvey(
    _ result: ORKTaskResult) -> [OCKOutcomeValue]?
{

    guard
        let choiceResult = result
            .results?
            .compactMap({ $0 as? ORKStepResult })
            .first(where: { $0.identifier == choiceFormIdentifier }),

        let scale2Results = choiceResult
            .results?
            .compactMap({ $0 as? ORKChoiceQuestionResult }),

        let selectedChoices = scale2Results
            .first(where: { $0.identifier == ChoiceQuestion1Step })?
            .choiceAnswers as? [NSNumber]
    else {
        assertionFailure("Failed to extract answers from check in survey!")
        return nil
    }

    // Convert the choice values to CareKit outcome values
    let outcomeValues = selectedChoices.map { selectedChoice -> OCKOutcomeValue in

        var outcomeValue = OCKOutcomeValue(Double(truncating: selectedChoice))

        // Set the kind here!
        outcomeValue.kind = ChoiceQuestionStep

        return outcomeValue
    }

    return outcomeValues
}
InfuriatingYetti commented 1 year ago

Hi Gavi!

It works!! Thank you so much for all of your help - you've been great!! :)

gavirawson-apple commented 1 year ago

Glad to hear it!