diff --git a/Prisma.xcodeproj/project.pbxproj b/Prisma.xcodeproj/project.pbxproj index 8e09c88..e5508aa 100644 --- a/Prisma.xcodeproj/project.pbxproj +++ b/Prisma.xcodeproj/project.pbxproj @@ -73,7 +73,6 @@ A9DFE8A92ABE551400428242 /* AccountButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9DFE8A82ABE551400428242 /* AccountButton.swift */; }; A9FE7AD02AA39BAB0077B045 /* AccountSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9FE7ACF2AA39BAB0077B045 /* AccountSheet.swift */; }; AC69903E2B6C5A2F00D92970 /* PrivacyModule.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC69903D2B6C5A2F00D92970 /* PrivacyModule.swift */; }; - AC6990402B6C627100D92970 /* ToggleTestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC69903F2B6C627100D92970 /* ToggleTestView.swift */; }; D8027E912B90655700BB9466 /* ManageDataView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8027E902B90655700BB9466 /* ManageDataView.swift */; }; D8F136C52B85CEED000BA7AE /* DeleteDataView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8F136C42B85CEED000BA7AE /* DeleteDataView.swift */; }; E4C766262B72D50500C1DEDA /* WebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E4C766252B72D50500C1DEDA /* WebView.swift */; }; @@ -156,7 +155,6 @@ A9DFE8A82ABE551400428242 /* AccountButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountButton.swift; sourceTree = ""; }; A9FE7ACF2AA39BAB0077B045 /* AccountSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountSheet.swift; sourceTree = ""; }; AC69903D2B6C5A2F00D92970 /* PrivacyModule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivacyModule.swift; sourceTree = ""; }; - AC69903F2B6C627100D92970 /* ToggleTestView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToggleTestView.swift; sourceTree = ""; }; D8027E902B90655700BB9466 /* ManageDataView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManageDataView.swift; sourceTree = ""; }; D8F136C42B85CEED000BA7AE /* DeleteDataView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeleteDataView.swift; sourceTree = ""; }; E4C766252B72D50500C1DEDA /* WebView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebView.swift; sourceTree = ""; }; @@ -416,7 +414,6 @@ isa = PBXGroup; children = ( AC69903D2B6C5A2F00D92970 /* PrivacyModule.swift */, - AC69903F2B6C627100D92970 /* ToggleTestView.swift */, D8F136C42B85CEED000BA7AE /* DeleteDataView.swift */, D8027E902B90655700BB9466 /* ManageDataView.swift */, ); @@ -704,7 +701,6 @@ A9FE7AD02AA39BAB0077B045 /* AccountSheet.swift in Sources */, D8027E912B90655700BB9466 /* ManageDataView.swift in Sources */, F8AF6FB42B5F6EDC0011C32D /* PrismaModule.swift in Sources */, - AC6990402B6C627100D92970 /* ToggleTestView.swift in Sources */, AC69903E2B6C5A2F00D92970 /* PrivacyModule.swift in Sources */, 653A2551283387FE005D4D48 /* Prisma.swift in Sources */, 2FE5DC3629EDD7CA004B9AB4 /* HealthKitPermissions.swift in Sources */, diff --git a/Prisma.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Prisma.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 43bd650..6cffbde 100644 --- a/Prisma.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Prisma.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,4 +1,5 @@ { + "originHash" : "2744d3b6cb9385cb089a3b7f85f47cd50be0044a6d049ff1f93f530ab4329df2", "pins" : [ { "identity" : "abseil-cpp-binary", @@ -316,5 +317,5 @@ } } ], - "version" : 2 + "version" : 3 } diff --git a/Prisma/PrivacyControls/DeleteDataView.swift b/Prisma/PrivacyControls/DeleteDataView.swift index c971d38..22cf951 100644 --- a/Prisma/PrivacyControls/DeleteDataView.swift +++ b/Prisma/PrivacyControls/DeleteDataView.swift @@ -27,59 +27,117 @@ struct DeleteDataView: View { var categoryIdentifier: String // NEXT STEPS: timeArrayStatic will be replaced by timestampsArray which is read in from firestore using the categoryIdentifier and getPath - @State private var timeArrayStatic = ["2023-11-14T20:39:44.467", "2023-11-14T20:41:00.000", "2023-11-14T20:42:00.000"] + @State private var timeArrayStatic: [String] = [] // var timeArray = getLastTimestamps(quantityType: "stepcount") + @State private var crossedOutTimestamps: [String: Bool] = [:] + @State private var customHideStartDate = Date() + @State private var customHideEndDate = Date() + @State private var customRangeTimestamps: [String] = [] + + // state variable for the category toggle + @State private var isCategoryToggleOn = false var body: some View { - // create a list of all the time stamps for this category - // get rid of spacing once we insert custom time range - VStack(spacing: -400) { - Form { - Section(header: Text("Allow to Read")) { - Toggle(self.privacyModule.identifierUIString[self.categoryIdentifier] ?? "Cannot Find Data Type", isOn: Binding( - get: { - // Return the current value or a default value if the key does not exist - self.privacyModule.togglesMap[self.categoryIdentifier] ?? false - }, - set: { newValue in - // Update the dictionary with the new value - self.privacyModule.togglesMap[self.categoryIdentifier] = newValue - } - )) - } + Form { + descriptionSection + toggleSection + hideByCustomRangeSection + hideByTimeSection + } + .navigationTitle(privacyModule.identifierInfo[categoryIdentifier]?.uiString ?? "Identifier Title Not Found") + .onAppear { + Task { + timeArrayStatic = await standard.fetchTop10RecentTimeStamps(selectedTypeIdentifier: categoryIdentifier) } - NavigationView { - // Toggle corresponding to the proper data to exclude all data of this type - List { - Section(header: Text("Delete by time")) { - ForEach(timeArrayStatic, id: \.self) { timestamp in - Text(timestamp) - } - // on delete, remove it on the UI and set flag in firebase - .onDelete { indices in - let timestampsToDelete = indices.map { timeArrayStatic[$0] } - deleteInBackend(identifier: categoryIdentifier, timestamps: timestampsToDelete) - timeArrayStatic.remove(atOffsets: indices) - } + } + } + + var descriptionSection: some View { + Section(header: Text("About")) { + Text(privacyModule.identifierInfo[categoryIdentifier]?.description ?? "Missing Description.") + } + } + + var toggleSection: some View { + Section(header: Text("Allow Data Upload")) { + Toggle(privacyModule.identifierInfo[categoryIdentifier]?.uiString ?? "Missing UI Type String ", isOn: Binding( + get: { + // get the current enable status for the toggle + // default to a disabled toggle if the value is missing + privacyModule.identifierInfo[categoryIdentifier]?.enabledBool ?? false + }, + set: { newValue in + // Update dict with new toggle status, signal to other views about dict change + privacyModule.updateAndSignalOnChange(identifierString: categoryIdentifier, newToggleVal: newValue) + } + )) + } + } + + var hideByCustomRangeSection: some View { + Section(header: Text("Hide Data by Custom Range")) { + VStack { + DatePicker("Start date", selection: $customHideStartDate, displayedComponents: .date) + DatePicker("End date", selection: $customHideEndDate, displayedComponents: .date) + + Divider() + + Button("Hide") { + let startDateString = formatDate(customHideStartDate) + let endDateString = formatDate(customHideEndDate) + Task { + customRangeTimestamps = await standard.fetchCustomRangeTimeStamps( + selectedTypeIdentifier: categoryIdentifier, + startDate: startDateString, + endDate: endDateString + ) } + switchHiddenInBackend(identifier: categoryIdentifier, timestamps: customRangeTimestamps, alwaysHide: true) } - .padding(.top, -40) - .navigationBarItems(trailing: EditButton()) +// .frame(maxWidth: .infinity) // Make the button take full width } } - .navigationTitle(privacyModule.identifierUIString[categoryIdentifier] ?? "Identifier Title Not Found") } - func deleteInBackend(identifier: String, timestamps: [String]) { + var hideByTimeSection: some View { + Section(header: Text("Hide by Timestamps")) { + timeStampsDisplay + } + } + + var timeStampsDisplay: some View { + ForEach(timeArrayStatic, id: \.self) { timestamp in + HStack { + Image(systemName: crossedOutTimestamps[timestamp, default: false] ? "eye.slash" : "eye") + .accessibilityLabel(crossedOutTimestamps[timestamp, default: false] ? "Hide Timestamp" : "Show Timestamp") + .onTapGesture { + switchHiddenInBackend(identifier: categoryIdentifier, timestamps: [timestamp], alwaysHide: false) + crossedOutTimestamps[timestamp]?.toggle() ?? (crossedOutTimestamps[timestamp] = true) + } + Text(timestamp) + } + .foregroundColor(crossedOutTimestamps[timestamp, default: false] ? .gray : .black) + .opacity(crossedOutTimestamps[timestamp, default: false] ? 0.5 : 1.0) + } + } + + func formatDate(_ date: Date) -> String { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd" + return formatter.string(from: date) + } + + func switchHiddenInBackend(identifier: String, timestamps: [String], alwaysHide: Bool) { for timestamp in timestamps { Task { - await standard.addDeleteFlag(selectedTypeIdentifier: identifier, timestamp: timestamp) + await standard.switchHideFlag(selectedTypeIdentifier: identifier, timestamp: timestamp, alwaysHide: alwaysHide) } } } } - -#Preview { - DeleteDataView(categoryIdentifier: "Example Preview: DeleteDataView") +struct DeleteDataView_Previews: PreviewProvider { + static var previews: some View { + DeleteDataView(categoryIdentifier: "Example Preview: DeleteDataView") + } } diff --git a/Prisma/PrivacyControls/ManageDataView.swift b/Prisma/PrivacyControls/ManageDataView.swift index bdde07f..4d969d3 100644 --- a/Prisma/PrivacyControls/ManageDataView.swift +++ b/Prisma/PrivacyControls/ManageDataView.swift @@ -16,29 +16,40 @@ import SwiftUI struct ManageDataView: View { - @Environment(PrivacyModule.self) private var privacyModule + @EnvironmentObject var privacyModule: PrivacyModule + var body: some View { NavigationView { - List(privacyModule.dataCategoryItems, id: \.name) { item in - NavigationLink(destination: DeleteDataView(categoryIdentifier: item.name)) { - HStack(alignment: .center, spacing: 10) { - Image(systemName: item.iconName) - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 35, height: 35) - .accessibility(label: Text("accessibility text temp")) - VStack(alignment: .leading, spacing: 4) { - Text(privacyModule.identifierUIString[item.name] ?? "Identifier UI String Not Found") - .font(.headline) - Text(item.enabledStatus) - .font(.subheadline) - .foregroundColor(.gray) + List { + ForEach(privacyModule.sortedSampleIdentifiers, id: \.self) { sampleIdentifier in + NavigationLink( + destination: DeleteDataView( + categoryIdentifier: privacyModule.identifierInfo[sampleIdentifier]?.identifier ?? "missing identifier string" + ) + ) { + HStack(alignment: .center, spacing: 10) { + Image(systemName: privacyModule.identifierInfo[sampleIdentifier]?.iconName ?? "missing icon name") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 35, height: 35) + .accessibility(label: Text("accessibility text temp")) + VStack(alignment: .leading, spacing: 4) { + Text(privacyModule.identifierInfo[sampleIdentifier]?.uiString ?? "missing ui identifier string") + .font(.headline) + // assume a default value of false if there is a nil value in enabledBool + Text((privacyModule.identifierInfo[sampleIdentifier]?.enabledBool ?? false) ? "Enabled" : "Disabled") + .font(.subheadline) + .foregroundColor(.gray) + } } } } } .navigationTitle("Manage Data") } + .onReceive(privacyModule.identifierInfoPublisher) { _ in + self.privacyModule.objectWillChange.send() + } } } diff --git a/Prisma/PrivacyControls/PrivacyModule.swift b/Prisma/PrivacyControls/PrivacyModule.swift index bc4ccb6..b179af0 100644 --- a/Prisma/PrivacyControls/PrivacyModule.swift +++ b/Prisma/PrivacyControls/PrivacyModule.swift @@ -17,92 +17,198 @@ // // SPDX-License-Identifier: MIT +import Combine import Foundation import HealthKit import Spezi import SwiftUI -public class PrivacyModule: Module, EnvironmentAccessible { +public class PrivacyModule: Module, EnvironmentAccessible, ObservableObject { + // when there are changes to the identifierInfo dictionary + // (e.g. the user changes the enable/disabled toggle for the category type in DeleteDataView), + // we want to signal the ManageDataView that listens for this signal and refreshes its view with new info public struct DataCategoryItem { - var name: String + var uiString: String var iconName: String - var enabledStatus: String + var enabledBool: Bool + var description: LocalizedStringKey + var identifier: String } - public var iconsMapping: [String: String] = [ - "activeenergyburned": "flame", - "distancewalkingrunning": "figure.walk", - "heartrate": "waveform.path.ecg", - "oxygensaturation": "drop.fill", - "respiratoryrate": "lungs.fill", - "restingheartrate": "arrow.down.heart.fill", - "stepcount": "shoeprints.fill", - "vo2max": "wind", - "walkingheartrateaverage": "figure.step.training" - ] - - public var togglesMap: [String: Bool] = [ - "activeenergyburned": true, - "distancewalkingrunning": true, - "heartrate": true, - "oxygensaturation": true, - "respiratoryrate": true, - "restingheartrate": true, - "stepcount": true, - "vo2max": true, - "walkingheartrateaverage": true - ] + @StandardActor var standard: PrismaStandard - public var identifierUIString: [String: String] = [ - "activeenergyburned": "Active Energy Burned", - "distancewalkingrunning": "Distance Walking Running", - "heartrate": "Heart Rate", - "oxygensaturation": "Oxygen Saturation", - "respiratoryrate": "Respiratory Rate", - "restingheartrate": "Resting Heart Rate", - "stepcount": "Step Count", - "vo2max": "VO2 Max", - "walkingheartrateaverage": "Walking Heart Rate Average" - ] - - var dataCategoryItems: [DataCategoryItem] = [] + var sortedSampleIdentifiers: [String] var sampleTypeList: [HKSampleType] var toggleMapUpdated: [String: Bool] = [:] - @StandardActor var standard: PrismaStandard + // expose the publisher so other views can subscribe to changes in identifierInfo dict + public var identifierInfoPublisher: AnyPublisher { + // expose the publisher without revealing its exact type + // outside code only knows that its dealing with AnyPublisher + identifierInfoSubject.eraseToAnyPublisher() as AnyPublisher + } + + + // create a Combine publisher that sends signal to subscribers each time identifierInfo is changed + private var identifierInfoSubject = PassthroughSubject() + // Dictionary mapping string identifier to all UI necessary information + // If the enabledBool is ever changed for any items in this dict, subscribing views should refresh + @Published public var identifierInfo: [String: DataCategoryItem] = [ + "stepcount": DataCategoryItem( + uiString: "Step Count", + iconName: "shoeprints.fill", + enabledBool: true, + description: "STEP_COUNT_DESCRIPTION", + identifier: "stepcount" + ), + "distancewalkingrunning": DataCategoryItem( + uiString: "Distance Walking Running", + iconName: "figure.walk", + enabledBool: true, + description: "distance walking description", + identifier: "distancewalkingrunning" + ), + "basalenergyburned": DataCategoryItem( + uiString: "Resting Energy Burned", + iconName: "fork.knife.circle", + enabledBool: true, + description: "BASAL_ENERGY_BURNED_DESCRIPTION", + identifier: "basalenergyburned" + ), + "activeenergyburned": DataCategoryItem( + uiString: "Active Energy Burned", + iconName: "flame", + enabledBool: true, + description: "ACTIVE_ENERGY_BURNED_DESCRIPTION", + identifier: "activeenergyburned" + ), + "flightsclimbed": DataCategoryItem( + uiString: "Flights Climbed", + iconName: "figure.stairs", + enabledBool: true, + description: "FLIGHTS_CLIMBED_DESCRIPTION", + identifier: "flightsclimbed" + ), + "appleexercisetime": DataCategoryItem( + uiString: "Exercise Time", + iconName: "figure.run.square.stack", + enabledBool: true, + description: "APPLE_EXERCISE_TIME_DESCRIPTION", + identifier: "appleexercisetime" + ), + "applemovetime": DataCategoryItem( + uiString: "Move Time", + iconName: "figure.cooldown", + enabledBool: true, + description: "APPLE_MOVE_TIME_DESCRIPTION", + identifier: "applemovetime" + ), + "applestandtime": DataCategoryItem( + uiString: "Stand Time", + iconName: "figure.stand", + enabledBool: true, + description: "APPLE_STAND_TIME_DESCRIPTION", + identifier: "applestandtime" + ), + "heartrate": DataCategoryItem( + uiString: "Heart Rate", + iconName: "waveform.path.ecg", + enabledBool: true, + description: "HEART_RATE_DESCRIPTION", + identifier: "heartrate" + ), + "restingheartrate": DataCategoryItem( + uiString: "Resting Heart Rate", + iconName: "arrow.down.heart", + enabledBool: true, + description: "RESTING_HEART_RATE_DESCRIPTION", + identifier: "restingheartrate" + ), + "heartratevariabilitysdnn": DataCategoryItem( + uiString: "Heart Rate Variability", + iconName: "chart.line.uptrend.xyaxis", + enabledBool: true, + description: "HEART_RATE_VARIABILITY_SDNN_DESCRIPTION", + identifier: "heartratevariabilitysdnn" + ), + "walkingheartrateaverage": DataCategoryItem( + uiString: "Walking Heart Rate Average", + iconName: "figure.walk.motion", + enabledBool: true, + description: "WALKING_HEART_RATE_AVERAGE_DESCRIPTION", + identifier: "walkingheartrateaverage" + ), + "oxygensaturation": DataCategoryItem( + uiString: "Oxygen Saturation", + iconName: "drop.degreesign", + enabledBool: true, + description: "OXYGEN_SATURATION_DESCRIPTION", + identifier: "oxygensaturation" + ), + "respiratoryrate": DataCategoryItem( + uiString: "Respiratory Rate", + iconName: "lungs.fill", + enabledBool: true, + description: "RESPIRATORY_RATE_DESCRIPTION", + identifier: "respiratoryrate" + ), + "bodytemperature": DataCategoryItem( + uiString: "Body Temperature", + iconName: "medical.thermometer", + enabledBool: true, + description: "BODY_TEMPERATURE_DESCRIPTION", + identifier: "bodytemperature" + ), + "sleepanalysis": DataCategoryItem( + uiString: "Sleep Analysis", + iconName: "bed.double.fill", + enabledBool: true, + description: "SLEEP_ANALYSIS_DESCRIPTION", + identifier: "sleepanalysis" + ), + "workout": DataCategoryItem( + uiString: "Workout", + iconName: "figure.strengthtraining.functional", + enabledBool: true, + description: "workout description", + identifier: "workout" + ) + ] var configuration: Configuration { Configuration(standard: PrismaStandard()) { } } - + public required init(sampleTypeList: [HKSampleType]) { self.sampleTypeList = sampleTypeList - self.dataCategoryItems = self.getDataCategoryItems() + var sampleTypeIdentifiers: [String] = [] + for sampleType in sampleTypeList { + if sampleType == HKWorkoutType.workoutType() { + sampleTypeIdentifiers.append("workout") + } else { + sampleTypeIdentifiers.append(sampleType.identifier.healthKitDescription) + } + } + sortedSampleIdentifiers = sampleTypeIdentifiers.sorted() + print(sortedSampleIdentifiers) } + // this function is called by DeleteDataView to signal a change each time it changes a bool value + public func updateAndSignalOnChange(identifierString: String, newToggleVal: Bool) { + identifierInfo[identifierString]?.enabledBool = newToggleVal + print("Updated toggle status for \(identifierString) to: \(String(describing: identifierInfo[identifierString]?.enabledBool))") + identifierInfoSubject.send() + print("Change detected in identifierInfo dictionary, signal sent to all subscriber views.") + } + public func configure() { Task { toggleMapUpdated = await getHKSampleTypeMappings() } } - public func getDataCategoryItems() -> [DataCategoryItem] { - // make dictionary into alphabetically sorted array of key-value tuples - let sortedDataCategoryItems = identifierUIString.sorted { $0.key < $1.key } - for dataCategoryPair in sortedDataCategoryItems { - dataCategoryItems.append( - DataCategoryItem( - name: dataCategoryPair.0, - iconName: (iconsMapping[dataCategoryPair.0] ?? "unable to get icon string"), - enabledStatus: (togglesMap[dataCategoryPair.0] ?? true) ? "Enabled" : "Disabled" - ) - ) - } - return dataCategoryItems - } - public func getHKSampleTypeMappings() async -> [String: Bool] { var toggleMapUpdated: [String: Bool] = [:] diff --git a/Prisma/PrivacyControls/ToggleTestView.swift b/Prisma/PrivacyControls/ToggleTestView.swift deleted file mode 100644 index 7088245..0000000 --- a/Prisma/PrivacyControls/ToggleTestView.swift +++ /dev/null @@ -1,39 +0,0 @@ -// -// ToggleTestView.swift -// Prisma -// SPDX-FileCopyrightText: 2023 Stanford University -// -// SPDX-License-Identifier: MIT -// -// Created by Dhruv Naik on 2/1/24. -// -import Foundation -import Spezi -import SwiftUI - - -struct ToggleTestView: View { - // @Bindable var privacyModule = PrivacyModule() - - - var body: some View { - NavigationView { -// Form { -// Toggle("Include Step Count Upload", isOn: $privacyModule.includeStepCountUpload) -// Toggle("Include Active Energy Burned", isOn: $privacyModule.includeActiveEnergyBurned) -// Toggle("Include Distance Walking Running", isOn: $privacyModule.includeDistanceWalkingRunning) -// Toggle("Include Vo2 Max", isOn: $privacyModule.includeVo2Max) -// Toggle("Include Heart Rate", isOn: $privacyModule.includeHeartRate) -// Toggle("Include Resting Heart Rate", isOn: $privacyModule.includeRestingHeartRate) -// Toggle("Include Oxygen Saturation", isOn: $privacyModule.includeOxygenSaturation) -// Toggle("Include Respiratory Rate", isOn: $privacyModule.includeRespiratoryRate) -// Toggle("Include Walking Heart Rate Average", isOn: $privacyModule.includeWalkingHRAverage) -// } -// .navigationBarTitle("Privacy Settings") - } - } -} - -#Preview { - ToggleTestView() -} diff --git a/Prisma/Resources/Localizable.xcstrings b/Prisma/Resources/Localizable.xcstrings index f6e4355..2a53bb5 100644 --- a/Prisma/Resources/Localizable.xcstrings +++ b/Prisma/Resources/Localizable.xcstrings @@ -3,6 +3,9 @@ "strings" : { "" : { + }, + "About" : { + }, "accessibility text temp" : { @@ -59,6 +62,19 @@ } } }, + "ACTIVE_ENERGY_BURNED" : { + "extractionState" : "manual" + }, + "ACTIVE_ENERGY_BURNED_DESCRIPTION" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "This is an estimate of energy burned over and above your Resting Energy use (see Resting Energy). Active energy includes activity such as walking slowly, pushing your wheelchair and household chores, as well as exercise such as biking and dancing. Your total energy use is the sum of your Resting Energy and Active Energy. " + } + } + } + }, "AFTERNOON_SURVEY_DESCRIPTION" : { "localizations" : { "en" : { @@ -79,8 +95,63 @@ } } }, - "Allow to Read" : { + "Allow Data Upload" : { + }, + "APPLE_EXERCISE_TIME_DESCRIPTION" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Every full minute of movement equal to or exceeding the intensity of a brisk walk for you counts towards your Exercise minutes. " + } + } + } + }, + "APPLE_MOVE_TIME_DESCRIPTION" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "A Move minute is measured of how active you are each day. Earning a Move minute requires motion that is more than sedentary activities such as sitting and playing a game or doing arts and crafts. Move minutes are designed for users under the age of 13. They are measured by Apple Watch and are automatically added to Health. " + } + } + } + }, + "APPLE_STAND_TIME_DESCRIPTION" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Stand minutes are the minutes in each hour that you're standing and moving. Looking at your Stand minutes over time can help you understand how active or sedentary you are. Apple Watch automatically tracks and logs Stand minutes in Health. Earning at least one Stand Minute each hour also earned you the hour in your Stand ring. " + } + } + } + }, + "BASAL_ENERGY_BURNED_DESCRIPTION" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "This is an estimate of the energy your body uses each day while minimally active. Additional physical activity requires more energy over and above Resting Energy (see Active Energy)." + } + } + } + }, + "BODY_TEMPERATURE_DESCRIPTION" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Normal body temperature varies throughout the day—it's lower in the morning and higher in the late afternoon and evening. The average normal body temperature is 98.6°F (37°C). What's normal for you may be a degree or more higher or lower than this. Body temperature can be affected by many factors, including heavy exercise, outdoor heat and humidity, and menstrual cycles. Of course, your body temperature may also rise if you have an illness. This is known as a fever. " + } + } + } }, "Chat" : { @@ -156,11 +227,20 @@ "Currently there are no surveys available, you will be notified about upcoming surveys." : { }, - "Delete by time" : { + "Disabled" : { + + }, + "distance walking description" : { }, "EMMA_BRUNSKILL_BIO" : { + }, + "Enabled" : { + + }, + "End date" : { + }, "END_OF_THE_DAY_SURVEY_DESCRIPTION" : { "localizations" : { @@ -279,6 +359,17 @@ } } }, + "FLIGHTS_CLIMBED_DESCRIPTION" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "A flight of stairs is counted as approximately 10 feet (3 meters) of elevation gain (approximately 16 steps). " + } + } + } + }, "HEALTHKIT_PERMISSIONS_BUTTON" : { "localizations" : { "en" : { @@ -320,7 +411,38 @@ } } }, - "Invalid URL" : { + "HEART_RATE_DESCRIPTION" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Your heart beats approximately 100,000 times per day, accelerating and slowing through periods of rest and exertion. Your heart rate refers to how many times your heart beats per minute and can be an indicator of your cardiovascular health. Health visualizes a history of the heart rate data collected by Apple Watch or a heart rate monitor so you can see your patterns and variability over time and with different activities." + } + } + } + }, + "HEART_RATE_VARIABILITY_SDNN_DESCRIPTION" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Heart Rate Variability (HRV) is a measure of the variation in the time interval between heart beats. Apple Watch calculates HRV by using the standard deviation of beat-to-beat measurements which are captured by the heart rate sensor. HRV is validated for users over the age of 18. Thirty party apps and devices can also add HRV to Health. " + } + } + } + }, + "Hide" : { + + }, + "Hide by Timestamps" : { + + }, + "Hide Data by Custom Range" : { + + }, + "Hide Timestamp" : { }, "Invalid URL" : { @@ -374,6 +496,9 @@ } } } + }, + "Missing Description." : { + }, "MOCK_WEB_SERVICE_TAB_TITLE" : { "comment" : "MARK: - Mock Upload Data Storage Provider", @@ -458,6 +583,17 @@ } } }, + "OXYGEN_SATURATION_DESCRIPTION" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Blood oxygen is a measure of the amount of oxygen in the protein (hemoglobin) in your red blood cells. To function properly, your body needs a certain level of oxygen circulating in the blood. Your red blood cells are loaded (saturated) with oxygen in the lungs and carry it throughout your body." + } + } + } + }, "PRIVACY_CONTROLS_TITLE" : { "comment" : "MARK: - Privacy Controls", "extractionState" : "manual", @@ -490,6 +626,20 @@ } } }, + "RESPIRATORY_RATE_DESCRIPTION" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Also commonly called respiration rate or breathing rate, this refers to the number of times you breathe in a minute. When you inhale, your lungs fill with air and oxygen is added to your blood stream while carbon dioxide is removed from your blood. The carbon dioxide is then released from your lungs as you exhale. Your respiratory rate can increase when your body needs more oxygen, such as when you're exercising. It can also decrease when you need less-during sleep for example." + } + } + } + }, + "RESTING_HEART_RATE_DESCRIPTION" : { + "extractionState" : "manual" + }, "SCHEDULE_LIST_TITLE" : { "localizations" : { "en" : { @@ -524,6 +674,34 @@ } } }, + "Show Timestamp" : { + + }, + "SLEEP_ANALYSIS_DESCRIPTION" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sleep provides insight into your sleep habits. Sleep trackers and monitors can help you determine the amount of time you are in bed and asleep. These devices estimate your time in bed and your time asleep by analyzing changes in physical activity, including movement during the night. You can also keep track of your sleep by entering your own estimation of your bed time and sleep time manually. The \"In Bed\" period reflects the time period you are lying in bed with the intention to sleep. For most people it starts when you turn the lights off and it ends when you get out of bed. The \"Asleep\" period reflects the period (s) you are asleep." + } + } + } + }, + "Start date" : { + + }, + "STEP_COUNT_DESCRIPTION" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Step count is the number of steps you take throughout the day. Pedometers and digital activity trackers can help you determine your step count. These devices count steps for any activity that involves step-like movement, including walking, running, stair-climbing, cross-country skiing, and even movement as you go about your daily chores." + } + } + } + }, "TASK_CONTEXT_ACTION_QUESTIONNAIRE" : { "localizations" : { "en" : { @@ -579,6 +757,17 @@ }, "Upcoming notification" : { + }, + "WALKING_HEART_RATE_AVERAGE_DESCRIPTION" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Your walking heart rate is the average heart beats per minute measured by your Apple Watch during walks at a steady pace throughout the day.\nLike resting heart rate, a lower walking heart rate may indicate better heart health and cardiovascular fitness. Walking regularly has many health benefits, and you may see your walking heart rate lower over time by staying active, managing your weight, and reducing everyday stress." + } + } + } }, "WELCOME_BUTTON" : { "localizations" : { @@ -613,6 +802,9 @@ }, "Welcome!" : { + }, + "workout description" : { + } }, "version" : "1.0" diff --git a/Prisma/Standard/PrismaStandard+HealthKit.swift b/Prisma/Standard/PrismaStandard+HealthKit.swift index c1c9b0c..3525f57 100644 --- a/Prisma/Standard/PrismaStandard+HealthKit.swift +++ b/Prisma/Standard/PrismaStandard+HealthKit.swift @@ -47,17 +47,7 @@ extension PrismaStandard { } } - /// Adds a new `HKSample` to the Firestore. - /// - Parameter response: The `HKSample` that should be added. - func add(sample: HKSample) async { - let identifier: String - if let id = getSampleIdentifier(sample: sample) { - identifier = id - } else { - print("Failed to upload HealtHkit sample. Unknown sample type: \(sample)") - return - } - + func writeToFirestore(sample: HKSample, identifier: String) async { // convert the startDate of the HKSample to local time let timeIndex = constructTimeIndex(startDate: sample.startDate, endDate: sample.endDate) let effectiveTimestamp = sample.startDate.toISOFormat() @@ -84,8 +74,11 @@ extension PrismaStandard { let deviceName = sample.sourceRevision.source.name let resource = try sample.resource let encoder = FirebaseFirestore.Firestore.Encoder() + // encoder is used to convert swift types to a format that can be stored in firestore var firestoreResource = try encoder.encode(resource) firestoreResource["device"] = deviceName + // timeIndex is a dictionary with time-related info (range, timezone, datetime.start, datetime.end) + // timeIndex is added a field for this specific HK datapoint so we can just access this part to fetch/sort by time firestoreResource["time"] = timeIndex try await Firestore.firestore().document(path).setData(firestoreResource) } catch { @@ -93,9 +86,53 @@ extension PrismaStandard { } } + /// Adds a new `HKSample` to the Firestore. + /// - Parameter response: The `HKSample` that should be added. + func add(sample: HKSample) async { + let sampleList = [ + // Activity + HKQuantityType(.stepCount), + HKQuantityType(.distanceWalkingRunning), + HKQuantityType(.basalEnergyBurned), + HKQuantityType(.activeEnergyBurned), + HKQuantityType(.flightsClimbed), + HKQuantityType(.appleExerciseTime), + HKQuantityType(.appleMoveTime), + HKQuantityType(.appleStandTime), + + // Vital Signs + HKQuantityType(.heartRate), + HKQuantityType(.restingHeartRate), + HKQuantityType(.heartRateVariabilitySDNN), + HKQuantityType(.walkingHeartRateAverage), + HKQuantityType(.oxygenSaturation), + HKQuantityType(.respiratoryRate), + HKQuantityType(.bodyTemperature), + + // Other events + HKCategoryType(.sleepAnalysis), + HKWorkoutType.workoutType() + ] + let privacyModule = PrivacyModule(sampleTypeList: sampleList) + let toggleMap = await privacyModule.getHKSampleTypeMappings() + + let identifier: String + if let id = getSampleIdentifier(sample: sample) { + identifier = id + } else { + print("Failed to upload HealtHkit sample. Unknown sample type: \(sample)") + return + } + if !(toggleMap[identifier] ?? false) { + return + } + await writeToFirestore(sample: sample, identifier: identifier) + } + func remove(sample: HKDeletedObject) async { } - func addDeleteFlag(selectedTypeIdentifier: String, timestamp: String) async { + func switchHideFlag(selectedTypeIdentifier: String, timestamp: String, alwaysHide: Bool) async { + let firestore = Firestore.firestore() let path: String do { @@ -108,15 +145,87 @@ extension PrismaStandard { return } - // try push to Firestore. do { - // add another key-value pair field for the delete flag - // merge new key-value with pre-existing data instead of overwriting it - let newData = ["deleteFlag": "true"] - try await Firestore.firestore().document(path).setData(newData, merge: true) - print("Successfully set deleteFlag to true.") + let document = firestore.document(path) + let docSnapshot = try await document.getDocument() + + // If hideFlag exists, update its value + if let hideFlagExists = docSnapshot.data()?["hideFlag"] as? Bool { + if alwaysHide { + // If alwaysHide is true, always set hideFlag to true regardless of original value + try await document.setData(["hideFlag": true], merge: true) + print("AlwaysHide is enabled; set hideFlag to true.") + } else { + // Toggle hideFlag if alwaysHide is not true + try await document.setData(["hideFlag": !hideFlagExists], merge: true) + print("Toggled hideFlag to \(!hideFlagExists).") + } + } else { + // If hideFlag does not exist, create it and set to true + try await document.setData(["hideFlag": true], merge: true) + print("hideFlag was missing; set to true.") + } } catch { print("Failed to set data in Firestore: \(error.localizedDescription)") } } + + func fetchTop10RecentTimeStamps(selectedTypeIdentifier: String) async -> [String] { + let firestore = Firestore.firestore() + let path: String + var timestampsArr: [String] = [] + + do { + path = try await getPath(module: .health(selectedTypeIdentifier)) + "raw/" + print("Selected identifier: " + selectedTypeIdentifier) + print("Path from getPath: " + path) + + let querySnapshot = try await firestore.collection(path) + .order(by: "issued", descending: true) + .limit(to: 10) + .getDocuments() + + for document in querySnapshot.documents { + timestampsArr.append(document.documentID) + } + + return timestampsArr + } catch { + print("Failed to fetch documents or define path: \(error.localizedDescription)") + return [] + } + } + + // Fetches timestamp based on documentID date + func fetchCustomRangeTimeStamps(selectedTypeIdentifier: String, startDate: String, endDate: String) async -> [String] { + let firestore = Firestore.firestore() + let path: String + var timestampsArr: [String] = [] + + do { + path = try await getPath(module: .health(selectedTypeIdentifier)) + "raw/" + print("Selected identifier: " + selectedTypeIdentifier) + print("Path from getPath: " + path) + + let querySnapshot = try await firestore.collection(path).getDocuments() + + for document in querySnapshot.documents { + let documentID = document.documentID + let documentDate = String(documentID.prefix(10)) + + // check if documentID date is within the start and end date range + if documentDate >= startDate && documentDate <= endDate { + timestampsArr.append(documentID) + } + } + return timestampsArr + } catch { + if let firestoreError = error as? FirestoreError { + print("Error fetching documents: \(firestoreError.localizedDescription)") + } else { + print("Unexpected error: \(error.localizedDescription)") + } + return [] + } + } } diff --git a/Prisma/Standard/PrismaStandard+TimeIndex.swift b/Prisma/Standard/PrismaStandard+TimeIndex.swift index 14c05bd..f769101 100644 --- a/Prisma/Standard/PrismaStandard+TimeIndex.swift +++ b/Prisma/Standard/PrismaStandard+TimeIndex.swift @@ -12,10 +12,12 @@ import Foundation func constructTimeIndex(startDate: Date, endDate: Date) -> [String: Any?] { let calendar = Calendar.current + // extract the calendar components from the startDate and the endDate let startComponents = calendar.dateComponents([.year, .month, .day, .hour, .minute, .second, .timeZone], from: startDate) let endComponents = calendar.dateComponents([.year, .month, .day, .hour, .minute, .second, .timeZone], from: endDate) let isRange = startDate != endDate + // initialize a dictionary for timeIndex and populate with info extracted above var timeIndex: [String: Any?] = [ "range": isRange, "timezone": startComponents.timeZone?.identifier, @@ -23,6 +25,7 @@ func constructTimeIndex(startDate: Date, endDate: Date) -> [String: Any?] { "datetime.end": endDate.toISOFormat() ] + // passing the timeIndex dictionary by reference so the changes persist addTimeIndexComponents(&timeIndex, dateComponents: startComponents, suffix: ".start") addTimeIndexComponents(&timeIndex, dateComponents: endComponents, suffix: ".end") addTimeIndexRangeComponents(&timeIndex, startComponents: startComponents, endComponents: endComponents) @@ -30,6 +33,8 @@ func constructTimeIndex(startDate: Date, endDate: Date) -> [String: Any?] { return timeIndex } +// populate timeIndex dict with individual components from DateComponents (startComponents for this case) +// "inout" parameter means the argument is passed by reference (dict is modified inside the funct and changes persist) func addTimeIndexComponents(_ timeIndex: inout [String: Any?], dateComponents: DateComponents, suffix: String) { timeIndex["year" + suffix] = dateComponents.year timeIndex["month" + suffix] = dateComponents.month @@ -41,6 +46,8 @@ func addTimeIndexComponents(_ timeIndex: inout [String: Any?], dateComponents: D timeIndex["fifteenMinBucket" + suffix] = calculate15MinBucket(hour: dateComponents.hour, minute: dateComponents.minute) } +// if the start/end time shows that we have a time RANGE and not a time STAMP +// then add the range-related components to the timeIndex func addTimeIndexRangeComponents(_ timeIndex: inout [String: Any?], startComponents: DateComponents, endComponents: DateComponents) { timeIndex["year.range"] = getRange( start: startComponents.year, @@ -80,6 +87,7 @@ func addTimeIndexRangeComponents(_ timeIndex: inout [String: Any?], startCompone } // swiftlint:disable discouraged_optional_collection +// passed the start and end bounds, returns the range in whichever unit passed in func getRange(start: Int?, end: Int?, maxValue: Int?, startValue: Int = 0) -> [Int]? { guard let startInt = start, let endInt = end, let maxValueInt = maxValue else { return nil