Skip to content

Commit 13f807c

Browse files
authored
Added symptoms survey, data type, and symptom manager; warnings for severe symptoms (#37)
# Added symptoms survey, data type, and symptom manager; warnings for severe symptoms ## ♻️ Current situation & Problem *Per discussion with the TUM team, an ideal early warning system for febrile neutropenia would also take into account self-reported patient symptoms for signs of impending infection, including nausea, vomiting, diarrhea, chills, cough, and pain. ## ⚙️ Release Notes *Added tab to add symptom information *Added view for symptom checker for nausea, vomiting, diarrhea, chills, cough, and pain. If one of these symptoms is selected, a pain rating scale will appear. *Created new SymptomManager class to add and save symptom entries to SpeziLocalStorage and Firestore *Generate a warning for any symptoms rated between 4-6 for moderate severity and any symptoms rated 7+ for high severity notifying patient to contact their provider ## 📚 Documentation *Please ensure that you properly document any additions in conformance to [Spezi Documentation Guide](https://github.com/StanfordSpezi/.github/blob/main/DOCUMENTATIONGUIDE.md).* *You can use this section to describe your solution, but we encourage contributors to document your reasoning and changes using in-line documentation.* ## ✅ Testing *Added tests to ensure data errors are successfully triggered for invalid types *Added UI tests for addSymptoms view ## 📝 Code of Conduct & Contributing Guidelines By submitting creating this pull request, you agree to follow our [Code of Conduct](https://github.com/CS342/.github/blob/main/CODE_OF_CONDUCT.md) and [Contributing Guidelines](https://github.com/CS342/.github/blob/main/CONTRIBUTING.md): - [x] I agree to follow the [Code of Conduct](https://github.com/CS342/.github/blob/main/CODE_OF_CONDUCT.md) and [Contributing Guidelines](https://github.com/CS342/.github/blob/main/CONTRIBUTING.md).
1 parent 7a63f66 commit 13f807c

File tree

13 files changed

+357
-18
lines changed

13 files changed

+357
-18
lines changed

NeutroFeverGuard.xcodeproj/project.pbxproj

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@
9696
C4C8A3B32D71204200F313AE /* HelperFuncTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4C8A3B22D71204200F313AE /* HelperFuncTests.swift */; };
9797
EB02C6612D5D52E90035AA89 /* NeutroFeverGuardTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 653A256128338800005D4D48 /* NeutroFeverGuardTests.swift */; };
9898
F223937A2D5D4095006C8EB4 /* DataError.swift in Sources */ = {isa = PBXBuildFile; fileRef = F22393792D5D4092006C8EB4 /* DataError.swift */; };
99+
F268C4562D7F928C0020026F /* SymptomManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = F268C4552D7F928B0020026F /* SymptomManager.swift */; };
99100
F279F70C2D780462005C2927 /* FirebaseCore in Frameworks */ = {isa = PBXBuildFile; productRef = F2A54D362D5DE24400A20113 /* FirebaseCore */; };
100101
F279F70D2D780462005C2927 /* FirebaseFirestore in Frameworks */ = {isa = PBXBuildFile; productRef = F2A54D382D5DE24E00A20113 /* FirebaseFirestore */; };
101102
F2E6898F2D52ED8600869C4F /* HealthKitService.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2E6898E2D52ED8500869C4F /* HealthKitService.swift */; };
@@ -178,6 +179,7 @@
178179
C4C8A3672D6F9E1A00F313AE /* LabViewUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LabViewUITests.swift; sourceTree = "<group>"; };
179180
C4C8A3B22D71204200F313AE /* HelperFuncTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HelperFuncTests.swift; sourceTree = "<group>"; };
180181
F22393792D5D4092006C8EB4 /* DataError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataError.swift; sourceTree = "<group>"; };
182+
F268C4552D7F928B0020026F /* SymptomManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SymptomManager.swift; sourceTree = "<group>"; };
181183
F2E6898E2D52ED8500869C4F /* HealthKitService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthKitService.swift; sourceTree = "<group>"; };
182184
/* End PBXFileReference section */
183185

@@ -344,6 +346,7 @@
344346
children = (
345347
B00991CB2D7FD86F00D9CBD5 /* BluetoothRelated */,
346348
B0DF8DFB2D7C114900581A00 /* HealthDataFetchable.swift */,
349+
F268C4552D7F928B0020026F /* SymptomManager.swift */,
347350
B0FF4DD12D797FEA0076A043 /* NotificationManager.swift */,
348351
B0D4C9372D790F5E000643C7 /* LabResultsManager.swift */,
349352
C42C1C6A2D7A985B00EA417F /* MedicationManager.swift */,
@@ -639,6 +642,7 @@
639642
C49EC8532D5038AC005C3495 /* DataInputForm.swift in Sources */,
640643
2FE5DC4129EDD7EE004B9AB4 /* StorageKeys.swift in Sources */,
641644
C42C1B312D782A7600EA417F /* MedicationView.swift in Sources */,
645+
F268C4562D7F928C0020026F /* SymptomManager.swift in Sources */,
642646
2FE5DCB129EE6107004B9AB4 /* AccountOnboarding.swift in Sources */,
643647
2FE5DC3A29EDD7CA004B9AB4 /* Welcome.swift in Sources */,
644648
2FE5DC3829EDD7CA004B9AB4 /* InterestingModules.swift in Sources */,

NeutroFeverGuard/AddDataView.swift

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ struct DataTypeItem: Identifiable {
1616

1717
struct AddDataView: View {
1818
@State private var selectedDataType: DataTypeItem?
19+
@State private var showWarningAlert = false
20+
@State private var warningMessage = ""
1921

2022
@Environment(Account.self) private var account: Account?
2123
@Binding var presentingAccount: Bool
@@ -26,13 +28,16 @@ struct AddDataView: View {
2628
(name: "Oxygen Saturation", emoji: "🫁"),
2729
(name: "Blood Pressure", emoji: "🩸"),
2830
(name: "Lab Results", emoji: "🧪"),
29-
(name: "Medication", emoji: "💊")
31+
(name: "Medication", emoji: "💊"),
32+
(name: "Symptoms", emoji: "😷")
3033
]
3134

3235
let columns = [GridItem(.flexible()), GridItem(.flexible())]
3336

3437
var body: some View {
38+
// swiftlint:disable closure_body_length
3539
NavigationStack {
40+
// swiftlint:disable closure_body_length
3641
ZStack {
3742
Color(.systemBackground).ignoresSafeArea()
3843
VStack {
@@ -60,9 +65,22 @@ struct AddDataView: View {
6065
.padding()
6166
}
6267
.sheet(item: $selectedDataType) { item in
63-
DataInputForm(dataType: item.name)
68+
DataInputForm(dataType: item.name) { warning in
69+
warningMessage = warning
70+
showWarningAlert = true
71+
}
72+
}
73+
.alert("Warning", isPresented: $showWarningAlert) {
74+
Button("Acknowledge") {
75+
showWarningAlert = false
76+
}
77+
} message: {
78+
Text(warningMessage)
6479
}
65-
.toolbar {if account != nil {AccountButton(isPresented: $presentingAccount)}
80+
.toolbar {
81+
if account != nil {
82+
AccountButton(isPresented: $presentingAccount)
83+
}
6684
}
6785
}
6886
}

NeutroFeverGuard/DataError.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ enum DataError: Error, Equatable {
1010
case invalidDate
1111
case invalidPercentage
1212
case invalidBloodPressure
13+
case invalidSeverity
1314

1415
var errorMessage: String {
1516
switch self {
@@ -19,6 +20,8 @@ enum DataError: Error, Equatable {
1920
return "percentage must be between 0 and 100"
2021
case .invalidBloodPressure:
2122
return "blood pressure must be greater than 0"
23+
case .invalidSeverity:
24+
return "Severity must be between 1 and 10"
2225
}
2326
}
2427
}

NeutroFeverGuard/DataInputForm.swift

Lines changed: 139 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
// SPDX-License-Identifier: MIT
77
//
88

9+
// swiftlint:disable file_length
910
import SpeziLocalStorage
1011
import SpeziViews
1112
import SwiftUI
@@ -141,11 +142,59 @@ struct BloodPressureForm: View {
141142
}
142143
}
143144

145+
struct SymptomForm: View {
146+
@Binding var selectedSymptoms: Set<Symptom>
147+
@Binding var symptomSeverity: [Symptom: String]
148+
149+
var body: some View {
150+
VStack(alignment: .leading, spacing: 8) {
151+
Text("Are you experiencing any of:")
152+
.font(.headline)
153+
.padding(.bottom, 4)
154+
155+
ForEach(Symptom.allCases, id: \.self) { symptom in
156+
VStack(alignment: .leading, spacing: 4) {
157+
Toggle(symptom.rawValue, isOn: Binding(
158+
get: { selectedSymptoms.contains(symptom) },
159+
set: { isSelected in
160+
if isSelected {
161+
selectedSymptoms.insert(symptom)
162+
} else {
163+
selectedSymptoms.remove(symptom)
164+
symptomSeverity.removeValue(forKey: symptom)
165+
}
166+
}
167+
))
168+
169+
if selectedSymptoms.contains(symptom) {
170+
HStack {
171+
Text("Rate your \(symptom.rawValue.lowercased()) (1-10):")
172+
TextField("1-10", text: Binding(
173+
get: { symptomSeverity[symptom] ?? "" },
174+
set: { symptomSeverity[symptom] = $0 }
175+
))
176+
.keyboardType(.numberPad)
177+
.textFieldStyle(RoundedBorderTextFieldStyle())
178+
.frame(width: 80)
179+
.accessibilityIdentifier("severity-\(symptom.rawValue)")
180+
}
181+
.padding(.leading, 8)
182+
}
183+
}
184+
.padding(.vertical, 2)
185+
}
186+
}
187+
.padding(8)
188+
}
189+
}
190+
191+
// swiftlint:disable type_body_length
144192
struct DataInputForm: View {
145193
let dataType: String
146194
@Environment(LabResultsManager.self) var labResultsManager
147195
@Environment(MedicationManager.self) private var medicationManager
148196
@Environment(HealthKitService.self) var healthKitService
197+
@Environment(SymptomManager.self) private var symptomManager
149198

150199
@State private var date = Date()
151200
@State private var time = Date()
@@ -160,6 +209,12 @@ struct DataInputForm: View {
160209
@State private var alertMessage: String = ""
161210
@Environment(\.dismiss) var dismiss
162211
@Environment(NeutroFeverGuardScheduler.self) private var scheduler
212+
@State private var selectedSymptoms: Set<Symptom> = []
213+
@State private var symptomSeverity: [Symptom: String] = [:]
214+
@State private var showWarningAlert = false
215+
@State private var warningMessage = ""
216+
217+
var onDismissWithWarning: ((String) -> Void)?
163218

164219
var isFormValid: Bool {
165220
switch dataType {
@@ -177,6 +232,14 @@ struct DataInputForm: View {
177232
}
178233
case "Medication":
179234
return !medicationName.isEmpty && !doseValue.isEmpty
235+
case "Symptoms":
236+
return !selectedSymptoms.isEmpty && selectedSymptoms.allSatisfy { symptom in
237+
guard let severityStr = symptomSeverity[symptom],
238+
let severity = Int(severityStr) else {
239+
return false
240+
}
241+
return severity >= 1 && severity <= 10
242+
}
180243
default:
181244
return false
182245
}
@@ -200,6 +263,8 @@ struct DataInputForm: View {
200263
LabResultsForm(labValues: $labValues)
201264
} else if dataType == "Medication" {
202265
MedicationForm( medicationName: $medicationName, doseValue: $doseValue, doseUnit: $doseUnit)
266+
} else if dataType == "Symptoms" {
267+
SymptomForm(selectedSymptoms: $selectedSymptoms, symptomSeverity: $symptomSeverity)
203268
}
204269
}
205270
.navigationTitle(dataType)
@@ -212,14 +277,24 @@ struct DataInputForm: View {
212277
} catch { print("Error requesting HealthKit authorization: \(error)") }
213278
}.disabled(!isFormValid)
214279
)
215-
.alert(isPresented: .constant(!alertMessage.isEmpty)) {
216-
Alert( title: Text("Error"), message: Text(alertMessage), dismissButton: .default(Text("OK")) { alertMessage = "" })
280+
}
281+
.alert("Error", isPresented: .constant(!alertMessage.isEmpty)) {
282+
Button("OK") { alertMessage = "" }
283+
} message: {
284+
Text(alertMessage)
285+
}
286+
.alert("Warning", isPresented: $showWarningAlert) {
287+
Button("Acknowledge") {
288+
showWarningAlert = false
217289
}
290+
} message: {
291+
Text(warningMessage)
218292
}
219293
}
220294

221-
init(dataType: String) {
295+
init(dataType: String, onDismissWithWarning: ((String) -> Void)? = nil) {
222296
self.dataType = dataType
297+
self.onDismissWithWarning = onDismissWithWarning
223298
}
224299

225300
func addData() async {
@@ -236,6 +311,8 @@ struct DataInputForm: View {
236311
await addLabResult()
237312
case "Medication":
238313
await addMedication()
314+
case "Symptoms":
315+
await addSymptoms()
239316
default:
240317
alertMessage = "Unknown data type"
241318
}
@@ -355,6 +432,65 @@ struct DataInputForm: View {
355432
alertMessage = "Error: \(error)"
356433
}
357434
}
435+
436+
private func generateWarningMessage(from symptoms: [Symptom: Int]) -> String {
437+
var warnings: [String] = []
438+
439+
for (symptom, severity) in symptoms {
440+
if severity >= 7 {
441+
warnings.append("severe \(symptom.rawValue.lowercased())")
442+
} else if severity >= 4 {
443+
warnings.append("moderately severe \(symptom.rawValue.lowercased())")
444+
}
445+
}
446+
447+
if warnings.isEmpty {
448+
return ""
449+
}
450+
451+
let prefix = "You should see your provider for "
452+
453+
if warnings.count == 1 {
454+
return prefix + warnings[0]
455+
} else if warnings.count == 2 {
456+
return prefix + warnings.joined(separator: " and ")
457+
} else {
458+
// For 3 or more items, join all but the last with commas, then add "and" before the last
459+
// swiftlint:disable force_unwrapping
460+
let lastWarning = warnings.last!
461+
let allButLast = warnings.dropLast()
462+
return prefix + allButLast.joined(separator: ", ") + ", and " + lastWarning
463+
}
464+
}
465+
466+
func addSymptoms() async {
467+
var symptoms: [Symptom: Int] = [:]
468+
469+
for symptom in selectedSymptoms {
470+
if let severityStr = symptomSeverity[symptom],
471+
let severity = Int(severityStr) {
472+
symptoms[symptom] = severity
473+
}
474+
}
475+
476+
do {
477+
let symptomEntry = try SymptomEntry(
478+
date: combineDateAndTime(date, time),
479+
symptoms: symptoms
480+
)
481+
symptomManager.addSymptomEntry(symptomEntry)
482+
483+
// Generate warning message if needed
484+
let warning = generateWarningMessage(from: symptoms)
485+
dismiss()
486+
487+
if !warning.isEmpty {
488+
onDismissWithWarning?(warning)
489+
}
490+
} catch {
491+
alertMessage = "Error: \(error)"
492+
}
493+
}
358494
}
359495

360496
#Preview {

NeutroFeverGuard/DataType.swift

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,3 +175,31 @@ struct MedicationEntry: Codable {
175175
self.doseUnit = doseUnit
176176
}
177177
}
178+
179+
enum Symptom: String, CaseIterable, Codable {
180+
case nausea = "Nausea"
181+
case vomiting = "Vomiting"
182+
case diarrhea = "Diarrhea"
183+
case chills = "Chills"
184+
case cough = "Cough"
185+
case pain = "Pain"
186+
}
187+
188+
struct SymptomEntry: Codable {
189+
// periphery:ignore
190+
var date: Date
191+
// periphery:ignore
192+
var symptoms: [Symptom: Int] // Maps symptoms to their severity (1-10)
193+
194+
init(date: Date, symptoms: [Symptom: Int]) throws {
195+
try isValidDate(date)
196+
// Validate that all severity ratings are between 1 and 10
197+
for (_, severity) in symptoms {
198+
guard severity >= 1 && severity <= 10 else {
199+
throw DataError.invalidSeverity
200+
}
201+
}
202+
self.date = date
203+
self.symptoms = symptoms
204+
}
205+
}

NeutroFeverGuard/LabResultsManager.swift

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -96,10 +96,16 @@ class LabResultsManager: Module, EnvironmentAccessible {
9696
try localStorage.store(labRecords, for: LocalStorageKey<[LabEntry]>("labResults"))
9797
// Save to Firestore
9898
if !FeatureFlags.disableFirebase {
99-
try firebaseConfig.userDocumentReference
100-
.collection("LabResults")
101-
.document(UUID().uuidString)
102-
.setData(from: labRecords)
99+
let dateFormatter = DateFormatter()
100+
dateFormatter.dateFormat = "yyyy-MM-dd"
101+
102+
for lab in labRecords {
103+
let dateString = dateFormatter.string(from: lab.date)
104+
try firebaseConfig.userDocumentReference
105+
.collection("LabResults")
106+
.document(dateString)
107+
.setData(from: lab)
108+
}
103109
}
104110
refresh()
105111
} catch {

NeutroFeverGuard/MedicationManager.swift

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -79,12 +79,15 @@ class MedicationManager: Module, EnvironmentAccessible {
7979
}
8080
do {
8181
try localStorage.store(medications, for: LocalStorageKey<[MedicationEntry]>("medications"))
82+
print(medications)
8283
// Save to Firestore
8384
if !FeatureFlags.disableFirebase {
84-
try firebaseConfig.userDocumentReference
85-
.collection("Medications")
86-
.document(UUID().uuidString)
87-
.setData(from: medications)
85+
for medication in medications {
86+
try firebaseConfig.userDocumentReference
87+
.collection("Medications")
88+
.document(medication.name)
89+
.setData(from: medication)
90+
}
8891
}
8992
refresh()
9093
} catch {

NeutroFeverGuard/NeutroFeverGuard.swift

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ import SpeziFirebaseAccount
1111
import SpeziViews
1212
import SwiftUI
1313

14-
1514
@main
1615
struct NeutroFeverGuard: App {
1716
@UIApplicationDelegateAdaptor(NeutroFeverGuardDelegate.self) var appDelegate

NeutroFeverGuard/NeutroFeverGuardDelegate.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ class NeutroFeverGuardDelegate: SpeziAppDelegate {
6060
NotificationManager()
6161
LabResultsManager()
6262
MedicationManager()
63+
SymptomManager()
6364
HealthKitService()
6465
NoMeasurementWarningState()
6566
Bluetooth {

0 commit comments

Comments
 (0)