Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Integrate M-CHAT R/F questionnaire into an updated Schedule view #35

Merged
merged 3 commits into from
Oct 26, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
169 changes: 93 additions & 76 deletions NAMS.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -198,15 +198,6 @@
"version" : "0.4.3"
}
},
{
"identity" : "spezischeduler",
"kind" : "remoteSourceControl",
"location" : "https://github.com/StanfordSpezi/SpeziScheduler.git",
"state" : {
"revision" : "dcba98814d783438b8505e692d0dfb8d90968597",
"version" : "0.6.3"
}
},
{
"identity" : "spezistorage",
"kind" : "remoteSourceControl",
Expand Down
2 changes: 1 addition & 1 deletion NAMS.xcodeproj/xcshareddata/xcschemes/NAMS.xcscheme
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@
<CommandLineArguments>
<CommandLineArgument
argument = "--inject-default-patient"
isEnabled = "NO">
isEnabled = "YES">
</CommandLineArgument>
<CommandLineArgument
argument = "--skipOnboarding"
Expand Down
3 changes: 1 addition & 2 deletions NAMS/Home.swift
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ struct HomeView: View {

var body: some View {
TabView(selection: $selectedTab) {
ScheduleView(presentingAccount: $presentingAccount, activePatientId: $activePatientId, eegModel: eegModel)
ScheduleView2(presentingAccount: $presentingAccount, activePatientId: $activePatientId, eegModel: eegModel)
.tag(Tabs.schedule)
.tabItem {
Label("Schedule", systemImage: "list.clipboard")
Expand Down Expand Up @@ -126,7 +126,6 @@ struct HomeView: View {

return HomeView()
.environmentObject(Account(building: details, active: MockUserIdPasswordAccountService()))
.environmentObject(NAMSScheduler(testSchedule: true))
.environmentObject(MockWebService())
}
#endif
2 changes: 0 additions & 2 deletions NAMS/NAMSAppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ import SpeziFirebaseAccount
import SpeziFirestore
import SpeziMockWebService
import SpeziQuestionnaire
import SpeziScheduler
import SwiftUI


Expand All @@ -39,7 +38,6 @@ class NAMSAppDelegate: SpeziAppDelegate {
}
QuestionnaireDataSource()
MockWebService()
NAMSScheduler()
}
}

Expand Down
43 changes: 16 additions & 27 deletions NAMS/NAMSStandard.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,47 +18,36 @@ import SwiftUI


actor NAMSStandard: Standard, ObservableObject, ObservableObjectProvider, QuestionnaireConstraint, AccountNotifyStandard {
enum TemplateApplicationStandardError: Error {
case userNotAuthenticatedYet
}

private let logger = Logger(subsystem: "TemplateApplication", category: "Standard")

@Dependency var mockWebService = MockWebService()

@AccountReference var account


private var userDocumentReference: DocumentReference {
get async throws {
guard let user = await account.details else {
throw TemplateApplicationStandardError.userNotAuthenticatedYet
}

return Firestore.firestore().collection("users").document(user.accountId)
/// Indicates whether the necessary authorization to deliver local notifications is already granted.
var localNotificationAuthorization: Bool {
get async {
await UNUserNotificationCenter.current().notificationSettings().authorizationStatus == .authorized
}
}

func add(response: ModelsR4.QuestionnaireResponse) async {
let id = response.identifier?.value?.value?.string ?? UUID().uuidString
/// Presents the system authentication UI to send local notifications if the application is not yet permitted to send local notifications.
func requestLocalNotificationAuthorization() async throws {
if await !localNotificationAuthorization {
try await UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound])

guard !FeatureFlags.disableFirebase else {
let jsonRepresentation = (try? String(data: JSONEncoder().encode(response), encoding: .utf8)) ?? ""
try? await mockWebService.upload(path: "questionnaireResponse/\(id)", body: jsonRepresentation)
return
// Triggers an update of the UI in case the notification permissions are changed
await MainActor.run {
self.objectWillChange.send()
}
}
}

do {
try await userDocumentReference
.collection("QuestionnaireResponse") // Add all HealthKit sources in a /QuestionnaireResponse collection.
.document(id) // Set the document identifier to the id of the response.
.setData(from: response)
} catch {
logger.error("Could not store questionnaire response: \(error)")
}
func add(response: ModelsR4.QuestionnaireResponse) async {
// we handle that directly in PatientTiles view.
}

func deletedAccount() async throws {
// delete all user associated data
// delete all care-provider associated data
}
}
10 changes: 5 additions & 5 deletions NAMS/Onboarding/NotificationPermissions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,13 @@
//
// SPDX-License-Identifier: MIT
//
import SpeziFHIR

import SpeziOnboarding
import SpeziScheduler
import SwiftUI


struct NotificationPermissions: View {
@EnvironmentObject private var scheduler: NAMSScheduler
@EnvironmentObject private var standard: NAMSStandard
@EnvironmentObject private var onboardingNavigationPath: OnboardingNavigationPath

@State private var notificationProcessing = false
Expand Down Expand Up @@ -44,9 +43,9 @@ struct NotificationPermissions: View {
notificationProcessing = true
// Notification Authorization are not available in the preview simulator.
if ProcessInfo.processInfo.isPreviewSimulator {
try await _Concurrency.Task.sleep(for: .seconds(5))
try await Task.sleep(for: .seconds(5))
} else {
try await scheduler.requestLocalNotificationAuthorization()
try await standard.requestLocalNotificationAuthorization()
}
} catch {
print("Could not request notification permissions.")
Expand All @@ -72,6 +71,7 @@ struct NotificationPermissions_Previews: PreviewProvider {
onboardingView
}
}
.environmentObject(NAMSStandard())
}
}
#endif
6 changes: 3 additions & 3 deletions NAMS/Onboarding/OnboardingFlow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import SwiftUI

/// Displays an multi-step onboarding flow for the Neurodevelopment Assessment and Monitoring System (NAMS).
struct OnboardingFlow: View {
@EnvironmentObject private var scheduler: NAMSScheduler
@EnvironmentObject private var standard: NAMSStandard

@AppStorage(StorageKeys.onboardingFlowComplete)
private var completedOnboardingFlow = false
Expand All @@ -36,7 +36,7 @@ struct OnboardingFlow: View {
FinishedSetup()
}
.task {
localNotificationAuthorization = await scheduler.localNotificationAuthorization
localNotificationAuthorization = await standard.localNotificationAuthorization
}
.interactiveDismissDisabled(!completedOnboardingFlow)
}
Expand All @@ -48,7 +48,7 @@ struct OnboardingFlow_Previews: PreviewProvider {
static var previews: some View {
OnboardingFlow()
.environmentObject(Account(MockUserIdPasswordAccountService()))
.environmentObject(NAMSScheduler())
.environmentObject(NAMSStandard())
}
}
#endif
13 changes: 10 additions & 3 deletions NAMS/Patients/CurrentPatientLabel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,14 @@ struct CurrentPatientLabel: View {

var body: some View {
HStack {
if let patient = patientList.activePatient, activePatientId != nil {
if activePatientId == nil {
selectPatientText
} else if let patient = patientList.activePatient {
Text(verbatim: patient.name.formatted(.name(style: .medium)))
.fontWeight(.medium)
} else {
Text("Select Patient", comment: "Principal Select Patient Button placeholder")
.italic()
selectPatientText
.redacted(reason: .placeholder)
}

Image(systemName: "chevron.down.circle.fill")
Expand All @@ -33,6 +35,11 @@ struct CurrentPatientLabel: View {
.foregroundColor(.primary)
}

@ViewBuilder private var selectPatientText: some View {
Text("Select Patient", comment: "Principal Select Patient Button placeholder")
.italic()
}


init(activePatient: Binding<String?>) {
self._activePatientId = activePatient
Expand Down
41 changes: 41 additions & 0 deletions NAMS/Patients/Model/PatientListModel+QuestionnaireResponse.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
//
// This source file is part of the Neurodevelopment Assessment and Monitoring System (NAMS) project
//
// SPDX-FileCopyrightText: 2023 Stanford University
//
// SPDX-License-Identifier: MIT
//

import SpeziFHIR
import SpeziFirestore


// SpeziFHIR defines the Observation Model which collides with Apples Observation framework naming

extension PatientListModel {
func add(response: QuestionnaireResponse) async throws {
guard let questionnaireId = response.questionnaire?.value?.url.absoluteString else {
Self.logger.error("Failed to retrieve questionnaire id for response!")
throw QuestionnaireError.unexpectedFormat
}

guard let activePatient,
let patientId = activePatient.id else {
Self.logger.error("Couldn't save questionnaire response \(questionnaireId). No patient found!")
throw QuestionnaireError.missingPatient
}

guard let questionnaire = PatientQuestionnaire.all.first(where: { $0.questionnaire.url?.value?.url.absoluteString == questionnaireId }) else {
Self.logger.error("Failed to match questionnaire response with id \(questionnaireId) to any of our local questionnaires.")
throw QuestionnaireError.failedQuestionnaireMatch
}

do {
try await completedQuestionnairesCollection(patientId: patientId)
.addDocument(from: CompletedQuestionnaire(internalQuestionnaireId: questionnaire.id, questionnaireResponse: response))
} catch {
Self.logger.error("Failed to save questionnaire response for questionnaire \(questionnaireId)!")
throw FirestoreError(error)
}
}
}
86 changes: 70 additions & 16 deletions NAMS/Patients/Model/PatientListModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

import FirebaseFirestore
import FirebaseFirestoreSwift
import Observation
import OrderedCollections
import OSLog
import SpeziAccount
Expand All @@ -23,12 +24,22 @@ class PatientListModel {
static let logger = Logger(subsystem: "edu.stanford.NAMS", category: "PatientListModel")

var patientList: [Patient]? // swiftlint:disable:this discouraged_optional_collection

var activePatient: Patient?

var questionnaires: [CompletedQuestionnaire]? // swiftlint:disable:this discouraged_optional_collection
var completedQuestionnaires: [String]? { // swiftlint:disable:this discouraged_optional_collection
guard let questionnaires else {
return nil
}
return questionnaires.map { $0.internalQuestionnaireId }
}

var categorizedPatients: OrderedDictionary<Character, [Patient]> = [:]

@ObservationIgnored var patientListListener: ListenerRegistration?
@ObservationIgnored var activePatientListener: ListenerRegistration?
@ObservationIgnored private var patientListListener: ListenerRegistration?
@ObservationIgnored private var activePatientListener: ListenerRegistration?
@ObservationIgnored private var activePatientQuestionnairesListener: ListenerRegistration?

private var patientsCollection: CollectionReference {
Firestore.firestore().collection("patients")
Expand All @@ -38,6 +49,13 @@ class PatientListModel {
init() {}


func completedQuestionnairesCollection(patientId: String) -> CollectionReference {
patientsCollection
.document(patientId)
.collection("questionnaireResponse")
}


func retrieveList(viewState: Binding<ViewState>) {
closeList()

Expand Down Expand Up @@ -103,20 +121,6 @@ class PatientListModel {
}
}

func setupTestEnvironment(withPatient patientId: String, viewState: Binding<ViewState>, account: Account) async {
await setupTestAccount(account: account, viewState: viewState)

do {
try await patientsCollection.document(patientId).setData(
from: Patient(name: .init(givenName: "Leland", familyName: "Stanford")),
merge: true
)
} catch {
Self.logger.error("Failed to set test patient information: \(error)")
viewState.wrappedValue = .error(FirestoreError(error))
}
}

func loadActivePatient(for id: String, viewState: Binding<ViewState>) {
if activePatient?.id == id {
return // already set up
Expand All @@ -143,6 +147,37 @@ class PatientListModel {
}
}
}

self.registerPatientCompletedQuestionnaire(patientId: id, viewState: viewState)
}

private func registerPatientCompletedQuestionnaire(patientId: String, viewState: Binding<ViewState>) {
if activePatientQuestionnairesListener != nil {
return
}

self.activePatientQuestionnairesListener = completedQuestionnairesCollection(patientId: patientId)
.addSnapshotListener { snapshot, error in
guard let snapshot else {
Self.logger.error("Failed to retrieve questionnaire responses for active patient: \(error)")
viewState.wrappedValue = .error(FirestoreError(error!)) // swiftlint:disable:this force_unwrapping
return
}

do {
self.questionnaires = try snapshot.documents.map { document in
try document.data(as: CompletedQuestionnaire.self)
}
} catch {
if error is DecodingError {
Self.logger.error("Failed to decode completed questionnaires: \(error)")
viewState.wrappedValue = .error(AnyLocalizedError(error: error))
} else {
Self.logger.error("Unexpected error occurred while decoding completed questionnaires: \(error)")
viewState.wrappedValue = .error(AnyLocalizedError(error: error))
}
}
}
}

func closeList() {
Expand All @@ -157,6 +192,25 @@ class PatientListModel {
activePatientListener.remove()
self.activePatientListener = nil
}

if let activePatientQuestionnairesListener {
activePatientQuestionnairesListener.remove()
self.activePatientQuestionnairesListener = nil
}
}

func setupTestEnvironment(withPatient patientId: String, viewState: Binding<ViewState>, account: Account) async {
await setupTestAccount(account: account, viewState: viewState)

do {
try await patientsCollection.document(patientId).setData(
from: Patient(name: .init(givenName: "Example", familyName: "Patient")),
merge: true
)
} catch {
Self.logger.error("Failed to set test patient information: \(error)")
viewState.wrappedValue = .error(FirestoreError(error))
}
}

private func setupTestAccount(account: Account, viewState: Binding<ViewState>) async {
Expand Down
Loading