Skip to content

Commit

Permalink
Integrate M-CHAT R/F questionnaire into an updated Schedule view (#35)
Browse files Browse the repository at this point in the history
# Integrate M-CHAT R/F questionnaire into an updated Schedule view

## ♻️ Current situation & Problem
The current `ScheduleView` implementation is directly taken from the
`SpeziTemplateApplication`. In our application, we won't need to
schedule repeated Tasks or questionnaires, but instead have a fixed set
of questionnaires to be completed for each patient.

## 💡 Proposed solution
This PR adds initial support for the M-CHAT R/F questionnaire and
restructures the ScheduleView to show a fixed set of tasks depending on
the completion status of a given patient.

## ⚙️ Release Notes 
* A new ScheduleView shows all Task to be completed for a selected
patient.
* Added initial support for the M-CHAT R/F questionnaire.

## ➕ Additional Information

### Related PRs

--
### Testing
New UI Tests were added to cover the added UI components.

### Reviewer Nudging
--
### Code of Conduct & Contributing Guidelines 

By submitting creating this pull request, you agree to follow our [Code
of
Conduct](https://github.com/StanfordBDHG/.github/blob/main/CODE_OF_CONDUCT.md)
and [Contributing
Guidelines](https://github.com/StanfordBDHG/.github/blob/main/CONTRIBUTING.md):
- [x] I agree to follow the [Code of
Conduct](https://github.com/StanfordBDHG/.github/blob/main/CODE_OF_CONDUCT.md)
and [Contributing
Guidelines](https://github.com/StanfordBDHG/.github/blob/main/CONTRIBUTING.md).
  • Loading branch information
Supereg authored Oct 26, 2023
1 parent 67e9271 commit 105f8bd
Show file tree
Hide file tree
Showing 41 changed files with 814 additions and 674 deletions.
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

0 comments on commit 105f8bd

Please sign in to comment.