Open hyun99999 opened 2 years ago
Build a research and care app, part 2: Schedule tasks - WWDC21 - Videos - Apple Developer
*๋ณธ ๊ธ์ WWDC ๋ฅผ ๋ณด๊ณ , ๋ฒ์ญ ๋ฐ ์์ฝ ๊ทธ๋ฆฌ๊ณ ์คํํด๋ณด๋ ์คํฐ๋ ํ๋ก์ ํธ์ ์ผํ์ ๋๋ค.
ResearchKit ๊ณผ CareKit ์ ๋ํด์ ๋ ๋ง์ ์ ๋ณด๋ฅผ ์ป๊ณ ์ถ๋ค๋ฉด ์๋์ ์๊ฐ๊ธ๋ ๋์์ด ๋ ๊ฒ์ ๋๋ค.
part1 ์์๋ onboarding ๊ณผ consent ์ ๋ํด์ ๋ง์ณค์ต๋๋ค.
๐คฆ๐ปโโ๏ธย Erick: oh, hang on. Jamie ๋ก๋ถํฐ ๋ฉ์์ง๋ฅผ ๋ฐ์ ๊ฒ ๊ฐ์ต๋๋ค.
โ์ฑ์ ๋ํ ์๋ก์ด ์์ด๋์ด๋ฅผ ์ป์์ด์.โ
โ๋ด ๋ง์ง๋ง text ๋ดค์ด์?โ
์ ๊ฐ์ด ๋ฉ์์ง๋ฅผ ๋ฐ๊ณ , mail ๊ณผ Notes ์ ์๋ฆผ์ Erick ์ด ๋ฐ๊ฒ๋ฉ๋๋ค.
๋ฐํ๊ฐ ์ฐธ ๊ธฐ๊ฐ ๋งํ๊ตฐ์ ํฌ..
์! ๊ทธ๋ผ ์ด๋ฒ์๋ ๋ฌด์์ ํด์ผํ ์ง ๋ด ์๋ค.
Display forms, persisting some data, dynamic schedules, range of motion..
*์๋์ part1 ์์ ์์ฑํ ์ฝ๋๋ฅผ ๊ทธ๋๋ก ์ฌ์ฉํ๊ฒ ์ต๋๋ค.
import CareKit
import CareKitStore
import UIKit
import os.log
@main
class AppDelegate: UIResponder, UIApplicationDelegate {
let storeManager = OCKSynchronizedStoreManager(
wrapping: OCKStore(
name: "com.apple.wwdc.carekitstore",
type: .inMemory
)
)
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions
launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
seedTasks()
return true
}
// MARK: UISceneSession Life Cycle
func application(
_ application: UIApplication,
configurationForConnecting connectingSceneSession: UISceneSession,
options: UIScene.ConnectionOptions) -> UISceneConfiguration {
UISceneConfiguration(
name: "Default Configuration",
sessionRole: connectingSceneSession.role
)
}
// MARK: Seeding the Store
private func seedTasks() {
let onboardSchedule = OCKSchedule.dailyAtTime(
hour: 0, minutes: 0,
start: Date(), end: nil,
text: "Task Due!",
duration: .allDay
)
var onboardTask = OCKTask(
id: TaskIDs.onboarding,
title: "Onboard",
carePlanUUID: nil,
schedule: onboardSchedule
)
onboardTask.instructions = "You'll need to agree to some terms and conditions before we get started!"
onboardTask.impactsAdherence = false
// 2.1 Add a check-in task
// 2.6 Add a range of motion task
storeManager.store.addAnyTasks(
[onboardTask],
callbackQueue: .main) { result in
switch result {
case let .success(tasks):
Logger.store.info("Seeded \(tasks.count) tasks")
case let .failure(error):
Logger.store.warning("Failed to seed tasks: \(error as NSError)")
}
}
}
}
// 2.1 Add a check-in task
let checkInSchedule = OCKSchedule.dailyAtTime(
hour: 8, minutes: 0,
start: Date(), end: nil,
text: nil
)
// ๐ฅ ๊ณ ์ ํ ์๋ณ์์ ๋ฐฉ๊ธ ์ ์ํ ์ผ์ ์ผ๋ก check-in task ๋ฅผ ๋ง๋ญ๋๋ค.
let checkInTask = OCKTask(
id: TaskIDs.checkIn,
title: "Check In",
carePlanUUID: nil,
schedule: checkInSchedule
)
// 2.6 Add a range of motion task
storeManager.store.addAnyTasks(
// ๐ฅ ์ ์งํ๊ธฐ์ํด์ ์ฌ๊ธฐ์ task ๋ฅผ ๋ฃ์ต๋๋ค.
[onboardTask, checkInTask],
callbackQueue: .main) { result in
์จ๋ณด๋ฉ ์์ ๊ณผ ๋ง์ฐฌ๊ฐ์ง๋ก ๋ค์ ๋จ๊ณ๋ CareFeedViewController ๋ก ์ด๋ํด์ CareKit ์ task ๋ฅผ ํ์ํ๋ ๋ฐฉ๋ฒ์ ์๋ ค์ฃผ๋ ๊ฒ์ ๋๋ค.
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
}
// 2.2 Query and display a card for each task.
}
}
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)
}
}
}
// 2.3 Query all the tasks to be displayed on a given date
// 2.4 Create a card for a given task
// MARK: SurveyTaskViewControllerDelegate
func surveyTask(
viewController: OCKSurveyTaskViewController,
for task: OCKAnyTask,
didFinish result: Result<ORKTaskViewControllerFinishReason, Error>) {
if case let .success(reason) = result, reason == .completed {
reload()
}
}
}
์ด๋ฒ์๋ solution ์ ์ข ๋ generic ํ๊ฒ ๋ง๋ค์ด๋ณด๊ฒ ์ต๋๋ค. ํ์ฌ ๋ ์ง์ ๋ชจ๋ task ๋ฅผ ๊ฐ์ ธ์จ ๋ค์ ๊ฐ ์์ ์ ๋ํด์ ๋ทฐ ์ปจํธ๋กค๋ฌ๋ฅผ ์์ฑํ๊ณ , ํด๋น ๋ทฐ ์ปจํธ๋กค๋ฌ๋ฅผ list ์ ์ถ๊ฐํฉ๋๋ค. task ๋ฅผ ์ถ๊ฐํ ์๋ก ํฌ๊ธฐ๊ฐ ์กฐ์ ๋ฉ๋๋ค.
// 2.2 Query and display a card for each task.
self.fetchTasks(on: date) { tasks in
tasks.compactMap {
self.taskViewController(for: $0, on: date)
}.forEach {
listViewController.appendViewController($0, animated: false)
}
}
// ...
// 2.3 Query all the tasks to be displayed on a given date
private func fetchTasks(
on date: Date,
completion: @escaping([OCKAnyTask]) -> Void) {
var query = OCKTaskQuery(for: date)
// โ
Determines if tasks with no events should be included in the query results or not. False be default.
// ๐ฅ ์์ฝ๋ ์ด๋ฒคํธ๊ฐ ์๋ ์์
์ ์ ์ธํ๋๋ก ์ง์ ํฉ๋๋ค.
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([])
// ๐ฅ query ๊ฐ ๋ฐํ๋๋ฉด ๊ฐ์ ธ์จ tasks ๋ฅผ completion handler ๋ก ์ ๋ฌํฉ๋๋ค.
case let .success(tasks):
completion(tasks)
}
}
}
์ด๊ฒ์ ๋งค์ผ ์ผ์ด๋์ง ์์ ์ผ์ด ์์ ๋ ์คํ๋ฉ๋๋ค. ์๋ฅผ ๋ค์ด, ๋งค์ฃผ ์์์ผ๋ง๋ค ์ฝ์ ๋ณต์ฉํ๋ ์ฒ๋ฐฉ์ ๋ฐ์๋ค๊ณ ํ์ ๋ ํ,์์์ผ์ ์ฌ์ ํ ์ฝ์ด ์์ต๋๋ค. ์๋์ ์์ฑ์ ๊ทธ๋ฌํ ์์ ์ด qeury ์์ ๋ฐํ๋์ง ์๋๋ก ํฉ๋๋ค.
query.excludesTasksWithNoEvents = true
๋ํ, ์ฐ๋ฆฌ๋ task ๋ฅผ ์ํํ๊ณ ๋ทฐ ์ปจํธ๋กค๋ฌ๋ฅผ ๋ฐํํ๋ ๋ฉ์๋๋ฅผ ์์ฑํด์ผํฉ๋๋ค.
task ์ id ๋ฅผ ํ์ธํ๊ณ , check-in task ์ ๊ฒฝ์ฐ๋ part1 ์์ ์๊ฐํ ๊ฒ์ฒ๋ผ SurveyTaskViewController ๋ฅผ ์ฌ์ฉํฉ๋๋ค.
// 2.4 Create a card for a given task
private func taskViewController(
for task: OCKAnyTask,
on date: Date) -> UIViewController? {
switch task.id {
case TaskIDs.checkIn:
// ๐ฅ part1 ๊ณผ ๋ง์ฐฌ๊ฐ์ง๋ก task, event query, store manager ์ ๋ํ ์ฐธ์กฐ๋ฅผ ์ ๊ณตํฉ๋๋ค.
let survey = OCKSurveyTaskViewController(
task: task,
eventQuery: OCKEventQuery(for: date),
storeManager: storeManager,
survey: Surveys.checkInSurvey(),
extractOutcome: Surveys.extractAnswersFromCheckInSurvey
)
return survey
case TaskIDs.rangeOfMotionCheck:
let survey = OCKSurveyTaskViewController(
task: task,
eventQuery: OCKEventQuery(for: date),
storeManager: storeManager,
survey: Surveys.rangeOfMotionCheck(),
extractOutcome: Surveys.extractRangeOfMotionOutcome
)
return survey
default:
return nil
}
}
ResearchKit ์ survey ์ ๊ฒฐ๊ณผ๋ฅผ CareKit ๊ฒฐ๊ณผ ๊ฐ์ผ๋ก ๋ณํํ๋ ๊ธฐ๋ฅ๋ ๋ง๋ค์ด์ผ ํฉ๋๋ค.
๋จผ์ , ResearchKit ๊ณผ CareKit ์ ๋ํด ์ดํด๋ณด๊ฒ ์ต๋๋ค.
์ฐ๋ฆฌ Recover ์ฑ์ด ResearchKit survey ๋ฅผ ๋ง๋ค ๊ฒ์ด๊ณ , ResearchKit ์ survey flow ๋ฅผ ์งํํ๋ฉด์ ์ฐธ๊ฐ์๋ฅผ ์๋ดํฉ๋๋ค.
๊ทธ๋ฌ๋ฉด ORKTaskResult ๊ฐ ์์ฑ๋์ด ์ฐ๋ฆฌ์ ์ฑ์ผ๋ก ๋ฐํ๋ฉ๋๋ค.
๊ทธ๋ฆฌ๊ณ ๋์ ์ฐ๋ฆฌ์ ์ฑ์ ResearchKit ์ ๊ฒฐ๊ณผ๋ฅผ CareKit ์ ์คํ ์ด์ ์ ์ง๋๋๋ก CareKit ๊ฒฐ๊ณผ ๊ฐ์ผ๋ก ๋ณํํฉ๋๋ค.
์๋ก์ด ๊ฒฐ๊ณผ๋ฅผ ์ ์ฅํ๋ฉด completion ring ์ด ์ฑ์์ง๊ณ , card UI ๊ฐ ์ ๋ฐ์ดํธ ๋ฉ๋๋ค.
Survey.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: 2.5 Check In Survey
// MARK: 2.7 Range of Motion
}
์ฐ๋ฆฌ๋ ์ฐธ๊ฐ์๊ฐ ์ก์ ์๋ ์๊ฐ๊ณผ ๊ณ ํต์ ๋๋ผ๋ ์๊ฐ ์ฌ์ด์ ์ฐ๊ด์ฑ์ด ์๋์ง ์์๋ณด๋ ค๊ณ ํฉ๋๋ค. ๋ฐ๋ผ์ ๋๊ฐ์ง ์ง๋ฌธ์ ๋ง๋ค๊ฒ ์ต๋๋ค.
๋ item ์ ๋จ์ผ ์์์ ์ ๋ฌํ ๋ค์ ORKOrderedTask ๋ฅผ ์์ฑํ๋ฉด ๋ฉ๋๋ค.
// MARK: 2.5 Check In Survey
static let checkInIdentifier = "checkin"
static let checkInFormIdentifier = "checkin.form"
static let checkInPainItemIdentifier = "checkin.form.pain"
static let checkInSleepItemIdentifier = "checkin.form.sleep"
// โ
2.5.1 create the survey
static func checkInSurvey() -> ORKTask {
// ๊ณ ํต
// ์ต๋ ํต์ฆ์ 10์ผ๋ก ์ง์ ํ๊ณ , ์ต์๊ฐ์ 1๋ก ์ง์ ํ๊ณ , ๋จ๊ณ ํฌ๊ธฐ๋ฅผ 1๋ก ์ค์ ํ์ฌ ๋ฐ์ฌ๋ฆผ ์ซ์๋ง ํ์ฉํ๋๋ก ์ค์ ํ๊ณ , ์ต์/์ต๋์ ๋ํ ์ค๋ช
์ ์ ๊ณตํ๊ฒ ์ต๋๋ค.
let painAnswerFormat = ORKAnswerFormat.scale(
withMaximumValue: 10, // ์ต๋ ํต์ฆ 10.
minimumValue: 1, // ์ต์๊ฐ 1.
defaultValue: 0,
step: 1, // ๋จ๊ณ ํฌ๊ธฐ 1.
vertical: false, // ๋ฐ์ฌ๋ฆผ ์ซ์๋ง ํ์ฉ.
maximumValueDescription: "Very painful", // ์ต๋๊ฐ ์ค๋ช
.
minimumValueDescription: "No pain" // ์ต์๊ฐ ์ค๋ช
.
)
// ๐ฅ pain ์ ๋ํ ์ง๋ฌธ
let painItem = ORKFormItem(
identifier: checkInPainItemIdentifier,
text: "How would you rate your pain?",
answerFormat: painAnswerFormat
)
painItem.isOptional = false
// ์๋ฉด
// ๐ฅ ์ต์ ์๋ฉด ์๊ฐ 0์๊ฐ, ์ต๋ ์๋ฉด ์๊ฐ 12์๊ฐ.
let sleepAnswerFormat = ORKAnswerFormat.scale(
withMaximumValue: 12,
minimumValue: 0,
defaultValue: 0,
step: 1,
vertical: false,
maximumValueDescription: nil,
minimumValueDescription: nil
)
let sleepItem = ORKFormItem(
identifier: checkInSleepItemIdentifier,
text: "How many hours of sleep did you get last night?",
answerFormat: sleepAnswerFormat
)
sleepItem.isOptional = false
// formStep
// ๐ฅ formStep ์๋ ๊ณ ์ ํ ์๋ณ์, ์ ๋ชฉ ๋ฐ ํ
์คํธ๊ฐ ํ์ํฉ๋๋ค.
let formStep = ORKFormStep(
identifier: checkInFormIdentifier,
title: "Check In",
text: "Please answer the following questions."
)
// ๐ฅ form ์ ๊ฑด๋๋ธ ์ ์๋๋ก ํ๋ ค๋ฉด ์ด ํญ๋ชฉ์ false ์ค์ ํด์ผ ํฉ๋๋ค.
formStep.isOptional = false
// ๐ฅ ์์ ๋ ํญ๋ชฉ(painItem, sleepItem)์ formStep ์ ์ ๋ฌ.
formStep.formItems = [
painItem,
sleepItem
]
// ๐ฅ item ์ ๊ฐ์ง๊ณ ORKOrderedTask ์์ฑ.
let surveyTask = ORKOrderedTask(
identifier: checkInIdentifier,
steps: [formStep]
)
return surveyTask
}
๋ ๋ฒ์งธ ํจ์๋ ResearchKit task ๊ฒฐ๊ณผ๋ฅผ ๊ฐ์ ธ์์ ์ง์ํ CareKit ๊ฐ์ ์์ฑํด์ผ ํฉ๋๋ค. ๊ทธ ์ ์ ResearchKit task ์ ๊ตฌ์กฐ๋ฅผ ์ดํด๋ณด๊ณ ์ด๋ป๊ฒ ๋ถ์ํ๋์ง ๋ฐฉ๋ฒ์ ๋ํด์ ์์๋ด ์๋ค.
ORKTaskResults ๋ ์ค์ฒฉ๋ ํ์ ์ด๋ผ๋ ๊ฒ์ ์ดํดํ๋ ๊ฒ์ด ์ค์ํฉ๋๋ค. check-in survey ๋ฅผ ์ํ root ๊ฒฐ๊ณผ์์ ์์ํ ๋ค์ checkin.form ์ dril down(์๋๋ก ๋ด๋ ค๊ฐ๋ฉฐ ๋ถ์) ํฉ๋๋ค.
checkin.form ์ ๋ ๊ฐ์ง children ์ ๊ฐ์ง๊ณ ์๊ณ , ์ด๊ฒ์ ํํค์ณ์ผ ํฉ๋๋ค.
๋จผ์ , pain item ๊ณผ sleep itme ์๋ณ์์ ๋ํด ์ฃผ์ด์ง ๋ต์ ์ฐพ๊ณ ์ถ์ต๋๋ค. ์์์์๋ 4์ 11 ์ ๋๋ค.
์๊ฐ์ ์ผ๋ก ์ดํด๋ดค๊ณ , ์ฝ๋์์๋ ๊ฐ์ ํ๋ก์ธ์ค์ ๋๋ค.
// 2.5.2 Parse the results
static func extractAnswersFromCheckInSurvey(
_ result: ORKTaskResult) -> [OCKOutcomeValue]? {
guard
let response = result.results?
.compactMap({ $0 as? ORKStepResult })
// ๐ฅ 'checkin.form' ์๋ณ์๋ง ์ ํ
.first(where: { $0.identifier == checkInFormIdentifier }),
// ๐ฅ ORKScaleQuestionResult ํ์
์ ๊ฐ์ง ๋ชจ๋ children ์ ์ถ์ถํ ์ ์์ต๋๋ค.
let scaleResults = response
.results?.compactMap({ $0 as? ORKScaleQuestionResult }),
// ๐ฅ pain answer ์ ํด๋น ์๋ณ์๋ฅผ ๊ฐ์ง ์ฒซ๋ฒ์งธ ๊ฒ์ด๊ณ , sleep answer ์ ํด๋น ์๋ณ์๋ฅผ ๊ฐ์ง ์ฒซ๋ฒ์งธ ๊ฒ ์
๋๋ค.
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
}
// ๐ฅ CareKit ๊ฒฐ๊ณผ๊ฐ์ผ๋ก ๋ณํํด์ผ ํฉ๋๋ค.
// kind ํ๋กํผํฐ๋ ์ ํ์ฌํญ์ด์ง๋ง ๋์ค์ ๊ฐ์ ์ฐพ์ผ๋ ค๋ฉด ๋์์ด ๋ ์ ์์ต๋๋ค.(์ด ๋ถ๋ถ์ part 3 ์์ ์์๋ณด๊ฒ ์ต๋๋ค!)
var painValue = OCKOutcomeValue(Double(truncating: painAnswer))
painValue.kind = checkInPainItemIdentifier
var sleepValue = OCKOutcomeValue(Double(truncating: sleepAnswer))
sleepValue.kind = checkInSleepItemIdentifier
return [painValue, sleepValue]
}
part 1์์ ์ด๋ฏธ onboarding ์ ์๋ฃํ๊ธฐ ๋๋ฌธ์ consent flow ๋ฅผ ๋ค์ ํ์ง ์์๋ ๋ฉ๋๋ค. card ๋ฅผ ํญํ๋ฉด ResearchKit survey ๋ก ์ด๋ํฉ๋๋ค. ์๋ฅผ ๋ค์ด ํต์ฆ 4, ์๋ฉด์๊ฐ 8 ์ค์ ํ๊ฒ ์ต๋๋ค.
Care feed ๋ก ๋์๊ฐ๋ฉด ์์ completion ring ์ด ์ฑ์์ง๋ ๊ฒ์ ๋ณผ ์ ์๊ณ , ResearchKit ์ ๋ต์ด CareKit ์ ์ฑ๊ณต์ ์ผ๋ก ํ์ฑ๋์์์ ์ ์ ์์ต๋๋ค.
๋ค์์ ์ง๋ฌธ ํผ์ ๊ฐ์ง check-in survey ๋ฅผ ๋ง์ณค๊ณ , CareKit ์ ์ฐ๋ฆฌ๊ฐ ์ํ๋ ๋๋ก ์ ์ง๋๋ ๊ฒ์ ํ์ธํ์ต๋๋ค. ๋ฐ์ฏค ์๊ตฐ์!
๋ค์์ CareKit ์ ๊ณ ๊ธ ์ผ์ ์ผ๋ก ๋์ด๊ฐ๋ด ์๋ค. ๋์ค์ ์ฐ๋ฆฌ๋ range of motion task ๋ฅผ ์ ์ฉํ ๊ฒ์ ๋๋ค. check-in task ์ด๋ ์ด์ ์ onboarding task ์ฒ๋ผ ์ฒซ ๋ฒ์งธ๋ schedule ์ ์ ์ํ๋ ๊ฒ์ ๋๋ค. ํ์ง๋ง, ์ด ์์ ์๋ ์ข ๋ ๋ง์ ์์ ์ด ํ์ํฉ๋๋ค.
Jamie ๋ ์๊ฐ์ด ์ง๋จ์ ๋ฐ๋ผ ์ฐธ๊ฐ์๋ค์๊ฒ range of motion(์ด๋ ๋ฒ์)๋ฅผ ์ธก์ ํ๋๋ก ์์ฒญํ๋ ๋น๋๋ฅผ ์ค์ด๋๋ก ์์ฒญํ์ต๋๋ค. ๊ตฌ์ฒด์ ์ผ๋ก, ์ฐ๋ฆฌ๋ ์ฐธ๊ฐ์๊ฐ ์ฒซ ์ฃผ ๋์ ๋งค์ผ range of motion ์ ์ธก์ ํ๋๋ก ํ๋ schedule ์ ์ธ์ ์ผ๋ฉด ํฉ๋๋ค. ๊ทธ ๋ค์ ์๋ง๊น์ง ์ผ์ฃผ์ผ์ ํ๋ฒ ๊ทธ๋ฆฌ๊ณ ๊ทธ ์ดํ์๋ ๋ค์ ์ธก์ ํ์ง ์๋๋ก ํ์ผ๋ฉด ํฉ๋๋ค.
์ฒซ ์ฃผ์ ํ๋ฃจ ํ ๋ฒ, ๋๋จธ์ง ์ฃผ์๋ ์ฃผ๋ง๋ค ํ๋ฒ. ๋ช๊ฐ์ง ์ฃผ์ ๋ ์ง๋ฅผ ์ ์ํ๋ ๊ฒ์ผ๋ก ์์ํ๊ฒ ์ต๋๋ค.(thisMorning, nextWeek, and nextMonth)
// 2.6 Add a range of motion task
let thisMorning = Calendar.current.startOfDay(for: Date())
let nextWeek = Calendar.current.date(
byAdding: .weekOfYear,
value: 1,
to: Date()
)!
let nextMonth = Calendar.current.date(
byAdding: .month,
value: 1,
to: thisMorning
)
// ๐ฅ CareKit ์์ ๋ฏธ๋ฌํ ์ผ์ ์ ๋ง๋ค๋ ค๋ฉด OCKScheduleElement ๋ฅผ ์ฌ์ฉํด์ผ ํฉ๋๋ค.
// schedule element ์๋ ์์ ๋ ์ง, ์ข
๋ฃ ๋ ์ง๊ฐ ์๊ณ , ํด๋น ๊ธฐ๊ฐ ๋์ iinterval ์ ๊ฐ์ง๊ณ ๋ฐ๋ณต๋ฉ๋๋ค.
let dailyElement = OCKScheduleElement(
start: thisMorning,
end: nextWeek,
interval: DateComponents(day: 1),
text: nil,
targetValues: [],
duration: .allDay
)
// ๐ฅ ๋ค์์ฃผ๋ถํฐ ์์ํด์ ๋ค์๋ฌ๊น์ง ๋งค์ฃผ ๋ฐ๋ณต๋ฉ๋๋ค.
let weeklyElement = OCKScheduleElement(
start: nextWeek,
end: nextMonth,
interval: DateComponents(weekOfYear: 1),
text: nil,
targetValues: [],
duration: .allDay
)
// ๐ฅ ๋๊ฐ์ element ๋ฅผ ๊ฐ์ง๊ณ compound schedule ์ ๋ง๋ค ์ ์์ต๋๋ค.
let rangeOfMotionCheckSchedule = OCKSchedule(
composing: [dailyElement, weeklyElement]
)
// ๐ฅ ๊ทธ๋ฆฌ๊ณ ํด๋น schdule ์ ์ฌ์ฉํ๋ range of motion task ๋ฅผ ๋ง๋ค ์ ์์ต๋๋ค.
let rangeOfMotionCheckTask = OCKTask(
id: TaskIDs.rangeOfMotionCheck,
title: "Range Of Motion",
carePlanUUID: nil,
schedule: rangeOfMotionCheckSchedule
)
// ๐ฅ ๋ฌผ๋ก store ์ ์ถ๊ฐํด ์ฃผ์ด์ผ ํฉ๋๋ค.
storeManager.store.addAnyTasks(
[onboardTask, checkInTask, rangeOfMotionCheckTask],
callbackQueue: .main) { result in
switch result {
case let .success(tasks):
Logger.store.info("Seeded \(tasks.count) tasks")
case let .failure(error):
Logger.store.warning("Failed to seed tasks: \(error as NSError)")
}
}
๋ค๋ฅธ task ์ ๋ง์ฐฌ๊ฐ์ง๋ก ๋ค์ ๋จ๊ณ๋ Care feed ๋ก ์ด๋ํด์ CareKit ์ ์ด ์์ ์ ํ์ํ ๋ฐฉ๋ฒ์ ์ง์ ํ๋ ๊ฒ์ ๋๋ค.
๋ค์ SurveyTaskViewController ๋ฅผ ์ฌ์ฉํฉ๋๋ค. ๋ต๋ณ์ ์ป๊ธฐ์ํด์ survey ์ function ์ ์ ๊ณตํด์ผ ํฉ๋๋ค. Survey.swift ๋ก ๋์๊ฐ์ ์์ ํด์ผ ํฉ๋๋ค. range of motion tak ๋ ์ฌ์ค์ ๊ฐ๋จํฉ๋๋ค.
ResearchKit ์ ๋ฏธ๋ฆฌ ์ ์๋์ด์์ต๋๋ค. ์๋ณ์๋ฅผ ์ง์ ํ๊ณ , ์ธก์ ํ ๋ฌด๋ฆ์ ์ง์ ํ๊ธฐ๋ง ํ๋ฉด ๋ฉ๋๋ค.
// MARK: 2.7 Range of Motion
static func rangeOfMotionCheck() -> ORKTask {
let rangeOfMotionOrderedTask = ORKOrderedTask.kneeRangeOfMotionTask(
withIdentifier: "rangeOfMotionTask",
limbOption: .left,
intendedUseDescription: nil,
// ๐ฅ ํด๋น task ๊ฐ ์ฃผ๋ ๋ฉ์์ง๋ ๊ธฐ๋ณธ๊ฐ์ด์ง๋ง ์ฌ์ฉ์ ์ง์ ๋ฉ์์ง๋ฅผ ๋ณด์ฌ๋๋ฆฌ๊ธฐ ์ํด์ ๋ค์ ์ต์
์ค์ ํ๊ฒ ์ต๋๋ค.
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
}
// ๐ฅ ResearchKit ๊ฒฐ๊ณผ๊ฐ์ผ๋ก ๋ณํํ๋ ๊ฒ์
๋๋ค.
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
}
// ๐ฅ range of motion ์ ๊ฒฐ๊ณผ๋ ์ ์ฉํ ํน์ฑ๋ค์ ๊ฐ์ง๊ณ ์์ต๋๋ค. ํ์ง๋ง, ์ฐ๋ฆฌ์ use case ์์ ๊ฐ์ฅ ๊ด์ฌ ์๋ ๊ฒ์ range ์
๋๋ค. ์ด๊ฒ์ ์ฐธ๊ฐ์๊ฐ ๋ฌด๋ฆ์ ์ผ๋ง๋ ๊ตฌ๋ถ๋ฆด ์ ์์๋์ง ์ธก์ ํ๋ ๋ฒ์์
๋๋ค.
var range = OCKOutcomeValue(motionResult.range)
// ๐ฅ ์ด ๊ฐ์ ๋์ค์ ์ฝ๊ฒ ์ฐพ์ ์ ์๋๋ก keyPath ์ ํจ๊ป ์ฌ์ฉํ ์ ์์ต๋๋ค. part 3 ์์ ์ข ๋ ์์๋ณด๊ฒ ์ต๋๋ค.
range.kind = #keyPath(ORKRangeOfMotionResult.range)
return [range]
}
๋ชจ๋ ์ค๋น๋ฅผ ๋ง์น ๊ฒ ๊ฐ์ต๋๋ค.
๋จผ์ ์ค์ผ์ค์ด ์ฐ๋ฆฌ๊ฐ ์๋ํ๋ ๋๋ก ์๋ํ๊ณ ์๋์ง ํ์ธํด๋ณผ ํ์๊ฐ ์์ต๋๋ค. range of motion task ๋ ๋งค์ผ ํ์๋์ด์ผ ํฉ๋๋ค. ๊ทธ๋ฌ๋ ๋ค์์ฃผ๋ก ๋์ด๊ฐ๋ฉด ์์์ผ์ ์ ์ธํ๊ณ ํ์๋์ง ์์ต๋๋ค. ๋ ๋์๊ฐ์ ๋ค์ ๋ฌ์๋ ๋ ์ด์ ๋ํ๋์ง ์์ต๋๋ค.
CareKit shcedules ๋ ์ด๋ฌํ ์๋ฒ์ ๋ฏธ๋ฆฌ ํ๋ก๊ทธ๋๋ฐํ ์ ์๋ ์ข์ ๋ฐฉ๋ฒ์ ๋๋ค.
6์ 7์ผ ์์์ผ ๊ณผ 6์ 9์ผ์๋ ์ฒซ์ฃผ์ด๊ธฐ ๋๋ฌธ์ ๋งค์ผ๋งค์ผ check-in ๋ํ๋๋ค.
6์ 14์ผ ์์์ผ์ด๋ผ ํ์๋๊ณ , 6์ 17์ผ์ ํ์๋์ง ์์ต๋๋ค.
๋ค์ ๋ฌ์๋ ํ์๋์ง ์์ต๋๋ค.