From 86d38c9f07cf90e87c2b97810e4f7e6f1f8ecb59 Mon Sep 17 00:00:00 2001 From: Corey Baker Date: Sat, 23 Apr 2022 18:35:32 -0400 Subject: [PATCH 1/2] feat: add Insights tab --- OCKSample.xcodeproj/project.pbxproj | 20 ++ OCKSample/Constants.swift | 19 ++ OCKSample/Extensions/Logger.swift | 1 + .../MainTabs/Care/CareViewController.swift | 37 ---- .../MainTabs/Insights/InsightsView.swift | 66 ++++++ .../Insights/InsightsViewController.swift | 193 ++++++++++++++++++ .../MainTabs/Insights/InsightsViewModel.swift | 28 +++ OCKSample/MainTabs/MainView.swift | 18 +- 8 files changed, 343 insertions(+), 39 deletions(-) create mode 100644 OCKSample/MainTabs/Insights/InsightsView.swift create mode 100644 OCKSample/MainTabs/Insights/InsightsViewController.swift create mode 100644 OCKSample/MainTabs/Insights/InsightsViewModel.swift diff --git a/OCKSample.xcodeproj/project.pbxproj b/OCKSample.xcodeproj/project.pbxproj index a2d1387..76b1a79 100644 --- a/OCKSample.xcodeproj/project.pbxproj +++ b/OCKSample.xcodeproj/project.pbxproj @@ -19,6 +19,9 @@ 701230EA280DE687003F5ECE /* OCKAnyEvent+Answer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 701230E9280DE687003F5ECE /* OCKAnyEvent+Answer.swift */; }; 701230EB280DE687003F5ECE /* OCKAnyEvent+Answer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 701230E9280DE687003F5ECE /* OCKAnyEvent+Answer.swift */; }; 701230F12810894D003F5ECE /* CustomContactViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 701230F02810894D003F5ECE /* CustomContactViewController.swift */; }; + 7012312428146365003F5ECE /* InsightsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7012312328146365003F5ECE /* InsightsViewController.swift */; }; + 701231262814638E003F5ECE /* InsightsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 701231252814638E003F5ECE /* InsightsView.swift */; }; + 70123128281463A2003F5ECE /* InsightsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 70123127281463A2003F5ECE /* InsightsViewModel.swift */; }; 70202EC12807333900CF73FB /* CareKit in Frameworks */ = {isa = PBXBuildFile; productRef = 70202EC02807333900CF73FB /* CareKit */; }; 70202EC32807333900CF73FB /* CareKitFHIR in Frameworks */ = {isa = PBXBuildFile; productRef = 70202EC22807333900CF73FB /* CareKitFHIR */; }; 70202EC52807333A00CF73FB /* CareKitUI in Frameworks */ = {isa = PBXBuildFile; productRef = 70202EC42807333A00CF73FB /* CareKitUI */; }; @@ -201,6 +204,9 @@ 701230E7280DE595003F5ECE /* SurveyViewSynchronizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SurveyViewSynchronizer.swift; sourceTree = ""; }; 701230E9280DE687003F5ECE /* OCKAnyEvent+Answer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "OCKAnyEvent+Answer.swift"; sourceTree = ""; }; 701230F02810894D003F5ECE /* CustomContactViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomContactViewController.swift; sourceTree = ""; }; + 7012312328146365003F5ECE /* InsightsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InsightsViewController.swift; sourceTree = ""; }; + 701231252814638E003F5ECE /* InsightsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InsightsView.swift; sourceTree = ""; }; + 70123127281463A2003F5ECE /* InsightsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InsightsViewModel.swift; sourceTree = ""; }; 70202EC9280746E900CF73FB /* ResearchKit.xcodeproj */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.pb-project"; name = ResearchKit.xcodeproj; path = ResearchKit/ResearchKit.xcodeproj; sourceTree = ""; }; 70202ED52807529900CF73FB /* Surveys.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Surveys.swift; sourceTree = ""; }; 70308885258273D400FFABB6 /* LoginViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginViewModel.swift; sourceTree = ""; }; @@ -343,6 +349,16 @@ path = Cards; sourceTree = ""; }; + 7012311F28146340003F5ECE /* Insights */ = { + isa = PBXGroup; + children = ( + 701231252814638E003F5ECE /* InsightsView.swift */, + 7012312328146365003F5ECE /* InsightsViewController.swift */, + 70123127281463A2003F5ECE /* InsightsViewModel.swift */, + ); + path = Insights; + sourceTree = ""; + }; 70202ECA280746E900CF73FB /* Products */ = { isa = PBXGroup; children = ( @@ -355,6 +371,7 @@ 703088772582727500FFABB6 /* MainTabs */ = { isa = PBXGroup; children = ( + 7012311F28146340003F5ECE /* Insights */, 91693819271B64CB00A634ED /* Stylers */, 70308878258272CD00FFABB6 /* Care */, 7036E4D2256EBE35006E9A3C /* MainView.swift */, @@ -888,6 +905,7 @@ 7036E64025717F85006E9A3C /* Constants.swift in Sources */, 918FDEBC271B4E4A0045A0EF /* Calendar+Dates.swift in Sources */, 70F03AA127860AB700E5AFB4 /* OCKOutcome+default.swift in Sources */, + 70123128281463A2003F5ECE /* InsightsViewModel.swift in Sources */, 7036E4CE256E9A0C006E9A3C /* ContactView.swift in Sources */, 70F03A972786098F00E5AFB4 /* OCKStore+Default.swift in Sources */, 70DFD80B2567074500B9DB12 /* LoginView.swift in Sources */, @@ -900,6 +918,8 @@ 9169381D271B650700A634ED /* ColorStyle.swift in Sources */, E72B2C0A226939E3009A9438 /* AppDelegate.swift in Sources */, 7036E4C4256E0A48006E9A3C /* CareView.swift in Sources */, + 7012312428146365003F5ECE /* InsightsViewController.swift in Sources */, + 701231262814638E003F5ECE /* InsightsView.swift in Sources */, 70F921B027CABED600368CEC /* LocalSyncSessionDelegate.swift in Sources */, 70123069280830A1003F5ECE /* Consent.swift in Sources */, 70F03A932786087800E5AFB4 /* OCKAnyEvent+CustomStringConvertable.swift in Sources */, diff --git a/OCKSample/Constants.swift b/OCKSample/Constants.swift index 0125615..737c651 100644 --- a/OCKSample/Constants.swift +++ b/OCKSample/Constants.swift @@ -10,6 +10,7 @@ import Foundation import CareKit import CareKitStore import ParseSwift +import os.log enum AppError: Error { case couldntCast @@ -98,3 +99,21 @@ enum UserType: String, Codable { enum InstallationChannel: String { case global } + +let eventAggregatorMean = OCKEventAggregator.custom { events -> Double in + + let totalCompleted = Double(events.map { $0.outcome?.values.count ?? 0 }.reduce(0, +)) + + Logger.constants.debug("Total \(totalCompleted)") + + let tempSumOfCompleted = events.compactMap { $0.outcome?.values.compactMap { $0.doubleValue ?? 0.0 }.reduce(0, +) } + Logger.constants.debug("TempSum \(tempSumOfCompleted)") + let sumOfCompleted = Double(tempSumOfCompleted.compactMap { $0 }.reduce(0, +)) + Logger.constants.debug("Sum \(sumOfCompleted)") + + if totalCompleted == 0 { + return Double(0) + } else { + return sumOfCompleted / totalCompleted + } +} diff --git a/OCKSample/Extensions/Logger.swift b/OCKSample/Extensions/Logger.swift index 030bd08..dab7128 100644 --- a/OCKSample/Extensions/Logger.swift +++ b/OCKSample/Extensions/Logger.swift @@ -27,4 +27,5 @@ extension Logger { static let ockStore = Logger(subsystem: subsystem, category: "OCKStore+Extension") static let ockHealthKitPassthroughStore = Logger(subsystem: subsystem, category: "OCKHealthKitPassthroughStore+Extension") + static let constants = Logger(subsystem: subsystem, category: "Constants") } diff --git a/OCKSample/MainTabs/Care/CareViewController.swift b/OCKSample/MainTabs/Care/CareViewController.swift index 76ba689..088b86a 100644 --- a/OCKSample/MainTabs/Care/CareViewController.swift +++ b/OCKSample/MainTabs/Care/CareViewController.swift @@ -38,8 +38,6 @@ import CareKitUI import ResearchKit import os.log -// swiftlint:disable type_body_length - class CareViewController: OCKDailyPageViewController { private var isSyncing = false @@ -278,41 +276,6 @@ class CareViewController: OCKDailyPageViewController { case TaskID.nausea: var cards = [UIViewController]() - // dynamic gradient colors - let nauseaGradientStart = UIColor { traitCollection -> UIColor in - return traitCollection.userInterfaceStyle == .light ? #colorLiteral(red: 0.06253327429, green: 0.6597633362, blue: 0.8644603491, alpha: 1) : #colorLiteral(red: 0, green: 0.2858072221, blue: 0.6897063851, alpha: 1) - } - let nauseaGradientEnd = UIColor { traitCollection -> UIColor in - return traitCollection.userInterfaceStyle == .light ? #colorLiteral(red: 0, green: 0.2858072221, blue: 0.6897063851, alpha: 1) : #colorLiteral(red: 0.06253327429, green: 0.6597633362, blue: 0.8644603491, alpha: 1) - } - - // Create a plot comparing nausea to medication adherence. - let nauseaDataSeries = OCKDataSeriesConfiguration( - taskID: "nausea", - legendTitle: "Nausea", - gradientStartColor: nauseaGradientStart, - gradientEndColor: nauseaGradientEnd, - markerSize: 10, - eventAggregator: OCKEventAggregator.countOutcomeValues) - - let doxylamineDataSeries = OCKDataSeriesConfiguration( - taskID: "doxylamine", - legendTitle: "Doxylamine", - gradientStartColor: .systemGray2, - gradientEndColor: .systemGray, - markerSize: 10, - eventAggregator: OCKEventAggregator.countOutcomeValues) - - let insightsCard = OCKCartesianChartViewController( - plotType: .bar, - selectedDate: date, - configurations: [nauseaDataSeries, doxylamineDataSeries], - storeManager: self.storeManager) - - insightsCard.chartView.headerView.titleLabel.text = "Nausea & Doxylamine Intake" - insightsCard.chartView.headerView.detailLabel.text = "This Week" - insightsCard.chartView.headerView.accessibilityLabel = "Nausea & Doxylamine Intake, This Week" - cards.append(insightsCard) // Also create a card that displays a single event. // The event query passed into the initializer specifies that only diff --git a/OCKSample/MainTabs/Insights/InsightsView.swift b/OCKSample/MainTabs/Insights/InsightsView.swift new file mode 100644 index 0000000..d6e2556 --- /dev/null +++ b/OCKSample/MainTabs/Insights/InsightsView.swift @@ -0,0 +1,66 @@ +// +// InsightsView.swift +// OCKSample +// +// Created by Corey Baker on 4/23/22. +// Copyright © 2022 Network Reconnaissance Lab. All rights reserved. +// + +/* + You should notice this looks like CareView but with references + to InsightsViewController instead. +*/ + +import SwiftUI +import UIKit +import CareKit +import CareKitStore +import os.log + +struct InsightsView: UIViewControllerRepresentable { + + @ObservedObject var viewModel = InsightsViewModel() + + @MainActor + func makeUIViewController(context: Context) -> some UIViewController { + let viewController = createViewContoller() + let navigationController = UINavigationController(rootViewController: viewController) + navigationController.navigationBar.backgroundColor = UIColor { $0.userInterfaceStyle == .light ? #colorLiteral(red: 1, green: 1, blue: 1, alpha: 1): #colorLiteral(red: 0, green: 0, blue: 0, alpha: 1) } + + return navigationController + } + + @MainActor + func updateUIViewController(_ uiViewController: UIViewControllerType, context: Context) { + // swiftlint:disable:next force_cast + let appDelegate = UIApplication.shared.delegate as! AppDelegate + + if appDelegate.isFirstLogin && appDelegate.isFirstAppOpen { + guard let navigationController = uiViewController as? UINavigationController, + let currentViewController = navigationController.viewControllers.first as? InsightsViewController, + appDelegate.storeManager !== currentViewController.storeManager else { + return + } + // Replace current view controller + let viewController = createViewContoller() + navigationController.viewControllers = [viewController] + } + } + + // MARK: Helpers + func createViewContoller() -> UIViewController { + guard let manager = StoreManagerKey.defaultValue else { + Logger.insights.error("Couldn't unwrap storeManager") + return InsightsViewController(storeManager: .init(wrapping: OCKStore(name: "none_insights", + type: .inMemory))) + } + return InsightsViewController(storeManager: manager) + } +} + +struct InsightsView_Previews: PreviewProvider { + + static var previews: some View { + InsightsView() + } +} diff --git a/OCKSample/MainTabs/Insights/InsightsViewController.swift b/OCKSample/MainTabs/Insights/InsightsViewController.swift new file mode 100644 index 0000000..7cf5794 --- /dev/null +++ b/OCKSample/MainTabs/Insights/InsightsViewController.swift @@ -0,0 +1,193 @@ +// +// InsightsViewController.swift +// OCKSample +// +// Created by Corey Baker on 4/23/22. +// Copyright © 2022 Network Reconnaissance Lab. All rights reserved. +// + +import UIKit +import CareKitStore +import CareKitUI +import CareKit +import ParseSwift +import ParseCareKit +import os.log + +class InsightsViewController: OCKListViewController { + + /// The manager of the `Store` from which the `Contact` data is fetched. + public let storeManager: OCKSynchronizedStoreManager + + /// Initialize using a store manager. All of the contacts in the store manager will be queried and dispalyed. + /// + /// - Parameters: + /// - storeManager: The store manager owning the store whose contacts should be displayed. + public init(storeManager: OCKSynchronizedStoreManager) { + self.storeManager = storeManager + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + public required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + + Task { + await displayTasks(Date()) + } + } + + override func viewDidAppear(_ animated: Bool) { + Task { + await displayTasks(Date()) + } + } + + override func appendViewController(_ viewController: UIViewController, animated: Bool) { + super.appendViewController(viewController, animated: animated) + + // Make sure this contact card matches app style when possible + if let carekitView = viewController.view as? OCKView { + carekitView.customStyle = CustomStyleKey.defaultValue + } + } + + @MainActor + func fetchTasks(on date: Date) async -> [OCKAnyTask] { + var query = OCKTaskQuery(for: date) + query.excludesTasksWithNoEvents = true + do { + let tasks = try await storeManager.store.fetchAnyTasks(query: query) + let orderedTasks = TaskID.ordered.compactMap { orderedTaskID in + tasks.first(where: { $0.id == orderedTaskID }) } + return orderedTasks + } catch { + Logger.insights.error("\(error.localizedDescription, privacy: .public)") + return [] + } + } + + /* + TODO: Plot all of your tasks in this method. Note that you can combine multiple + tasks into one chart (like the Nausea/Doxlymine chart if they are related. + */ + + func taskViewController(for task: OCKAnyTask, + on date: Date) -> [UIViewController]? { + switch task.id { + case TaskID.checkIn: + + /* + TODO: For checkInGradientStart, make another Environment + object similar to TintColorKey and use it instead of + creating a color for checkInGradientStart everytime. + Your new Environment object should be placed in the correct + folder. + */ + + // dynamic gradient colors + let checkInGradientStart = UIColor { traitCollection -> UIColor in + return traitCollection.userInterfaceStyle == .light ? #colorLiteral(red: 0.06253327429, green: 0.6597633362, blue: 0.8644603491, alpha: 1) : #colorLiteral(red: 0, green: 0.2858072221, blue: 0.6897063851, alpha: 1) + } + let checkInGradientEnd = TintColorKey.defaultValue + + /* + Note that that there's a small bug for the check in graph because + it averages all of the "Pain + Sleep" hours. This okay for now. If + you are collecting ResearchKit input that only collects 1 value per + survey, you won't have this problem. + */ + let checkInDataSeries = OCKDataSeriesConfiguration( + taskID: TaskID.checkIn, + legendTitle: "Check In", + gradientStartColor: checkInGradientStart, + gradientEndColor: checkInGradientEnd, + markerSize: 10, + eventAggregator: eventAggregatorMean) + + /* + TODO: CareKit has 3 plotType's: .bar, .scatter, and .line. + You should have a 3 types in your InsightView meaning you + should have at least 3 charts. Remember that all of your + tasks need to be graphed so you may have more. + */ + let insightsCard = OCKCartesianChartViewController( + plotType: .bar, + selectedDate: date, + configurations: [checkInDataSeries], + storeManager: self.storeManager) + + insightsCard.chartView.headerView.titleLabel.text = "Average Check In's" + insightsCard.chartView.headerView.detailLabel.text = "This Week" + insightsCard.chartView.headerView.accessibilityLabel = "Average Check In's, This Week" + + return [insightsCard] + + case TaskID.nausea: + var cards = [UIViewController]() + // dynamic gradient colors + let nauseaGradientStart = UIColor { traitCollection -> UIColor in + return traitCollection.userInterfaceStyle == .light ? #colorLiteral(red: 0.06253327429, green: 0.6597633362, blue: 0.8644603491, alpha: 1) : #colorLiteral(red: 0, green: 0.2858072221, blue: 0.6897063851, alpha: 1) + } + let nauseaGradientEnd = TintColorKey.defaultValue + + // Create a plot comparing nausea to medication adherence. + let nauseaDataSeries = OCKDataSeriesConfiguration( + taskID: TaskID.nausea, + legendTitle: "Nausea", + gradientStartColor: nauseaGradientStart, + gradientEndColor: nauseaGradientEnd, + markerSize: 10, + eventAggregator: OCKEventAggregator.countOutcomeValues) + + let doxylamineDataSeries = OCKDataSeriesConfiguration( + taskID: TaskID.doxylamine, + legendTitle: "Doxylamine", + gradientStartColor: .systemGray2, + gradientEndColor: .systemGray, + markerSize: 10, + eventAggregator: OCKEventAggregator.countOutcomeValues) + + let insightsCard = OCKCartesianChartViewController( + plotType: .bar, + selectedDate: date, + configurations: [nauseaDataSeries, doxylamineDataSeries], + storeManager: self.storeManager) + + insightsCard.chartView.headerView.titleLabel.text = "Nausea & Doxylamine Intake" + insightsCard.chartView.headerView.detailLabel.text = "This Week" + insightsCard.chartView.headerView.accessibilityLabel = "Nausea & Doxylamine Intake, This Week" + cards.append(insightsCard) + + return cards + + default: + return nil + } + } + + @MainActor + func displayTasks(_ date: Date) async { + + let tasks = await fetchTasks(on: date) + self.clear() // Clear after pulling tasks from database + + tasks.compactMap { + let cards = self.taskViewController(for: $0, on: date) + cards?.forEach { + if let carekitView = $0.view as? OCKView { + carekitView.customStyle = CustomStyleKey.defaultValue + } + } + return cards + }.forEach { (cards: [UIViewController]) in + cards.forEach { + self.appendViewController($0, animated: false) + } + } + } +} diff --git a/OCKSample/MainTabs/Insights/InsightsViewModel.swift b/OCKSample/MainTabs/Insights/InsightsViewModel.swift new file mode 100644 index 0000000..4f4b282 --- /dev/null +++ b/OCKSample/MainTabs/Insights/InsightsViewModel.swift @@ -0,0 +1,28 @@ +// +// InsightsViewModel.swift +// OCKSample +// +// Created by Corey Baker on 4/23/22. +// Copyright © 2022 Network Reconnaissance Lab. All rights reserved. +// + +import Foundation + +/* + You should notice this looks like CareViewModel. +*/ + +class InsightsViewModel: ObservableObject { + @Published var update = false + + init() { + NotificationCenter.default.addObserver(self, selector: #selector(reloadViewModel), + name: Notification.Name(rawValue: Constants.storeInitialized), + object: nil) + } + + // MARK: Helpers + @objc private func reloadViewModel() { + update = !update + } +} diff --git a/OCKSample/MainTabs/MainView.swift b/OCKSample/MainTabs/MainView.swift index 186a5bb..8e2a006 100644 --- a/OCKSample/MainTabs/MainView.swift +++ b/OCKSample/MainTabs/MainView.swift @@ -49,6 +49,20 @@ struct MainView: View { .navigationBarTitle("") .navigationBarHidden(true) + InsightsView() + .tabItem { + if selectedTab == 0 { + Image(systemName: "chart.pie.fill") + .renderingMode(.template) + } else { + Image(systemName: "chart.pie") + .renderingMode(.template) + } + } + .tag(1) + .navigationBarTitle("") + .navigationBarHidden(true) + ContactView() .tabItem { if selectedTab == 1 { @@ -59,7 +73,7 @@ struct MainView: View { .renderingMode(.template) } } - .tag(1) + .tag(2) .navigationBarTitle("") .navigationBarHidden(true) @@ -73,7 +87,7 @@ struct MainView: View { .renderingMode(.template) } } - .tag(2) + .tag(3) .navigationBarTitle("") .navigationBarHidden(true) } From c15cbb749d4f4fd78d4938e58898d7d4a82dbe73 Mon Sep 17 00:00:00 2001 From: Corey Baker Date: Sat, 23 Apr 2022 18:43:21 -0400 Subject: [PATCH 2/2] add navigation title --- OCKSample/MainTabs/Insights/InsightsViewController.swift | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/OCKSample/MainTabs/Insights/InsightsViewController.swift b/OCKSample/MainTabs/Insights/InsightsViewController.swift index 7cf5794..73a40f4 100644 --- a/OCKSample/MainTabs/Insights/InsightsViewController.swift +++ b/OCKSample/MainTabs/Insights/InsightsViewController.swift @@ -6,6 +6,11 @@ // Copyright © 2022 Network Reconnaissance Lab. All rights reserved. // +/* + You should notice this looks like CareView and MyContactView combined, + but only shows charts instead. +*/ + import UIKit import CareKitStore import CareKitUI @@ -36,6 +41,8 @@ class InsightsViewController: OCKListViewController { override func viewDidLoad() { super.viewDidLoad() + navigationItem.title = "Insights" + Task { await displayTasks(Date()) }