From ed4cf58880430a71ad9c3d4387d2818876fb7d11 Mon Sep 17 00:00:00 2001 From: Corey Baker Date: Fri, 22 Apr 2022 00:35:04 -0400 Subject: [PATCH 1/8] feat: add updates to profile and contact --- OCKSample.xcodeproj/project.pbxproj | 44 ++++- OCKSample/Extensions/Logger.swift | 1 + .../OCKBiologicalSex+Hashable.swift | 13 ++ .../Contact/CustomContactViewController.swift | 12 +- OCKSample/MainTabs/Profile/ImagePicker.swift | 53 ++++++ .../ContactCards/DetailedContactView.swift | 50 ++++++ .../Profile/MyContact/MyContactView.swift | 38 +++++ .../MyContact/MyContactViewModel.swift | 74 ++++++++ OCKSample/MainTabs/Profile/ProfileView.swift | 161 +++++++++++------- .../MainTabs/Profile/ProfileViewModel.swift | 90 ++++++++++ OCKSample/Models/User.swift | 6 + 11 files changed, 480 insertions(+), 62 deletions(-) create mode 100644 OCKSample/Extensions/OCKBiologicalSex+Hashable.swift create mode 100644 OCKSample/MainTabs/Profile/ImagePicker.swift create mode 100644 OCKSample/MainTabs/Profile/MyContact/ContactCards/DetailedContactView.swift create mode 100644 OCKSample/MainTabs/Profile/MyContact/MyContactView.swift create mode 100644 OCKSample/MainTabs/Profile/MyContact/MyContactViewModel.swift diff --git a/OCKSample.xcodeproj/project.pbxproj b/OCKSample.xcodeproj/project.pbxproj index a2d1387..ccbb058 100644 --- a/OCKSample.xcodeproj/project.pbxproj +++ b/OCKSample.xcodeproj/project.pbxproj @@ -19,6 +19,11 @@ 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 */; }; + 701230FD28122A22003F5ECE /* MyContactView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 701230FC28122A22003F5ECE /* MyContactView.swift */; }; + 7012310028122A4E003F5ECE /* MyContactViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 701230FF28122A4E003F5ECE /* MyContactViewModel.swift */; }; + 7012310328122B0F003F5ECE /* DetailedContactView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7012310228122B0F003F5ECE /* DetailedContactView.swift */; }; + 70123105281255BD003F5ECE /* ImagePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 70123104281255BD003F5ECE /* ImagePicker.swift */; }; + 70123107281262FF003F5ECE /* OCKBiologicalSex+Hashable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 70123106281262FF003F5ECE /* OCKBiologicalSex+Hashable.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 +206,11 @@ 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 = ""; }; + 701230FC28122A22003F5ECE /* MyContactView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MyContactView.swift; sourceTree = ""; }; + 701230FF28122A4E003F5ECE /* MyContactViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MyContactViewModel.swift; sourceTree = ""; }; + 7012310228122B0F003F5ECE /* DetailedContactView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetailedContactView.swift; sourceTree = ""; }; + 70123104281255BD003F5ECE /* ImagePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImagePicker.swift; sourceTree = ""; }; + 70123106281262FF003F5ECE /* OCKBiologicalSex+Hashable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "OCKBiologicalSex+Hashable.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 +353,24 @@ path = Cards; sourceTree = ""; }; + 701230FE28122A31003F5ECE /* MyContact */ = { + isa = PBXGroup; + children = ( + 7012310128122AF2003F5ECE /* ContactCards */, + 701230FC28122A22003F5ECE /* MyContactView.swift */, + 701230FF28122A4E003F5ECE /* MyContactViewModel.swift */, + ); + path = MyContact; + sourceTree = ""; + }; + 7012310128122AF2003F5ECE /* ContactCards */ = { + isa = PBXGroup; + children = ( + 7012310228122B0F003F5ECE /* DetailedContactView.swift */, + ); + path = ContactCards; + sourceTree = ""; + }; 70202ECA280746E900CF73FB /* Products */ = { isa = PBXGroup; children = ( @@ -380,8 +408,10 @@ 7030887C258272E700FFABB6 /* Profile */ = { isa = PBXGroup; children = ( + 70123104281255BD003F5ECE /* ImagePicker.swift */, 7036E4C8256E1A6F006E9A3C /* ProfileView.swift */, 7036E516256F2413006E9A3C /* ProfileViewModel.swift */, + 701230FE28122A31003F5ECE /* MyContact */, ); path = Profile; sourceTree = ""; @@ -484,15 +514,16 @@ children = ( 918FDEBB271B4E4A0045A0EF /* Calendar+Dates.swift */, 918FDEB6271B41FF0045A0EF /* Logger.swift */, - 70F03A922786087800E5AFB4 /* OCKAnyEvent+CustomStringConvertable.swift */, 701230E9280DE687003F5ECE /* OCKAnyEvent+Answer.swift */, + 70F03A922786087800E5AFB4 /* OCKAnyEvent+CustomStringConvertable.swift */, 70F03A942786093B00E5AFB4 /* OCKHealthKitPassthroughStore+Default.swift */, - 70F03A962786098F00E5AFB4 /* OCKStore+Default.swift */, - 70F03A9827860A0800E5AFB4 /* OCKSynchronizedStoreManager+Publishers.swift */, - 70F03A9E27860A8800E5AFB4 /* OCKOutcomeValue+Identifiable.swift */, 70F03AA027860AB700E5AFB4 /* OCKOutcome+default.swift */, + 70F03A9E27860A8800E5AFB4 /* OCKOutcomeValue+Identifiable.swift */, 70F03AA227860AFF00E5AFB4 /* OCKPatient+Parse.swift */, + 70F03A962786098F00E5AFB4 /* OCKStore+Default.swift */, + 70F03A9827860A0800E5AFB4 /* OCKSynchronizedStoreManager+Publishers.swift */, 7083A855279CA40A00B3832E /* PCKUtility+extention.swift */, + 70123106281262FF003F5ECE /* OCKBiologicalSex+Hashable.swift */, ); path = Extensions; sourceTree = ""; @@ -884,6 +915,7 @@ E7440E4F229477F7007AD30A /* CareViewController.swift in Sources */, 918FDEB5271B40590045A0EF /* Installation.swift in Sources */, 70F03AB62788C80500E5AFB4 /* UserStatus.swift in Sources */, + 701230FD28122A22003F5ECE /* MyContactView.swift in Sources */, 70F921AC27CABE3000368CEC /* SessionDelegate.swift in Sources */, 7036E64025717F85006E9A3C /* Constants.swift in Sources */, 918FDEBC271B4E4A0045A0EF /* Calendar+Dates.swift in Sources */, @@ -897,19 +929,23 @@ 701230EA280DE687003F5ECE /* OCKAnyEvent+Answer.swift in Sources */, 701230F12810894D003F5ECE /* CustomContactViewController.swift in Sources */, 70202ED62807529900CF73FB /* Surveys.swift in Sources */, + 70123105281255BD003F5ECE /* ImagePicker.swift in Sources */, 9169381D271B650700A634ED /* ColorStyle.swift in Sources */, E72B2C0A226939E3009A9438 /* AppDelegate.swift in Sources */, 7036E4C4256E0A48006E9A3C /* CareView.swift in Sources */, 70F921B027CABED600368CEC /* LocalSyncSessionDelegate.swift in Sources */, 70123069280830A1003F5ECE /* Consent.swift in Sources */, 70F03A932786087800E5AFB4 /* OCKAnyEvent+CustomStringConvertable.swift in Sources */, + 7012310328122B0F003F5ECE /* DetailedContactView.swift in Sources */, 918FDEC3271B4E950045A0EF /* TintColorKey.swift in Sources */, + 7012310028122A4E003F5ECE /* MyContactViewModel.swift in Sources */, 70F03AA327860AFF00E5AFB4 /* OCKPatient+Parse.swift in Sources */, 918FDEB7271B41FF0045A0EF /* Logger.swift in Sources */, 70F03A9F27860A8800E5AFB4 /* OCKOutcomeValue+Identifiable.swift in Sources */, 7036E4D3256EBE35006E9A3C /* MainView.swift in Sources */, 918FDEC5271B4EA70045A0EF /* StoreManagerKey.swift in Sources */, 91693822271B897200A634ED /* Utility.swift in Sources */, + 70123107281262FF003F5ECE /* OCKBiologicalSex+Hashable.swift in Sources */, 707CC718254DA91900116728 /* OCKLocalization.swift in Sources */, E7C37849228F887800E982D8 /* TipView.swift in Sources */, 70077597252228E900EC0EDA /* User.swift in Sources */, diff --git a/OCKSample/Extensions/Logger.swift b/OCKSample/Extensions/Logger.swift index 030bd08..5520c39 100644 --- a/OCKSample/Extensions/Logger.swift +++ b/OCKSample/Extensions/Logger.swift @@ -18,6 +18,7 @@ extension Logger { static let localSessionDelegate = Logger(subsystem: subsystem, category: "LocalSessionDelegate") static let utility = Logger(subsystem: subsystem, category: "Utility") static let contact = Logger(subsystem: subsystem, category: "Contact") + static let myContact = Logger(subsystem: subsystem, category: "MyContact") static let login = Logger(subsystem: subsystem, category: "Login") static let feed = Logger(subsystem: subsystem, category: "Feed") static let watch = Logger(subsystem: subsystem, category: "Watch") diff --git a/OCKSample/Extensions/OCKBiologicalSex+Hashable.swift b/OCKSample/Extensions/OCKBiologicalSex+Hashable.swift new file mode 100644 index 0000000..104bfda --- /dev/null +++ b/OCKSample/Extensions/OCKBiologicalSex+Hashable.swift @@ -0,0 +1,13 @@ +// +// OCKBiologicalSex+Hashable.swift +// OCKSample +// +// Created by Corey Baker on 4/22/22. +// Copyright © 2022 Network Reconnaissance Lab. All rights reserved. +// + +import CareKitStore + +// Needed to use OCKBiologicalSex in a Picker. +// Simple conformance to hashable protocol. +extension OCKBiologicalSex: Hashable { } diff --git a/OCKSample/MainTabs/Contact/CustomContactViewController.swift b/OCKSample/MainTabs/Contact/CustomContactViewController.swift index 1ead88d..06545d4 100644 --- a/OCKSample/MainTabs/Contact/CustomContactViewController.swift +++ b/OCKSample/MainTabs/Contact/CustomContactViewController.swift @@ -8,6 +8,7 @@ import UIKit import CareKitStore +import CareKitUI import CareKit import Contacts import ContactsUI @@ -63,6 +64,15 @@ class CustomContactViewController: OCKListViewController { } } + 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 + } + } + @objc private func presentContactsListViewController() { let contactPicker = CNContactPickerViewController() @@ -100,7 +110,7 @@ class CustomContactViewController: OCKListViewController { } let filterdContacts = convertedContacts.filter { - // Modify this filter to not show the contact info for this user + // This filter removes the logged in users contact from the view. if $0.id == personUUIDString { return false } diff --git a/OCKSample/MainTabs/Profile/ImagePicker.swift b/OCKSample/MainTabs/Profile/ImagePicker.swift new file mode 100644 index 0000000..63ff9a6 --- /dev/null +++ b/OCKSample/MainTabs/Profile/ImagePicker.swift @@ -0,0 +1,53 @@ +// +// ImagePicker.swift +// OCKSample +// +// Created by Corey Baker on 4/21/22. +// Copyright © 2022 Network Reconnaissance Lab. All rights reserved. +// + +// swiftlint:disable:next line_length +// Credit to: https://www.hackingwithswift.com/books/ios-swiftui/importing-an-image-into-swiftui-using-phpickerviewcontroller + +import PhotosUI +import SwiftUI + +struct ImagePicker: UIViewControllerRepresentable { + @Binding var image: UIImage? + + func makeUIViewController(context: Context) -> PHPickerViewController { + var config = PHPickerConfiguration() + config.filter = .images + let picker = PHPickerViewController(configuration: config) + picker.delegate = context.coordinator + return picker + } + + func updateUIViewController(_ uiViewController: PHPickerViewController, context: Context) { + + } + + func makeCoordinator() -> Coordinator { + Coordinator(self) + } + + class Coordinator: NSObject, PHPickerViewControllerDelegate { + let parent: ImagePicker + + init(_ parent: ImagePicker) { + self.parent = parent + } + + func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { + picker.dismiss(animated: true) + + guard let provider = results.first?.itemProvider else { return } + + if provider.canLoadObject(ofClass: UIImage.self) { + provider.loadObject(ofClass: UIImage.self) { image, _ in + self.parent.image = image as? UIImage + } + } + } + } +} diff --git a/OCKSample/MainTabs/Profile/MyContact/ContactCards/DetailedContactView.swift b/OCKSample/MainTabs/Profile/MyContact/ContactCards/DetailedContactView.swift new file mode 100644 index 0000000..7a5aae1 --- /dev/null +++ b/OCKSample/MainTabs/Profile/MyContact/ContactCards/DetailedContactView.swift @@ -0,0 +1,50 @@ +// +// DetailedContactView.swift +// OCKSample +// +// Created by Corey Baker on 4/21/22. +// Copyright © 2022 Network Reconnaissance Lab. All rights reserved. +// + +import SwiftUI +import UIKit +import CareKit +import CareKitUI +import CareKitStore +import os.log + +struct DetailedContactView: UIViewControllerRepresentable { + + /// The contact to display. + public let contact: OCKAnyContact + + /// The manager of the `Store` from which the `Contact` data is fetched. + public let storeManager: OCKSynchronizedStoreManager + + func makeUIViewController(context: Context) -> some UIViewController { + + let contactCard = OCKDetailedContactViewController(contact: contact, + storeManager: storeManager) + + // Make sure this contact card matches app style when possible + if let carekitView = contactCard.view as? OCKView { + carekitView.customStyle = CustomStyleKey.defaultValue + } + return contactCard + } + + func updateUIViewController(_ uiViewController: UIViewControllerType, context: Context) { + + } +} + +struct DetailedContactView_Previews: PreviewProvider { + + static var previews: some View { + DetailedContactView(contact: OCKContact(id: "", + givenName: "hello", + familyName: "world", + carePlanUUID: nil), + storeManager: .init(wrapping: OCKStore(name: "dummy", type: .inMemory))) + } +} diff --git a/OCKSample/MainTabs/Profile/MyContact/MyContactView.swift b/OCKSample/MainTabs/Profile/MyContact/MyContactView.swift new file mode 100644 index 0000000..b3264ac --- /dev/null +++ b/OCKSample/MainTabs/Profile/MyContact/MyContactView.swift @@ -0,0 +1,38 @@ +// +// MyContactView.swift +// OCKSample +// +// Created by Corey Baker on 4/21/22. +// Copyright © 2022 Network Reconnaissance Lab. All rights reserved. +// + +import SwiftUI + +struct MyContactView: View { + + @StateObject var viewModel = MyContactViewModel() + + var body: some View { + ScrollView { + VStack { + if let storeManager = viewModel.storeManager { + ForEach(viewModel.contacts, id: \.id) { contact in + DetailedContactView(contact: contact, + storeManager: storeManager) + .padding(.horizontal) + // Technically, shouldn't hardcode a size, + // but leave the frame for the assignment. + .frame(width: 350, height: 350) + } + } + Spacer() + } + } + } +} + +struct MyContactView_Previews: PreviewProvider { + static var previews: some View { + MyContactView() + } +} diff --git a/OCKSample/MainTabs/Profile/MyContact/MyContactViewModel.swift b/OCKSample/MainTabs/Profile/MyContact/MyContactViewModel.swift new file mode 100644 index 0000000..47646fe --- /dev/null +++ b/OCKSample/MainTabs/Profile/MyContact/MyContactViewModel.swift @@ -0,0 +1,74 @@ +// +// MyContactViewModel.swift +// OCKSample +// +// Created by Corey Baker on 4/21/22. +// Copyright © 2022 Network Reconnaissance Lab. All rights reserved. +// + +import Foundation +import CareKitStore +import os.log +import CareKit + +class MyContactViewModel: ObservableObject { + + @Published var contacts = [OCKAnyContact]() + @Published var storeManager: OCKSynchronizedStoreManager? + + init() { + NotificationCenter.default.addObserver(self, selector: #selector(reloadViewModel), + name: Notification.Name(rawValue: Constants.storeInitialized), + object: nil) + if let storeManager = StoreManagerKey.defaultValue { + self.storeManager = storeManager + Task { + try? await fetchMyContact() + } + } + } + + // MARK: Helpers + + @objc private func reloadViewModel() { + guard let storeManager = StoreManagerKey.defaultValue else { + return + } + // If these stores don't have the same address in memory + if self.storeManager !== storeManager { + self.storeManager = storeManager + } + Task { + try? await fetchMyContact() + } + } + + @MainActor + func fetchMyContact() async throws { + guard User.current != nil, + let personUUIDString = UserDefaults.standard.object(forKey: Constants.parseRemoteClockIDKey) as? String, + let storeManager = storeManager else { + Logger.myContact.error("User not logged in") + self.contacts.removeAll() + return + } + + // ToDo to get credit: How would you modify this query to only fetch the + // contact that belongs to this device? + /* + Hint 1: There are multiple ways to do this. You can modify the query + below which can work. + + Hint2: Look at the other queries in the app related to the uuid of the + user who's signed in. + + Hint3: You should have a warning currently, solving this properly would + get rid of the warning without changing the line the warning is on. + */ + var query = OCKContactQuery(for: Date()) + query.sortDescriptors.append(.familyName(ascending: true)) + query.sortDescriptors.append(.givenName(ascending: true)) + + self.contacts = try await storeManager.store.fetchAnyContacts(query: query) + } +} diff --git a/OCKSample/MainTabs/Profile/ProfileView.swift b/OCKSample/MainTabs/Profile/ProfileView.swift index 1202100..b3aa84b 100644 --- a/OCKSample/MainTabs/Profile/ProfileView.swift +++ b/OCKSample/MainTabs/Profile/ProfileView.swift @@ -12,78 +12,125 @@ import CareKitStore import CareKit import os.log +// swiftlint:disable multiple_closures_with_trailing_closure + struct ProfileView: View { @Environment(\.presentationMode) var presentationMode @Environment(\.userProfileViewModel) var viewModel + @Environment(\.tintColor) private var tintColor @EnvironmentObject var userStatus: UserStatus @State var firstName = "" @State var lastName = "" @State var birthday = Calendar.current.date(byAdding: .year, value: -20, to: Date())! + @State var showContact = false + @State var showingImagePicker = false var body: some View { + NavigationView { + VStack { + NavigationLink(isActive: $showContact, + destination: { + MyContactView() + }) { + EmptyView() + } + if let image = viewModel.profileImage { + image + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 100, height: 100, alignment: .center) + .clipShape(Circle()) + .shadow(radius: 10) + .overlay(Circle().stroke(Color(tintColor), lineWidth: 5)) + .onTapGesture { + self.showingImagePicker = true + } + } else { + Image(systemName: "person.fill") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 100, height: 100, alignment: .center) + .clipShape(Circle()) + .shadow(radius: 10) + .overlay(Circle().stroke(Color(tintColor), lineWidth: 5)) + .onTapGesture { + self.showingImagePicker = true + } + } + VStack(alignment: .leading) { + TextField("First Name", text: $firstName) + .padding() + .cornerRadius(20.0) + .shadow(radius: 10.0, x: 20, y: 10) + + TextField("Last Name", text: $lastName) + .padding() + .cornerRadius(20.0) + .shadow(radius: 10.0, x: 20, y: 10) + + DatePicker("Birthday", selection: $birthday, displayedComponents: [DatePickerComponents.date]) + .padding() + .cornerRadius(20.0) + .shadow(radius: 10.0, x: 20, y: 10) + } - VStack { - VStack(alignment: .leading) { - TextField("First Name", text: $firstName) - .padding() - .cornerRadius(20.0) - .shadow(radius: 10.0, x: 20, y: 10) - - TextField("Last Name", text: $lastName) - .padding() - .cornerRadius(20.0) - .shadow(radius: 10.0, x: 20, y: 10) - - DatePicker("Birthday", selection: $birthday, displayedComponents: [DatePickerComponents.date]) - .padding() - .cornerRadius(20.0) - .shadow(radius: 10.0, x: 20, y: 10) - } + // Notice that "action" is a closure (which is essentially + // a function as argument like we discussed in class) + Button(action: { + + Task { + do { + try await viewModel.saveProfile(firstName, + last: lastName, + birth: birthday) + } catch { + Logger.profile.error("Error saving profile: \(error.localizedDescription)") + } + } - // Notice that "action" is a closure (which is essentially - // a function as argument like we discussed in class) - Button(action: { - - Task { - do { - try await viewModel.saveProfile(firstName, - last: lastName, - birth: birthday) - } catch { - Logger.profile.error("Error saving profile: \(error.localizedDescription)") + }, label: { + + Text("Save Profile") + .font(.headline) + .foregroundColor(.white) + .padding() + .frame(width: 300, height: 50) + }) + .background(Color(.green)) + .cornerRadius(15) + + // Notice that "action" is a closure (which is essentially + // a function as argument like we discussed in class) + Button(action: { + Task { + await viewModel.logout() } - } - }, label: { - - Text("Save Profile") - .font(.headline) - .foregroundColor(.white) - .padding() - .frame(width: 300, height: 50) - }) - .background(Color(.green)) - .cornerRadius(15) - - // Notice that "action" is a closure (which is essentially - // a function as argument like we discussed in class) - Button(action: { - Task { - await viewModel.logout() - } + }, label: { - }, label: { - - Text("Log Out") - .font(.headline) - .foregroundColor(.white) - .padding() - .frame(width: 300, height: 50) - }) - .background(Color(.red)) - .cornerRadius(15) - }.onReceive(viewModel.$patient, perform: { patient in + Text("Log Out") + .font(.headline) + .foregroundColor(.white) + .padding() + .frame(width: 300, height: 50) + }) + .background(Color(.red)) + .cornerRadius(15) + + Spacer() + } + .navigationBarItems(trailing: + Button(action: { + self.showContact = true + }) { + Text("My Contact") + }) + } + .sheet(isPresented: $showingImagePicker) { + ImagePicker(image: viewModel.$profileUIImage) + } + .onReceive(viewModel.$patient, perform: { patient in if let currentFirstName = patient?.name.givenName { firstName = currentFirstName } diff --git a/OCKSample/MainTabs/Profile/ProfileViewModel.swift b/OCKSample/MainTabs/Profile/ProfileViewModel.swift index acdac15..8b3658e 100644 --- a/OCKSample/MainTabs/Profile/ProfileViewModel.swift +++ b/OCKSample/MainTabs/Profile/ProfileViewModel.swift @@ -10,6 +10,7 @@ import Foundation import CareKit import CareKitStore import SwiftUI +import ParseSwift import ParseCareKit import UIKit import os.log @@ -18,18 +19,50 @@ import Combine class ProfileViewModel: ObservableObject { @Published var patient: OCKPatient? + @Published var contact: OCKContact? + @Published var sex: OCKBiologicalSex = .other("unspecified") @Published var isLoggedOut = false { willSet { if newValue { error = nil patient = nil + contact = nil clearSubscriptions() } } } @Published public internal(set) var error: Error? + @Published var profileImage: Image? + @State var profileUIImage = UIImage(systemName: "person.crop.circle") { + willSet { + guard self.profileUIImage != newValue, + let inputImage = newValue else { + return + } + profileImage = Image(uiImage: inputImage) + if !settingProfilePictureForFirstTime { + guard var user = User.current?.mergeable, + let image = inputImage.jpegData(compressionQuality: 0.25) else { + return + } + + let newProfilePicture = ParseFile(name: "profile.jpg", data: image) + user.profilePicture = newProfilePicture + let userToSave = user + Task { + do { + _ = try await userToSave.save() + Logger.profile.info("Saved updated profile picture successfully.") + } catch { + Logger.profile.error("Couldn't save profile picture: \(error.localizedDescription)") + } + } + } + } + } private(set) var storeManager: OCKSynchronizedStoreManager? private var cancellables: Set = [] + private var settingProfilePictureForFirstTime = true init() { reloadViewModel() @@ -62,6 +95,34 @@ class ProfileViewModel: ObservableObject { } } + @MainActor + private func fetchProfilePicture() async throws { + + // Profile pics are stored in Parse User. + guard let currentUser = try await User.current?.fetch() else { + Logger.profile.error("User isn't logged in") + return + } + + if let pictureFile = currentUser.profilePicture { + + // Download picture from server + do { + let profilePicture = try await pictureFile.fetch() + guard let path = profilePicture.localURL?.relativePath else { + return + } + self.profileUIImage = UIImage(contentsOfFile: path) + } catch { + Logger.profile.error("Couldn't fetch profile picture: \(error.localizedDescription).") + } + self.settingProfilePictureForFirstTime = false + + } else { + self.settingProfilePictureForFirstTime = false + } + } + @MainActor private func findAndObserveCurrentProfile() async { @@ -86,6 +147,19 @@ class ProfileViewModel: ObservableObject { return } self.observePatient(currentPatient) + + // Query the contact also so the user can edit + var queryForCurrentContact = OCKContactQuery(for: Date()) + queryForCurrentContact.ids = [uuid.uuidString] + + guard let foundContact = try await appDelegate.store?.fetchContacts(query: queryForCurrentContact), + let currentContact = foundContact.first else { + // swiftlint:disable:next line_length + Logger.profile.error("Error: Couldn't find contact with id \"\(uuid)\". It's possible they have never been saved.") + return + } + self.contact = currentContact + try? await fetchProfilePicture() } catch { // swiftlint:disable:next line_length Logger.profile.error("Error: Couldn't find patient with id \"\(uuid)\". It's possible they have never been saved. Query error: \(error.localizedDescription)") @@ -199,6 +273,14 @@ class ProfileViewModel: ObservableObject { return } self.patient = newPatient + + // Added code to create a contact for the respective signed up user + let newContact = OCKContact(id: remoteUUID, + name: newPatient.name, + carePlanUUID: nil) + + // This is new contact that has never been saved before + _ = try await storeManager?.store.addAnyContact(newContact) } } @@ -232,6 +314,14 @@ class ProfileViewModel: ObservableObject { throw AppError.couldntCast } + // Added code to create a contact for the respective signed up user + let newContact = OCKContact(id: remoteUUID.uuidString, + name: newPatient.name, + carePlanUUID: nil) + + // This is new contact that has never been saved before + _ = try await storeManager.store.addAnyContact(newContact) + try await appDelegate.store?.populateSampleData() try await appDelegate.healthKitStore.populateSampleData() appDelegate.parseRemote.automaticallySynchronizes = true diff --git a/OCKSample/Models/User.swift b/OCKSample/Models/User.swift index 788ae57..1272f62 100644 --- a/OCKSample/Models/User.swift +++ b/OCKSample/Models/User.swift @@ -35,6 +35,8 @@ struct User: ParseUser { var userTypeUUIDs: [String: UUID]? + var profilePicture: ParseFile? + func merge(with object: Self) throws -> Self { var updated = try mergeParse(with: object) if updated.shouldRestoreKey(\.lastTypeSelected, @@ -45,6 +47,10 @@ struct User: ParseUser { original: object) { updated.userTypeUUIDs = object.userTypeUUIDs } + if updated.shouldRestoreKey(\.profilePicture, + original: object) { + updated.profilePicture = object.profilePicture + } return updated } } From 4b2bce4a0846fe31892a3453ae87d99b3b442022 Mon Sep 17 00:00:00 2001 From: Corey Baker Date: Fri, 22 Apr 2022 10:42:17 -0400 Subject: [PATCH 2/8] observer contact and use UIKit MyContactView instead --- OCKSample.xcodeproj/project.pbxproj | 26 ++++---- ...KSynchronizedStoreManager+Publishers.swift | 35 +++++++++++ OCKSample/MainTabs/Care/CareView.swift | 1 + OCKSample/MainTabs/Contact/ContactView.swift | 43 +++++++++++-- .../MainTabs/Contact/ContactViewModel.swift | 29 +++++++++ .../Contact/CustomContactViewController.swift | 10 +-- OCKSample/MainTabs/MainView.swift | 1 + OCKSample/MainTabs/Profile/ImagePicker.swift | 4 +- .../ContactCards/DetailedContactView.swift | 50 --------------- .../Profile/MyContact/MyContactView.swift | 63 +++++++++++++------ .../MyContact/MyContactViewModel.swift | 54 ++-------------- OCKSample/MainTabs/Profile/ProfileView.swift | 18 +++--- .../MainTabs/Profile/ProfileViewModel.swift | 15 ++++- 13 files changed, 192 insertions(+), 157 deletions(-) create mode 100644 OCKSample/MainTabs/Contact/ContactViewModel.swift delete mode 100644 OCKSample/MainTabs/Profile/MyContact/ContactCards/DetailedContactView.swift diff --git a/OCKSample.xcodeproj/project.pbxproj b/OCKSample.xcodeproj/project.pbxproj index ccbb058..32c0e15 100644 --- a/OCKSample.xcodeproj/project.pbxproj +++ b/OCKSample.xcodeproj/project.pbxproj @@ -21,9 +21,10 @@ 701230F12810894D003F5ECE /* CustomContactViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 701230F02810894D003F5ECE /* CustomContactViewController.swift */; }; 701230FD28122A22003F5ECE /* MyContactView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 701230FC28122A22003F5ECE /* MyContactView.swift */; }; 7012310028122A4E003F5ECE /* MyContactViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 701230FF28122A4E003F5ECE /* MyContactViewModel.swift */; }; - 7012310328122B0F003F5ECE /* DetailedContactView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7012310228122B0F003F5ECE /* DetailedContactView.swift */; }; 70123105281255BD003F5ECE /* ImagePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 70123104281255BD003F5ECE /* ImagePicker.swift */; }; 70123107281262FF003F5ECE /* OCKBiologicalSex+Hashable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 70123106281262FF003F5ECE /* OCKBiologicalSex+Hashable.swift */; }; + 701231092812DC3B003F5ECE /* MyContactViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 701231082812DC3B003F5ECE /* MyContactViewController.swift */; }; + 7012310B2812E24F003F5ECE /* ContactViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7012310A2812E24F003F5ECE /* ContactViewModel.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 */; }; @@ -208,9 +209,10 @@ 701230F02810894D003F5ECE /* CustomContactViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomContactViewController.swift; sourceTree = ""; }; 701230FC28122A22003F5ECE /* MyContactView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MyContactView.swift; sourceTree = ""; }; 701230FF28122A4E003F5ECE /* MyContactViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MyContactViewModel.swift; sourceTree = ""; }; - 7012310228122B0F003F5ECE /* DetailedContactView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetailedContactView.swift; sourceTree = ""; }; 70123104281255BD003F5ECE /* ImagePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImagePicker.swift; sourceTree = ""; }; 70123106281262FF003F5ECE /* OCKBiologicalSex+Hashable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "OCKBiologicalSex+Hashable.swift"; sourceTree = ""; }; + 701231082812DC3B003F5ECE /* MyContactViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MyContactViewController.swift; sourceTree = ""; }; + 7012310A2812E24F003F5ECE /* ContactViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactViewModel.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 = ""; }; @@ -356,21 +358,13 @@ 701230FE28122A31003F5ECE /* MyContact */ = { isa = PBXGroup; children = ( - 7012310128122AF2003F5ECE /* ContactCards */, 701230FC28122A22003F5ECE /* MyContactView.swift */, + 701231082812DC3B003F5ECE /* MyContactViewController.swift */, 701230FF28122A4E003F5ECE /* MyContactViewModel.swift */, ); path = MyContact; sourceTree = ""; }; - 7012310128122AF2003F5ECE /* ContactCards */ = { - isa = PBXGroup; - children = ( - 7012310228122B0F003F5ECE /* DetailedContactView.swift */, - ); - path = ContactCards; - sourceTree = ""; - }; 70202ECA280746E900CF73FB /* Products */ = { isa = PBXGroup; children = ( @@ -395,12 +389,12 @@ 70308878258272CD00FFABB6 /* Care */ = { isa = PBXGroup; children = ( + 7036E4C3256E0A48006E9A3C /* CareView.swift */, E7440E4E229477F7007AD30A /* CareViewController.swift */, + 70F03ABA2789071400E5AFB4 /* CareViewModel.swift */, + 70123068280830A1003F5ECE /* Consent.swift */, 701230E6280DE574003F5ECE /* Cards */, 701230DC280DDF3A003F5ECE /* Surveys */, - 70123068280830A1003F5ECE /* Consent.swift */, - 7036E4C3256E0A48006E9A3C /* CareView.swift */, - 70F03ABA2789071400E5AFB4 /* CareViewModel.swift */, ); path = Care; sourceTree = ""; @@ -420,6 +414,7 @@ isa = PBXGroup; children = ( 7036E4CD256E9A0C006E9A3C /* ContactView.swift */, + 7012310A2812E24F003F5ECE /* ContactViewModel.swift */, 701230F02810894D003F5ECE /* CustomContactViewController.swift */, ); path = Contact; @@ -930,13 +925,14 @@ 701230F12810894D003F5ECE /* CustomContactViewController.swift in Sources */, 70202ED62807529900CF73FB /* Surveys.swift in Sources */, 70123105281255BD003F5ECE /* ImagePicker.swift in Sources */, + 701231092812DC3B003F5ECE /* MyContactViewController.swift in Sources */, 9169381D271B650700A634ED /* ColorStyle.swift in Sources */, E72B2C0A226939E3009A9438 /* AppDelegate.swift in Sources */, 7036E4C4256E0A48006E9A3C /* CareView.swift in Sources */, 70F921B027CABED600368CEC /* LocalSyncSessionDelegate.swift in Sources */, 70123069280830A1003F5ECE /* Consent.swift in Sources */, 70F03A932786087800E5AFB4 /* OCKAnyEvent+CustomStringConvertable.swift in Sources */, - 7012310328122B0F003F5ECE /* DetailedContactView.swift in Sources */, + 7012310B2812E24F003F5ECE /* ContactViewModel.swift in Sources */, 918FDEC3271B4E950045A0EF /* TintColorKey.swift in Sources */, 7012310028122A4E003F5ECE /* MyContactViewModel.swift in Sources */, 70F03AA327860AFF00E5AFB4 /* OCKPatient+Parse.swift in Sources */, diff --git a/OCKSample/Extensions/OCKSynchronizedStoreManager+Publishers.swift b/OCKSample/Extensions/OCKSynchronizedStoreManager+Publishers.swift index 69fd687..9c81dbf 100644 --- a/OCKSample/Extensions/OCKSynchronizedStoreManager+Publishers.swift +++ b/OCKSample/Extensions/OCKSynchronizedStoreManager+Publishers.swift @@ -47,6 +47,41 @@ extension OCKSynchronizedStoreManager { .prepend(presentValuePublisher)) } + // MARK: Contacts + + func contactsPublisher(categories: [OCKStoreNotificationCategory]) -> AnyPublisher { + return AnyPublisher(notificationPublisher + .compactMap { $0 as? OCKContactNotification } + .filter { categories.contains($0.category) } + .map { $0.contact }) + } + + func publisher(forContactID id: String, + categories: [OCKStoreNotificationCategory]) -> AnyPublisher { + return notificationPublisher + .compactMap { $0 as? OCKContactNotification } + .filter { $0.contact.id == id && categories.contains($0.category) } + .map { $0.contact } + .eraseToAnyPublisher() + } + + func publisher(forContact contact: OCKAnyContact, + categories: [OCKStoreNotificationCategory], + fetchImmediately: Bool = true) -> AnyPublisher { + let presentValuePublisher = Future({ completion in + self.store.fetchAnyContact(withID: contact.id) { result in + completion(.success((try? result.get()) ?? contact)) + } + }) + + let changePublisher = notificationPublisher + .compactMap { $0 as? OCKContactNotification } + .filter { $0.contact.id == contact.id && categories.contains($0.category) } + .map { $0.contact } + + return fetchImmediately ? AnyPublisher(changePublisher.prepend(presentValuePublisher)) : AnyPublisher(changePublisher) + } + // MARK: Tasks func publisherForTasks(categories: [OCKStoreNotificationCategory]) -> AnyPublisher { diff --git a/OCKSample/MainTabs/Care/CareView.swift b/OCKSample/MainTabs/Care/CareView.swift index 5dab6af..029bc3b 100644 --- a/OCKSample/MainTabs/Care/CareView.swift +++ b/OCKSample/MainTabs/Care/CareView.swift @@ -18,6 +18,7 @@ struct CareView: UIViewControllerRepresentable { @ObservedObject var viewModel = CareViewModel() + @MainActor func makeUIViewController(context: Context) -> some UIViewController { let view = createCareView() diff --git a/OCKSample/MainTabs/Contact/ContactView.swift b/OCKSample/MainTabs/Contact/ContactView.swift index 328c2d7..c9866df 100644 --- a/OCKSample/MainTabs/Contact/ContactView.swift +++ b/OCKSample/MainTabs/Contact/ContactView.swift @@ -12,19 +12,50 @@ import CareKit import CareKitStore import os.log +/* + You should notice this looks like CareView but with references + to CustomContactViewController instead. +*/ + struct ContactView: UIViewControllerRepresentable { + @ObservedObject var viewModel = ContactViewModel() + + @MainActor func makeUIViewController(context: Context) -> some UIViewController { - guard let manager = StoreManagerKey.defaultValue else { - Logger.contact.error("Couldn't unwrap storeManager") - return UINavigationController() - } - let contactViewController = CustomContactViewController(storeManager: manager) - return UINavigationController(rootViewController: contactViewController) + 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, + // swiftlint:disable:next line_length + let currentViewController = navigationController.viewControllers.first as? CustomContactViewController, + 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.contact.error("Couldn't unwrap storeManager") + return CustomContactViewController(storeManager: .init(wrapping: OCKStore(name: "none_contact", + type: .inMemory))) + } + return CustomContactViewController(storeManager: manager) } } diff --git a/OCKSample/MainTabs/Contact/ContactViewModel.swift b/OCKSample/MainTabs/Contact/ContactViewModel.swift new file mode 100644 index 0000000..afad77f --- /dev/null +++ b/OCKSample/MainTabs/Contact/ContactViewModel.swift @@ -0,0 +1,29 @@ +// +// ContactViewModel.swift +// OCKSample +// +// Created by Corey Baker on 4/22/22. +// Copyright © 2022 Network Reconnaissance Lab. All rights reserved. +// + +import Foundation + +/* + You should notice this looks like CareViewModel. +*/ + +class ContactViewModel: 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/Contact/CustomContactViewController.swift b/OCKSample/MainTabs/Contact/CustomContactViewController.swift index 06545d4..0e8a068 100644 --- a/OCKSample/MainTabs/Contact/CustomContactViewController.swift +++ b/OCKSample/MainTabs/Contact/CustomContactViewController.swift @@ -86,10 +86,6 @@ class CustomContactViewController: OCKListViewController { dismiss(animated: true, completion: nil) } - func clearAndKeepSearchBar() { - clear() - } - @MainActor func fetchContacts() async throws { guard User.current != nil else { @@ -117,13 +113,13 @@ class CustomContactViewController: OCKListViewController { return true } - self.clearAndKeepSearchBar() self.allContacts = filterdContacts self.displayContacts(self.allContacts) } @MainActor func displayContacts(_ contacts: [OCKAnyContact]) { + self.clear() for contact in contacts { let contactViewController = OCKSimpleContactViewController(contact: contact, storeManager: storeManager) @@ -172,13 +168,10 @@ extension CustomContactViewController: UISearchBarDelegate { if searchBar.text!.isEmpty { // Show all contacts - clearAndKeepSearchBar() displayContacts(allContacts) return } - clearAndKeepSearchBar() - let filteredContacts = allContacts.filter { (contact: OCKAnyContact) -> Bool in if let givenName = contact.name.givenName { @@ -194,7 +187,6 @@ extension CustomContactViewController: UISearchBarDelegate { } func searchBarCancelButtonClicked(_ searchBar: UISearchBar) { - clearAndKeepSearchBar() displayContacts(allContacts) } } diff --git a/OCKSample/MainTabs/MainView.swift b/OCKSample/MainTabs/MainView.swift index 186a5bb..b1b12b8 100644 --- a/OCKSample/MainTabs/MainView.swift +++ b/OCKSample/MainTabs/MainView.swift @@ -79,6 +79,7 @@ struct MainView: View { } } } + .environmentObject(profileViewModel) .environmentObject(userStatus) .statusBar(hidden: true) .accentColor(Color(tintColor)) diff --git a/OCKSample/MainTabs/Profile/ImagePicker.swift b/OCKSample/MainTabs/Profile/ImagePicker.swift index 63ff9a6..371cee9 100644 --- a/OCKSample/MainTabs/Profile/ImagePicker.swift +++ b/OCKSample/MainTabs/Profile/ImagePicker.swift @@ -45,7 +45,9 @@ struct ImagePicker: UIViewControllerRepresentable { if provider.canLoadObject(ofClass: UIImage.self) { provider.loadObject(ofClass: UIImage.self) { image, _ in - self.parent.image = image as? UIImage + DispatchQueue.main.async { [weak self] in + self?.parent.image = image as? UIImage + } } } } diff --git a/OCKSample/MainTabs/Profile/MyContact/ContactCards/DetailedContactView.swift b/OCKSample/MainTabs/Profile/MyContact/ContactCards/DetailedContactView.swift deleted file mode 100644 index 7a5aae1..0000000 --- a/OCKSample/MainTabs/Profile/MyContact/ContactCards/DetailedContactView.swift +++ /dev/null @@ -1,50 +0,0 @@ -// -// DetailedContactView.swift -// OCKSample -// -// Created by Corey Baker on 4/21/22. -// Copyright © 2022 Network Reconnaissance Lab. All rights reserved. -// - -import SwiftUI -import UIKit -import CareKit -import CareKitUI -import CareKitStore -import os.log - -struct DetailedContactView: UIViewControllerRepresentable { - - /// The contact to display. - public let contact: OCKAnyContact - - /// The manager of the `Store` from which the `Contact` data is fetched. - public let storeManager: OCKSynchronizedStoreManager - - func makeUIViewController(context: Context) -> some UIViewController { - - let contactCard = OCKDetailedContactViewController(contact: contact, - storeManager: storeManager) - - // Make sure this contact card matches app style when possible - if let carekitView = contactCard.view as? OCKView { - carekitView.customStyle = CustomStyleKey.defaultValue - } - return contactCard - } - - func updateUIViewController(_ uiViewController: UIViewControllerType, context: Context) { - - } -} - -struct DetailedContactView_Previews: PreviewProvider { - - static var previews: some View { - DetailedContactView(contact: OCKContact(id: "", - givenName: "hello", - familyName: "world", - carePlanUUID: nil), - storeManager: .init(wrapping: OCKStore(name: "dummy", type: .inMemory))) - } -} diff --git a/OCKSample/MainTabs/Profile/MyContact/MyContactView.swift b/OCKSample/MainTabs/Profile/MyContact/MyContactView.swift index b3264ac..ee5f935 100644 --- a/OCKSample/MainTabs/Profile/MyContact/MyContactView.swift +++ b/OCKSample/MainTabs/Profile/MyContact/MyContactView.swift @@ -7,27 +7,54 @@ // import SwiftUI +import UIKit +import CareKit +import CareKitStore +import os.log -struct MyContactView: View { - - @StateObject var viewModel = MyContactViewModel() - - var body: some View { - ScrollView { - VStack { - if let storeManager = viewModel.storeManager { - ForEach(viewModel.contacts, id: \.id) { contact in - DetailedContactView(contact: contact, - storeManager: storeManager) - .padding(.horizontal) - // Technically, shouldn't hardcode a size, - // but leave the frame for the assignment. - .frame(width: 350, height: 350) - } - } - Spacer() +/* + You should notice this looks like ContactView but with references + to MyContactViewController instead. +*/ + +struct MyContactView: UIViewControllerRepresentable { + + @ObservedObject var viewModel = MyContactViewModel() + + @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) } + navigationController.setNavigationBarHidden(true, animated: false) + 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? MyContactViewController, + 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.myContact.error("Couldn't unwrap storeManager") + return MyContactViewController(storeManager: .init(wrapping: OCKStore(name: "none_my_contact", + type: .inMemory))) } + return MyContactViewController(storeManager: manager) } } diff --git a/OCKSample/MainTabs/Profile/MyContact/MyContactViewModel.swift b/OCKSample/MainTabs/Profile/MyContact/MyContactViewModel.swift index 47646fe..28e6ece 100644 --- a/OCKSample/MainTabs/Profile/MyContact/MyContactViewModel.swift +++ b/OCKSample/MainTabs/Profile/MyContact/MyContactViewModel.swift @@ -11,64 +11,22 @@ import CareKitStore import os.log import CareKit -class MyContactViewModel: ObservableObject { +/* + You should notice this looks like ContactViewModel. +*/ - @Published var contacts = [OCKAnyContact]() - @Published var storeManager: OCKSynchronizedStoreManager? +class MyContactViewModel: ObservableObject { + @Published var update = false init() { NotificationCenter.default.addObserver(self, selector: #selector(reloadViewModel), name: Notification.Name(rawValue: Constants.storeInitialized), object: nil) - if let storeManager = StoreManagerKey.defaultValue { - self.storeManager = storeManager - Task { - try? await fetchMyContact() - } - } } // MARK: Helpers @objc private func reloadViewModel() { - guard let storeManager = StoreManagerKey.defaultValue else { - return - } - // If these stores don't have the same address in memory - if self.storeManager !== storeManager { - self.storeManager = storeManager - } - Task { - try? await fetchMyContact() - } + update = !update } - - @MainActor - func fetchMyContact() async throws { - guard User.current != nil, - let personUUIDString = UserDefaults.standard.object(forKey: Constants.parseRemoteClockIDKey) as? String, - let storeManager = storeManager else { - Logger.myContact.error("User not logged in") - self.contacts.removeAll() - return - } - - // ToDo to get credit: How would you modify this query to only fetch the - // contact that belongs to this device? - /* - Hint 1: There are multiple ways to do this. You can modify the query - below which can work. - - Hint2: Look at the other queries in the app related to the uuid of the - user who's signed in. - - Hint3: You should have a warning currently, solving this properly would - get rid of the warning without changing the line the warning is on. - */ - var query = OCKContactQuery(for: Date()) - query.sortDescriptors.append(.familyName(ascending: true)) - query.sortDescriptors.append(.givenName(ascending: true)) - - self.contacts = try await storeManager.store.fetchAnyContacts(query: query) - } } diff --git a/OCKSample/MainTabs/Profile/ProfileView.swift b/OCKSample/MainTabs/Profile/ProfileView.swift index b3aa84b..4736c50 100644 --- a/OCKSample/MainTabs/Profile/ProfileView.swift +++ b/OCKSample/MainTabs/Profile/ProfileView.swift @@ -17,9 +17,10 @@ import os.log struct ProfileView: View { @Environment(\.presentationMode) var presentationMode - @Environment(\.userProfileViewModel) var viewModel + // @Environment(\.userProfileViewModel) var viewModel @Environment(\.tintColor) private var tintColor @EnvironmentObject var userStatus: UserStatus + @EnvironmentObject var profileViewModel: ProfileViewModel @State var firstName = "" @State var lastName = "" @State var birthday = Calendar.current.date(byAdding: .year, value: -20, to: Date())! @@ -32,10 +33,11 @@ struct ProfileView: View { NavigationLink(isActive: $showContact, destination: { MyContactView() + .navigationBarTitle("My Contact Card") }) { EmptyView() } - if let image = viewModel.profileImage { + if let image = profileViewModel.profileImage { image .resizable() .aspectRatio(contentMode: .fit) @@ -81,7 +83,7 @@ struct ProfileView: View { Task { do { - try await viewModel.saveProfile(firstName, + try await profileViewModel.saveProfile(firstName, last: lastName, birth: birthday) } catch { @@ -104,7 +106,7 @@ struct ProfileView: View { // a function as argument like we discussed in class) Button(action: { Task { - await viewModel.logout() + await profileViewModel.logout() } }, label: { @@ -128,9 +130,9 @@ struct ProfileView: View { }) } .sheet(isPresented: $showingImagePicker) { - ImagePicker(image: viewModel.$profileUIImage) + ImagePicker(image: $profileViewModel.profileUIImage) } - .onReceive(viewModel.$patient, perform: { patient in + .onReceive(profileViewModel.$patient, perform: { patient in if let currentFirstName = patient?.name.givenName { firstName = currentFirstName } @@ -142,12 +144,12 @@ struct ProfileView: View { if let currentBirthday = patient?.birthday { birthday = currentBirthday } - }).onReceive(viewModel.$isLoggedOut, perform: { value in + }).onReceive(profileViewModel.$isLoggedOut, perform: { value in if self.userStatus.isLoggedOut != value { self.userStatus.check() } }).onAppear(perform: { - viewModel.refreshViewIfNeeded() + profileViewModel.refreshViewIfNeeded() }) } } diff --git a/OCKSample/MainTabs/Profile/ProfileViewModel.swift b/OCKSample/MainTabs/Profile/ProfileViewModel.swift index 8b3658e..91bf857 100644 --- a/OCKSample/MainTabs/Profile/ProfileViewModel.swift +++ b/OCKSample/MainTabs/Profile/ProfileViewModel.swift @@ -33,7 +33,7 @@ class ProfileViewModel: ObservableObject { } @Published public internal(set) var error: Error? @Published var profileImage: Image? - @State var profileUIImage = UIImage(systemName: "person.crop.circle") { + @Published var profileUIImage = UIImage(systemName: "person.crop.circle") { willSet { guard self.profileUIImage != newValue, let inputImage = newValue else { @@ -158,7 +158,8 @@ class ProfileViewModel: ObservableObject { Logger.profile.error("Error: Couldn't find contact with id \"\(uuid)\". It's possible they have never been saved.") return } - self.contact = currentContact + self.observeContact(currentContact) + try? await fetchProfilePicture() } catch { // swiftlint:disable:next line_length @@ -176,6 +177,16 @@ class ProfileViewModel: ObservableObject { .store(in: &cancellables) } + @MainActor + private func observeContact(_ contact: OCKContact) { + + storeManager?.publisher(forContact: contact, categories: [.add, .update, .delete]) + .sink { [weak self] in + self?.contact = $0 as? OCKContact + } + .store(in: &cancellables) + } + private func clearSubscriptions() { cancellables = [] } From 0bc0b7d79ef41340ae760c94370b958d221f9d7a Mon Sep 17 00:00:00 2001 From: Corey Baker Date: Fri, 22 Apr 2022 10:44:34 -0400 Subject: [PATCH 3/8] lint --- ...KSynchronizedStoreManager+Publishers.swift | 1 + .../MyContact/MyContactViewController.swift | 115 ++++++++++++++++++ .../MainTabs/Profile/ProfileViewModel.swift | 2 + 3 files changed, 118 insertions(+) create mode 100644 OCKSample/MainTabs/Profile/MyContact/MyContactViewController.swift diff --git a/OCKSample/Extensions/OCKSynchronizedStoreManager+Publishers.swift b/OCKSample/Extensions/OCKSynchronizedStoreManager+Publishers.swift index 9c81dbf..8a5ef97 100644 --- a/OCKSample/Extensions/OCKSynchronizedStoreManager+Publishers.swift +++ b/OCKSample/Extensions/OCKSynchronizedStoreManager+Publishers.swift @@ -79,6 +79,7 @@ extension OCKSynchronizedStoreManager { .filter { $0.contact.id == contact.id && categories.contains($0.category) } .map { $0.contact } + // swiftlint:disable:next line_length return fetchImmediately ? AnyPublisher(changePublisher.prepend(presentValuePublisher)) : AnyPublisher(changePublisher) } diff --git a/OCKSample/MainTabs/Profile/MyContact/MyContactViewController.swift b/OCKSample/MainTabs/Profile/MyContact/MyContactViewController.swift new file mode 100644 index 0000000..32b4634 --- /dev/null +++ b/OCKSample/MainTabs/Profile/MyContact/MyContactViewController.swift @@ -0,0 +1,115 @@ +// +// MyContactViewController.swift +// OCKSample +// +// Created by Corey Baker on 4/22/22. +// Copyright © 2022 Network Reconnaissance Lab. All rights reserved. +// + +import UIKit +import CareKitStore +import CareKitUI +import CareKit +import Contacts +import ContactsUI +import ParseSwift +import ParseCareKit +import os.log + +class MyContactViewController: OCKListViewController { + + fileprivate weak var contactDelegate: OCKContactViewControllerDelegate? + fileprivate var contacts = [OCKAnyContact]() + + /// 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 { + try? await fetchContacts() + } + } + + override func viewDidAppear(_ animated: Bool) { + Task { + try? await fetchContacts() + } + } + + 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 fetchContacts() async throws { + + guard User.current != nil, + // swiftlint:disable:next line_length + let personUUIDString = UserDefaults.standard.object(forKey: Constants.parseRemoteClockIDKey) as? String else { + Logger.myContact.error("User not logged in") + self.contacts.removeAll() + return + } + + // ToDo to get credit: How would you modify this query to only fetch the + // contact that belongs to this device? + /* + Hint 1: There are multiple ways to do this. You can modify the query + below which can work. + + Hint2: Look at the other queries in the app related to the uuid of the + user who's signed in. + + Hint3: You should have a warning currently, solving this properly would + get rid of the warning without changing the line the warning is on. + */ + var query = OCKContactQuery(for: Date()) + query.sortDescriptors.append(.familyName(ascending: true)) + query.sortDescriptors.append(.givenName(ascending: true)) + + self.contacts = try await storeManager.store.fetchAnyContacts(query: query) + self.displayContacts() + } + + @MainActor + func displayContacts() { + self.clear() + for contact in self.contacts { + let contactViewController = OCKDetailedContactViewController(contact: contact, + storeManager: storeManager) + contactViewController.delegate = self.contactDelegate + self.appendViewController(contactViewController, animated: false) + } + } +} + +extension MyContactViewController: OCKContactViewControllerDelegate { + + // swiftlint:disable:next line_length + func contactViewController(_ viewController: CareKit.OCKContactViewController, didEncounterError error: Error) where C: CareKit.OCKContactController, VS: CareKit.OCKContactViewSynchronizerProtocol { + + Logger.myContact.error("\(error.localizedDescription)") + } + +} diff --git a/OCKSample/MainTabs/Profile/ProfileViewModel.swift b/OCKSample/MainTabs/Profile/ProfileViewModel.swift index 91bf857..a59165c 100644 --- a/OCKSample/MainTabs/Profile/ProfileViewModel.swift +++ b/OCKSample/MainTabs/Profile/ProfileViewModel.swift @@ -16,6 +16,8 @@ import UIKit import os.log import Combine +// swiftlint:disable type_body_length + class ProfileViewModel: ObservableObject { @Published var patient: OCKPatient? From 0b0851fbb6ab27ede0ea1fdab7d5678fce30fbf4 Mon Sep 17 00:00:00 2001 From: Corey Baker Date: Fri, 22 Apr 2022 11:18:05 -0400 Subject: [PATCH 4/8] fix saving profileImage --- OCKSample.xcodeproj/project.pbxproj | 4 +-- OCKSample/MainTabs/Profile/ImagePicker.swift | 33 ++++++++----------- OCKSample/MainTabs/Profile/ProfileView.swift | 6 ++-- .../MainTabs/Profile/ProfileViewModel.swift | 10 ++---- 4 files changed, 22 insertions(+), 31 deletions(-) diff --git a/OCKSample.xcodeproj/project.pbxproj b/OCKSample.xcodeproj/project.pbxproj index 32c0e15..93c65ec 100644 --- a/OCKSample.xcodeproj/project.pbxproj +++ b/OCKSample.xcodeproj/project.pbxproj @@ -377,11 +377,11 @@ 703088772582727500FFABB6 /* MainTabs */ = { isa = PBXGroup; children = ( - 91693819271B64CB00A634ED /* Stylers */, - 70308878258272CD00FFABB6 /* Care */, 7036E4D2256EBE35006E9A3C /* MainView.swift */, + 70308878258272CD00FFABB6 /* Care */, 7030887D258272F400FFABB6 /* Contact */, 7030887C258272E700FFABB6 /* Profile */, + 91693819271B64CB00A634ED /* Stylers */, ); path = MainTabs; sourceTree = ""; diff --git a/OCKSample/MainTabs/Profile/ImagePicker.swift b/OCKSample/MainTabs/Profile/ImagePicker.swift index 371cee9..1054ea8 100644 --- a/OCKSample/MainTabs/Profile/ImagePicker.swift +++ b/OCKSample/MainTabs/Profile/ImagePicker.swift @@ -7,23 +7,23 @@ // // swiftlint:disable:next line_length -// Credit to: https://www.hackingwithswift.com/books/ios-swiftui/importing-an-image-into-swiftui-using-phpickerviewcontroller +// Credit to: https://www.hackingwithswift.com/books/ios-swiftui/importing-an-image-into-swiftui-using-uiimagepickercontroller -import PhotosUI import SwiftUI +import UIKit struct ImagePicker: UIViewControllerRepresentable { + @Environment(\.presentationMode) var presentationMode @Binding var image: UIImage? - func makeUIViewController(context: Context) -> PHPickerViewController { - var config = PHPickerConfiguration() - config.filter = .images - let picker = PHPickerViewController(configuration: config) + func makeUIViewController(context: UIViewControllerRepresentableContext) -> UIImagePickerController { + let picker = UIImagePickerController() picker.delegate = context.coordinator return picker } - func updateUIViewController(_ uiViewController: PHPickerViewController, context: Context) { + func updateUIViewController(_ uiViewController: UIImagePickerController, + context: UIViewControllerRepresentableContext) { } @@ -31,25 +31,20 @@ struct ImagePicker: UIViewControllerRepresentable { Coordinator(self) } - class Coordinator: NSObject, PHPickerViewControllerDelegate { + class Coordinator: NSObject, UINavigationControllerDelegate, UIImagePickerControllerDelegate { let parent: ImagePicker init(_ parent: ImagePicker) { self.parent = parent } - func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { - picker.dismiss(animated: true) - - guard let provider = results.first?.itemProvider else { return } - - if provider.canLoadObject(ofClass: UIImage.self) { - provider.loadObject(ofClass: UIImage.self) { image, _ in - DispatchQueue.main.async { [weak self] in - self?.parent.image = image as? UIImage - } - } + func imagePickerController(_ picker: UIImagePickerController, + didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) { + if let uiImage = info[.originalImage] as? UIImage { + parent.image = uiImage } + + parent.presentationMode.wrappedValue.dismiss() } } } diff --git a/OCKSample/MainTabs/Profile/ProfileView.swift b/OCKSample/MainTabs/Profile/ProfileView.swift index 4736c50..99835ea 100644 --- a/OCKSample/MainTabs/Profile/ProfileView.swift +++ b/OCKSample/MainTabs/Profile/ProfileView.swift @@ -17,7 +17,6 @@ import os.log struct ProfileView: View { @Environment(\.presentationMode) var presentationMode - // @Environment(\.userProfileViewModel) var viewModel @Environment(\.tintColor) private var tintColor @EnvironmentObject var userStatus: UserStatus @EnvironmentObject var profileViewModel: ProfileViewModel @@ -37,8 +36,8 @@ struct ProfileView: View { }) { EmptyView() } - if let image = profileViewModel.profileImage { - image + if let image = profileViewModel.profileUIImage { + Image(uiImage: image) .resizable() .aspectRatio(contentMode: .fit) .frame(width: 100, height: 100, alignment: .center) @@ -158,5 +157,6 @@ struct ProfileView_Previews: PreviewProvider { static var previews: some View { ProfileView() .environmentObject(UserStatus(isLoggedOut: false)) + .environmentObject(ProfileViewModel()) } } diff --git a/OCKSample/MainTabs/Profile/ProfileViewModel.swift b/OCKSample/MainTabs/Profile/ProfileViewModel.swift index a59165c..e7fb832 100644 --- a/OCKSample/MainTabs/Profile/ProfileViewModel.swift +++ b/OCKSample/MainTabs/Profile/ProfileViewModel.swift @@ -34,14 +34,13 @@ class ProfileViewModel: ObservableObject { } } @Published public internal(set) var error: Error? - @Published var profileImage: Image? - @Published var profileUIImage = UIImage(systemName: "person.crop.circle") { + @Published var profileUIImage = UIImage(systemName: "person.fill") { willSet { guard self.profileUIImage != newValue, let inputImage = newValue else { return } - profileImage = Image(uiImage: inputImage) + if !settingProfilePictureForFirstTime { guard var user = User.current?.mergeable, let image = inputImage.jpegData(compressionQuality: 0.25) else { @@ -118,11 +117,8 @@ class ProfileViewModel: ObservableObject { } catch { Logger.profile.error("Couldn't fetch profile picture: \(error.localizedDescription).") } - self.settingProfilePictureForFirstTime = false - - } else { - self.settingProfilePictureForFirstTime = false } + self.settingProfilePictureForFirstTime = false } @MainActor From 10c5f26e57ea72d0b73a626604fcd56058151ff8 Mon Sep 17 00:00:00 2001 From: Corey Baker Date: Fri, 22 Apr 2022 12:22:07 -0400 Subject: [PATCH 5/8] use form for profile view --- .../Contact/CustomContactViewController.swift | 3 +- .../MyContact/MyContactViewController.swift | 3 +- OCKSample/MainTabs/Profile/ProfileView.swift | 164 ++++++++++++------ .../MainTabs/Profile/ProfileViewModel.swift | 96 +++++++++- 4 files changed, 200 insertions(+), 66 deletions(-) diff --git a/OCKSample/MainTabs/Contact/CustomContactViewController.swift b/OCKSample/MainTabs/Contact/CustomContactViewController.swift index 0e8a068..21b370a 100644 --- a/OCKSample/MainTabs/Contact/CustomContactViewController.swift +++ b/OCKSample/MainTabs/Contact/CustomContactViewController.swift @@ -100,8 +100,7 @@ class CustomContactViewController: OCKListViewController { let contacts = try await storeManager.store.fetchAnyContacts(query: query) guard let convertedContacts = contacts as? [OCKContact], - // swiftlint:disable:next line_length - let personUUIDString = UserDefaults.standard.object(forKey: Constants.parseRemoteClockIDKey) as? String else { + let personUUIDString = ProfileViewModel.getRemoteClockUUIDAfterLoginFromLocalStorage()?.uuidString else { return } diff --git a/OCKSample/MainTabs/Profile/MyContact/MyContactViewController.swift b/OCKSample/MainTabs/Profile/MyContact/MyContactViewController.swift index 32b4634..f2dd068 100644 --- a/OCKSample/MainTabs/Profile/MyContact/MyContactViewController.swift +++ b/OCKSample/MainTabs/Profile/MyContact/MyContactViewController.swift @@ -65,8 +65,7 @@ class MyContactViewController: OCKListViewController { func fetchContacts() async throws { guard User.current != nil, - // swiftlint:disable:next line_length - let personUUIDString = UserDefaults.standard.object(forKey: Constants.parseRemoteClockIDKey) as? String else { + let personUUIDString = ProfileViewModel.getRemoteClockUUIDAfterLoginFromLocalStorage()?.uuidString else { Logger.myContact.error("User not logged in") self.contacts.removeAll() return diff --git a/OCKSample/MainTabs/Profile/ProfileView.swift b/OCKSample/MainTabs/Profile/ProfileView.swift index 99835ea..71e24e6 100644 --- a/OCKSample/MainTabs/Profile/ProfileView.swift +++ b/OCKSample/MainTabs/Profile/ProfileView.swift @@ -23,6 +23,13 @@ struct ProfileView: View { @State var firstName = "" @State var lastName = "" @State var birthday = Calendar.current.date(byAdding: .year, value: -20, to: Date())! + @State var note = "" + @State var sex = OCKBiologicalSex.other("unspecified") + @State private var sexOtherField = "" + @State private var street = "" + @State private var city = "" + @State private var state = "" + @State private var zipcode = "" @State var showContact = false @State var showingImagePicker = false @@ -59,65 +66,81 @@ struct ProfileView: View { self.showingImagePicker = true } } - VStack(alignment: .leading) { - TextField("First Name", text: $firstName) - .padding() - .cornerRadius(20.0) - .shadow(radius: 10.0, x: 20, y: 10) - - TextField("Last Name", text: $lastName) - .padding() - .cornerRadius(20.0) - .shadow(radius: 10.0, x: 20, y: 10) - - DatePicker("Birthday", selection: $birthday, displayedComponents: [DatePickerComponents.date]) - .padding() - .cornerRadius(20.0) - .shadow(radius: 10.0, x: 20, y: 10) - } - // Notice that "action" is a closure (which is essentially - // a function as argument like we discussed in class) - Button(action: { - - Task { - do { - try await profileViewModel.saveProfile(firstName, - last: lastName, - birth: birthday) - } catch { - Logger.profile.error("Error saving profile: \(error.localizedDescription)") + VStack { + Form { + Section(header: Text("About")) { + TextField("First Name", text: $firstName) + TextField("Last Name", text: $lastName) + TextField("Note", text: $note) + DatePicker("Birthday", + selection: $birthday, + displayedComponents: [DatePickerComponents.date]) + + Picker(selection: $sex, label: Text("Sex"), content: { + Text(OCKBiologicalSex.female.rawValue).tag(OCKBiologicalSex.female) + Text(OCKBiologicalSex.male.rawValue).tag(OCKBiologicalSex.male) + TextField("Other", text: $sexOtherField).tag(OCKBiologicalSex.other(sexOtherField)) + }) } - } - }, label: { - - Text("Save Profile") - .font(.headline) - .foregroundColor(.white) - .padding() - .frame(width: 300, height: 50) - }) - .background(Color(.green)) - .cornerRadius(15) - - // Notice that "action" is a closure (which is essentially - // a function as argument like we discussed in class) - Button(action: { - Task { - await profileViewModel.logout() + Section(header: Text("Contact")) { + TextField("Street", text: $street) + TextField("City", text: $city) + TextField("State", text: $state) + TextField("Postal code", text: $zipcode) + } } - }, label: { + // Notice that "action" is a closure (which is essentially + // a function as argument like we discussed in class) + Button(action: { + + Task { + do { + try await profileViewModel.saveProfile(firstName, + last: lastName, + birth: birthday, + sex: sex, + note: note) + try await profileViewModel.saveContact(street, + city: city, + state: state, + zipcode: zipcode) + } catch { + Logger.profile.error("Error saving profile: \(error.localizedDescription)") + } + } + + }, label: { + + Text("Save Profile") + .font(.headline) + .foregroundColor(.white) + .padding() + .frame(width: 300, height: 50) + }) + .background(Color(.green)) + .cornerRadius(15) + + // Notice that "action" is a closure (which is essentially + // a function as argument like we discussed in class) + Button(action: { + Task { + await profileViewModel.logout() + } + + }, label: { - Text("Log Out") - .font(.headline) - .foregroundColor(.white) - .padding() - .frame(width: 300, height: 50) - }) - .background(Color(.red)) - .cornerRadius(15) + Text("Log Out") + .font(.headline) + .foregroundColor(.white) + .padding() + .frame(width: 300, height: 50) + }) + .background(Color(.red)) + .cornerRadius(15) + } Spacer() } @@ -132,6 +155,7 @@ struct ProfileView: View { ImagePicker(image: $profileViewModel.profileUIImage) } .onReceive(profileViewModel.$patient, perform: { patient in + // ToDo: Be sure to update this list so changes are reflected in the view. if let currentFirstName = patient?.name.givenName { firstName = currentFirstName } @@ -143,7 +167,39 @@ struct ProfileView: View { if let currentBirthday = patient?.birthday { birthday = currentBirthday } - }).onReceive(profileViewModel.$isLoggedOut, perform: { value in + + if let currentNote = patient?.notes?.first?.content { + note = currentNote + } + + if let currentSex = patient?.sex { + sex = currentSex + } + }) + .onReceive(profileViewModel.$contact, perform: { contact in + // ToDo: Be sure to update this list so changes are reflected in the view. + if let currentStreet = contact?.address?.street { + street = currentStreet + } + if let currentCity = contact?.address?.city { + city = currentCity + } + if let currentState = contact?.address?.state { + state = currentState + } + if let currentZipcode = contact?.address?.postalCode { + zipcode = currentZipcode + } + }) + .alert(isPresented: $profileViewModel.isShowingSaveAlert) { + return Alert(title: Text("Update"), + message: Text("All changs saved successfully!"), + dismissButton: .default(Text("Ok"), action: { + profileViewModel.isShowingSaveAlert = false + self.presentationMode.wrappedValue.dismiss() + })) + } + .onReceive(profileViewModel.$isLoggedOut, perform: { value in if self.userStatus.isLoggedOut != value { self.userStatus.check() } diff --git a/OCKSample/MainTabs/Profile/ProfileViewModel.swift b/OCKSample/MainTabs/Profile/ProfileViewModel.swift index e7fb832..e101a08 100644 --- a/OCKSample/MainTabs/Profile/ProfileViewModel.swift +++ b/OCKSample/MainTabs/Profile/ProfileViewModel.swift @@ -23,6 +23,7 @@ class ProfileViewModel: ObservableObject { @Published var patient: OCKPatient? @Published var contact: OCKContact? @Published var sex: OCKBiologicalSex = .other("unspecified") + @Published var isShowingSaveAlert = false @Published var isLoggedOut = false { willSet { if newValue { @@ -232,8 +233,24 @@ class ProfileViewModel: ObservableObject { } // MARK: User intentions + @MainActor - func saveProfile(_ first: String, last: String, birth: Date) async throws { + func saveProfile(_ first: String, + last: String, + birth: Date, + sex: OCKBiologicalSex, + note: String) async throws { + + /* + ToDo: Be sure to this methods to save changes properly to OCKPatient. + */ + guard let remoteUUID = Self.getRemoteClockUUIDAfterLoginFromLocalStorage()?.uuidString else { + Logger.profile.error("Error: The user currently isn't logged in") + isLoggedOut = true + return + } + + isShowingSaveAlert = true // Make alert pop up for user. if var patientToUpdate = patient { // If there is a currentPatient that was fetched, check to see if any of the fields changed @@ -255,6 +272,17 @@ class ProfileViewModel: ObservableObject { patientToUpdate.birthday = birth } + if patient?.sex != sex { + patientHasBeenUpdated = true + patientToUpdate.sex = sex + } + + let notes = [OCKNote(author: first, title: "my note", content: note)] + if patient?.notes != notes { + patientHasBeenUpdated = true + patientToUpdate.notes = notes + } + if patientHasBeenUpdated { let updated = try await storeManager?.store.updateAnyPatient(patientToUpdate) Logger.profile.info("Successfully updated patient") @@ -265,12 +293,6 @@ class ProfileViewModel: ObservableObject { } } else { - // swiftlint:disable:next line_length - guard let remoteUUID = UserDefaults.standard.object(forKey: Constants.parseRemoteClockIDKey) as? String else { - Logger.profile.error("Error: The user currently isn't logged in") - isLoggedOut = true - return - } var newPatient = OCKPatient(id: remoteUUID, givenName: first, familyName: last) newPatient.birthday = birth @@ -282,10 +304,68 @@ class ProfileViewModel: ObservableObject { return } self.patient = newPatient + } + } + + @MainActor + func saveContact(_ street: String, + city: String, + state: String, + zipcode: String) async throws { + + /* + ToDo: Be sure to this methods to save changes properly to OCKContact. + */ + + guard let remoteUUID = Self.getRemoteClockUUIDAfterLoginFromLocalStorage()?.uuidString else { + Logger.profile.error("Error: The user currently isn't logged in") + isLoggedOut = true + return + } + + if var contactToUpdate = contact { + // If there is a currentContact that was fetched, check to see if any of the fields changed + + var contactHasBeenUpdated = false + + // Since OCKPatient was updated earlier, we should compare against this name + if let patientName = patient?.name, + contact?.name != patient?.name { + contactHasBeenUpdated = true + contactToUpdate.name = patientName + } + + // Create a mutable temp address to compare + let potentialAddress = OCKPostalAddress() + potentialAddress.street = street + potentialAddress.city = city + potentialAddress.state = state + potentialAddress.postalCode = zipcode + + if contact?.address != potentialAddress { + contactHasBeenUpdated = true + contactToUpdate.address = potentialAddress + } + + if contactHasBeenUpdated { + let updated = try await storeManager?.store.updateAnyContact(contactToUpdate) + Logger.profile.info("Successfully updated contact") + guard let updatedContact = updated as? OCKContact else { + return + } + self.contact = updatedContact + } + + } else { + + guard let patientName = self.patient?.name else { + Logger.profile.info("The patient didn't have a name.") + return + } // Added code to create a contact for the respective signed up user let newContact = OCKContact(id: remoteUUID, - name: newPatient.name, + name: patientName, carePlanUUID: nil) // This is new contact that has never been saved before From b05afb49db7dcf0c66dd5114453555d08c55c786 Mon Sep 17 00:00:00 2001 From: Corey Baker Date: Fri, 22 Apr 2022 15:36:55 -0400 Subject: [PATCH 6/8] tie patients to care plans --- ...OCKHealthKitPassthroughStore+Default.swift | 2 +- OCKSample/Extensions/OCKStore+Default.swift | 36 +++++++++++-------- .../MainTabs/Profile/ProfileViewModel.swift | 8 ++--- 3 files changed, 27 insertions(+), 19 deletions(-) diff --git a/OCKSample/Extensions/OCKHealthKitPassthroughStore+Default.swift b/OCKSample/Extensions/OCKHealthKitPassthroughStore+Default.swift index a7585ad..fbf3c2a 100644 --- a/OCKSample/Extensions/OCKHealthKitPassthroughStore+Default.swift +++ b/OCKSample/Extensions/OCKHealthKitPassthroughStore+Default.swift @@ -42,7 +42,7 @@ extension OCKHealthKitPassthroughStore { } } - func populateSampleData() async throws { + func populateSampleData(_ patientUUID: UUID? = nil) async throws { let schedule = OCKSchedule.dailyAtTime( hour: 8, minutes: 0, start: Date(), end: nil, text: nil, diff --git a/OCKSample/Extensions/OCKStore+Default.swift b/OCKSample/Extensions/OCKStore+Default.swift index a693649..0f8b002 100644 --- a/OCKSample/Extensions/OCKStore+Default.swift +++ b/OCKSample/Extensions/OCKStore+Default.swift @@ -16,7 +16,15 @@ import ParseCareKit extension OCKStore { - func addCarePlansIfNotPresent(_ carePlans: [OCKCarePlan]) async throws { + /** + Adds an `OCKCarePlan`*asynchronously* to `OCKStore` if it has not been added already. + + - parameter carePlans: The array of `OCKCarePlan`'s to be added to the `OCKStore`. + - parameter patientUUID: The uuid of the `OCKPatient` to tie to the `OCKCarePlan`. Defaults to nil. + - throws: An error if there was a problem adding the missing `OCKCarePlan`'s. + - note: `OCKCarePlan`'s that have an existing `id` will not be added and will not cause errors to be thrown. + */ + func addCarePlansIfNotPresent(_ carePlans: [OCKCarePlan], patientUUID: UUID? = nil) async throws { let carePlanIdsToAdd = carePlans.compactMap { $0.id } // Prepare query to see if Care Plan are already added @@ -29,7 +37,9 @@ extension OCKStore { // Check results to see if there's a missing Care Plan carePlans.forEach { potentialCarePlan in if foundCarePlans.first(where: { $0.id == potentialCarePlan.id }) == nil { - carePlanNotInStore.append(potentialCarePlan) + var mutableCarePlan = potentialCarePlan + mutableCarePlan.patientUUID = patientUUID + carePlanNotInStore.append(mutableCarePlan) } } @@ -72,7 +82,7 @@ extension OCKStore { } } - func addContactsIfNotPresent(_ contacts: [OCKContact]) async throws { + func addContactsIfNotPresent(_ contacts: [OCKContact], carePlanUUID: UUID? = nil) async throws { let contactIdsToAdd = contacts.compactMap { $0.id } // Prepare query to see if contacts are already added @@ -85,7 +95,9 @@ extension OCKStore { // Check results to see if there's a missing task contacts.forEach { potential in if foundContacts.first(where: { $0.id == potential.id }) == nil { - contactsNotInStore.append(potential) + var mutableContact = potential + mutableContact.carePlanUUID = carePlanUUID + contactsNotInStore.append(mutableContact) } } @@ -100,17 +112,13 @@ extension OCKStore { } } - func populateCarePlans() async throws { - // Get patient from local database if available - guard let uuid = ProfileViewModel.getRemoteClockUUIDAfterLoginFromLocalStorage() else { - throw AppError.remoteClockIDNotAvailable - } - let patient = try await fetchPatient(withID: uuid.uuidString) + func populateCarePlans(patientUUID: UUID? = nil) async throws { let checkInCarePlan = OCKCarePlan(id: CarePlanID.checkIn.rawValue, title: "Check in Care Plan", - patientUUID: patient.uuid) - try await addCarePlansIfNotPresent([checkInCarePlan]) + patientUUID: nil) + + try await addCarePlansIfNotPresent([checkInCarePlan], patientUUID: patientUUID) } @MainActor @@ -134,8 +142,8 @@ extension OCKStore { } // Adds tasks and contacts into the store - func populateSampleData() async throws { - try await populateCarePlans() + func populateSampleData(_ patientUUID: UUID? = nil) async throws { + try await populateCarePlans(patientUUID: patientUUID) let carePlanUUIDs = try await Self.getCarePlanUUIDs() let thisMorning = Calendar.current.startOfDay(for: Date()) diff --git a/OCKSample/MainTabs/Profile/ProfileViewModel.swift b/OCKSample/MainTabs/Profile/ProfileViewModel.swift index e101a08..4176b63 100644 --- a/OCKSample/MainTabs/Profile/ProfileViewModel.swift +++ b/OCKSample/MainTabs/Profile/ProfileViewModel.swift @@ -240,7 +240,7 @@ class ProfileViewModel: ObservableObject { birth: Date, sex: OCKBiologicalSex, note: String) async throws { - + /* ToDo: Be sure to this methods to save changes properly to OCKPatient. */ @@ -316,7 +316,7 @@ class ProfileViewModel: ObservableObject { /* ToDo: Be sure to this methods to save changes properly to OCKContact. */ - + guard let remoteUUID = Self.getRemoteClockUUIDAfterLoginFromLocalStorage()?.uuidString else { Logger.profile.error("Error: The user currently isn't logged in") isLoggedOut = true @@ -411,8 +411,8 @@ class ProfileViewModel: ObservableObject { // This is new contact that has never been saved before _ = try await storeManager.store.addAnyContact(newContact) - try await appDelegate.store?.populateSampleData() - try await appDelegate.healthKitStore.populateSampleData() + try await appDelegate.store?.populateSampleData(patient.uuid) + try await appDelegate.healthKitStore.populateSampleData(patient.uuid) appDelegate.parseRemote.automaticallySynchronizes = true // Post notification to sync From f87e49e51f2a4514fac5a764a404550e1b21bbf7 Mon Sep 17 00:00:00 2001 From: Corey Baker Date: Fri, 22 Apr 2022 16:04:45 -0400 Subject: [PATCH 7/8] Reset to blank values on profile view. --- OCKSample/MainTabs/Profile/ProfileView.swift | 25 ++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/OCKSample/MainTabs/Profile/ProfileView.swift b/OCKSample/MainTabs/Profile/ProfileView.swift index 71e24e6..f681c06 100644 --- a/OCKSample/MainTabs/Profile/ProfileView.swift +++ b/OCKSample/MainTabs/Profile/ProfileView.swift @@ -158,37 +158,62 @@ struct ProfileView: View { // ToDo: Be sure to update this list so changes are reflected in the view. if let currentFirstName = patient?.name.givenName { firstName = currentFirstName + } else { + /* + Else statements are default for when the view resets. For example, + when you logout and then login as a different user. + */ + firstName = "" } if let currentLastName = patient?.name.familyName { lastName = currentLastName + } else { + lastName = "" } if let currentBirthday = patient?.birthday { birthday = currentBirthday + } else { + birthday = Calendar.current.date(byAdding: .year, value: -20, to: Date())! } if let currentNote = patient?.notes?.first?.content { note = currentNote + } else { + note = "" } if let currentSex = patient?.sex { sex = currentSex + } else { + sex = OCKBiologicalSex.other("unspecified") } }) .onReceive(profileViewModel.$contact, perform: { contact in // ToDo: Be sure to update this list so changes are reflected in the view. if let currentStreet = contact?.address?.street { street = currentStreet + } else { + street = "" } + if let currentCity = contact?.address?.city { city = currentCity + } else { + city = "" } + if let currentState = contact?.address?.state { state = currentState + } else { + state = "" } + if let currentZipcode = contact?.address?.postalCode { zipcode = currentZipcode + } else { + zipcode = "" } }) .alert(isPresented: $profileViewModel.isShowingSaveAlert) { From 9fd4d653c8c674b62c9fa222e594a9431720026f Mon Sep 17 00:00:00 2001 From: Corey Baker Date: Fri, 22 Apr 2022 17:52:14 -0400 Subject: [PATCH 8/8] update TODO's --- .../MainTabs/Contact/CustomContactViewController.swift | 2 -- .../Profile/MyContact/MyContactViewController.swift | 4 ++-- OCKSample/MainTabs/Profile/ProfileView.swift | 6 +++--- OCKSample/MainTabs/Profile/ProfileViewModel.swift | 4 ++-- 4 files changed, 7 insertions(+), 9 deletions(-) diff --git a/OCKSample/MainTabs/Contact/CustomContactViewController.swift b/OCKSample/MainTabs/Contact/CustomContactViewController.swift index 21b370a..e4e1d74 100644 --- a/OCKSample/MainTabs/Contact/CustomContactViewController.swift +++ b/OCKSample/MainTabs/Contact/CustomContactViewController.swift @@ -213,8 +213,6 @@ extension CustomContactViewController: CNContactPickerDelegate { if !(self.allContacts.contains { $0.id == contactToAdd.id }) { - // Note - once the functionality is added to edit a contact, - // and let the user potentially edit before the save Task { do { _ = try await storeManager.store.addAnyContact(contactToAdd) diff --git a/OCKSample/MainTabs/Profile/MyContact/MyContactViewController.swift b/OCKSample/MainTabs/Profile/MyContact/MyContactViewController.swift index f2dd068..464c486 100644 --- a/OCKSample/MainTabs/Profile/MyContact/MyContactViewController.swift +++ b/OCKSample/MainTabs/Profile/MyContact/MyContactViewController.swift @@ -71,9 +71,9 @@ class MyContactViewController: OCKListViewController { return } - // ToDo to get credit: How would you modify this query to only fetch the - // contact that belongs to this device? /* + TODO: to get credit: How would you modify this query to only fetch the contact that belongs to this device? + Hint 1: There are multiple ways to do this. You can modify the query below which can work. diff --git a/OCKSample/MainTabs/Profile/ProfileView.swift b/OCKSample/MainTabs/Profile/ProfileView.swift index f681c06..5f1d4f5 100644 --- a/OCKSample/MainTabs/Profile/ProfileView.swift +++ b/OCKSample/MainTabs/Profile/ProfileView.swift @@ -155,7 +155,7 @@ struct ProfileView: View { ImagePicker(image: $profileViewModel.profileUIImage) } .onReceive(profileViewModel.$patient, perform: { patient in - // ToDo: Be sure to update this list so changes are reflected in the view. + // TODO: Be sure to update this list so changes are reflected in the view. if let currentFirstName = patient?.name.givenName { firstName = currentFirstName } else { @@ -191,7 +191,7 @@ struct ProfileView: View { } }) .onReceive(profileViewModel.$contact, perform: { contact in - // ToDo: Be sure to update this list so changes are reflected in the view. + // TODO: Be sure to update this list so changes are reflected in the view. if let currentStreet = contact?.address?.street { street = currentStreet } else { @@ -203,7 +203,7 @@ struct ProfileView: View { } else { city = "" } - + if let currentState = contact?.address?.state { state = currentState } else { diff --git a/OCKSample/MainTabs/Profile/ProfileViewModel.swift b/OCKSample/MainTabs/Profile/ProfileViewModel.swift index 4176b63..f8a42cb 100644 --- a/OCKSample/MainTabs/Profile/ProfileViewModel.swift +++ b/OCKSample/MainTabs/Profile/ProfileViewModel.swift @@ -242,7 +242,7 @@ class ProfileViewModel: ObservableObject { note: String) async throws { /* - ToDo: Be sure to this methods to save changes properly to OCKPatient. + TODO: Be sure to this methods to save changes properly to OCKPatient. */ guard let remoteUUID = Self.getRemoteClockUUIDAfterLoginFromLocalStorage()?.uuidString else { Logger.profile.error("Error: The user currently isn't logged in") @@ -314,7 +314,7 @@ class ProfileViewModel: ObservableObject { zipcode: String) async throws { /* - ToDo: Be sure to this methods to save changes properly to OCKContact. + TODO: Be sure to this methods to save changes properly to OCKContact. */ guard let remoteUUID = Self.getRemoteClockUUIDAfterLoginFromLocalStorage()?.uuidString else {