diff --git a/LifeSpace.xcodeproj/project.pbxproj b/LifeSpace.xcodeproj/project.pbxproj index 365238a..323c366 100644 --- a/LifeSpace.xcodeproj/project.pbxproj +++ b/LifeSpace.xcodeproj/project.pbxproj @@ -80,6 +80,7 @@ 63F4C39D2BBCCD200033D985 /* LocationUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63F4C39C2BBCCD200033D985 /* LocationUtils.swift */; }; 63F4C39F2BBCCDB70033D985 /* Date+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63F4C39E2BBCCDB70033D985 /* Date+Helpers.swift */; }; 63F4C3A32BBCE79B0033D985 /* LocationPermissions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63F4C3A22BBCE79B0033D985 /* LocationPermissions.swift */; }; + 63FA22C32D2045B600A15017 /* LocationStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63FA22C22D2045B300A15017 /* LocationStorage.swift */; }; 653A2551283387FE005D4D48 /* LifeSpace.swift in Sources */ = {isa = PBXBuildFile; fileRef = 653A2550283387FE005D4D48 /* LifeSpace.swift */; }; 653A255528338800005D4D48 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 653A255428338800005D4D48 /* Assets.xcassets */; }; 653A256228338800005D4D48 /* LifeSpaceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 653A256128338800005D4D48 /* LifeSpaceTests.swift */; }; @@ -169,6 +170,7 @@ 63F4C39C2BBCCD200033D985 /* LocationUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationUtils.swift; sourceTree = ""; }; 63F4C39E2BBCCDB70033D985 /* Date+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Date+Helpers.swift"; sourceTree = ""; }; 63F4C3A22BBCE79B0033D985 /* LocationPermissions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationPermissions.swift; sourceTree = ""; }; + 63FA22C22D2045B300A15017 /* LocationStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationStorage.swift; sourceTree = ""; }; 653A254D283387FE005D4D48 /* LifeSpace.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = LifeSpace.app; sourceTree = BUILT_PRODUCTS_DIR; }; 653A2550283387FE005D4D48 /* LifeSpace.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LifeSpace.swift; sourceTree = ""; }; 653A255428338800005D4D48 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; @@ -338,6 +340,7 @@ 637AA5CF2BBDA686007BD7A3 /* Location */ = { isa = PBXGroup; children = ( + 63FA22C22D2045B300A15017 /* LocationStorage.swift */, 63F4C39A2BBCCCF80033D985 /* LocationModule.swift */, 63F4C39C2BBCCD200033D985 /* LocationUtils.swift */, 63497B6F2BBF6ECE001F8419 /* LocationDataPoint.swift */, @@ -658,6 +661,7 @@ files = ( 2FE5DC4129EDD7EE004B9AB4 /* StorageKeys.swift in Sources */, 2FE5DCB129EE6107004B9AB4 /* AccountOnboarding.swift in Sources */, + 63FA22C32D2045B600A15017 /* LocationStorage.swift in Sources */, 2FE5DC3A29EDD7CA004B9AB4 /* Welcome.swift in Sources */, 634FFF672C169F40005E8217 /* LifeSpaceConsent.swift in Sources */, 634E38482CDE7A7400B16E20 /* LogLevel.swift in Sources */, @@ -820,7 +824,7 @@ CODE_SIGN_ENTITLEMENTS = "LifeSpace/Supporting Files/LifeSpace.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2; + CURRENT_PROJECT_VERSION = 3; DEVELOPMENT_ASSET_PATHS = ""; DEVELOPMENT_TEAM = ""; ENABLE_PREVIEWS = YES; @@ -1022,7 +1026,7 @@ CODE_SIGN_ENTITLEMENTS = "LifeSpace/Supporting Files/LifeSpace.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2; + CURRENT_PROJECT_VERSION = 3; DEVELOPMENT_ASSET_PATHS = ""; DEVELOPMENT_TEAM = ""; ENABLE_PREVIEWS = YES; @@ -1070,7 +1074,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 2; + CURRENT_PROJECT_VERSION = 3; DEVELOPMENT_ASSET_PATHS = ""; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = ""; diff --git a/LifeSpace/Account/AccountSheet.swift b/LifeSpace/Account/AccountSheet.swift index 2aea5d8..4871734 100644 --- a/LifeSpace/Account/AccountSheet.swift +++ b/LifeSpace/Account/AccountSheet.swift @@ -49,8 +49,10 @@ struct AccountSheet: View { @AppStorage(StorageKeys.studyID) var studyID = "unknownStudyID" @AppStorage(StorageKeys.trackingPreference) private var trackingOn = true - @AppStorage(StorageKeys.lastSurveyTransmissionDate) private var lastSurveyTransmissionDate = "Not set" - @AppStorage(StorageKeys.lastLocationTransmissionDate) private var lastLocationTransmissionDate = "Not set" + + @AppStorage(StorageKeys.lastSurveyTransmissionDate) private var lastSurveyTransmissionDate = "None" + @AppStorage(StorageKeys.lastLocationTransmissionDate) private var lastLocationTransmissionDate = "None" + @AppStorage(StorageKeys.lastHealthKitTransmissionDate) private var lastHealthKitTransmissionDate = "None" var body: some View { NavigationStack { @@ -116,6 +118,9 @@ struct AccountSheet: View { Section(header: Text("LAST_LOCATION_TRANSMISSION_SECTION")) { Text(lastLocationTransmissionDate) } + Section(header: Text("LAST_HEALTHKIT_TRANSMISSION_SECTION")) { + Text(lastHealthKitTransmissionDate) + } } } } diff --git a/LifeSpace/Location/LocationModule.swift b/LifeSpace/Location/LocationModule.swift index 2d4cda9..7c3dea0 100644 --- a/LifeSpace/Location/LocationModule.swift +++ b/LifeSpace/Location/LocationModule.swift @@ -19,9 +19,14 @@ public class LocationModule: NSObject, CLLocationManagerDelegate, Module, Defaul @Published var authorizationStatus = CLLocationManager().authorizationStatus @Published var canShowRequestMessage = true - public var allLocations = [CLLocationCoordinate2D]() + private let storage = LocationStorage() public var onLocationsUpdated: (([CLLocationCoordinate2D]) -> Void)? - private var lastSaved: (location: CLLocationCoordinate2D, date: Date)? + + public var allLocations: [CLLocationCoordinate2D] { + get async { + await storage.getAllLocations() + } + } override public required init() { super.init() @@ -64,8 +69,13 @@ public class LocationModule: NSObject, CLLocationManagerDelegate, Module, Defaul public func fetchLocations() async { do { if let locations = try await standard?.fetchLocations() { - self.allLocations = locations - self.onLocationsUpdated?(self.allLocations) + await storage.updateAllLocations(locations) + if let callback = onLocationsUpdated { + let currentLocations = await storage.getAllLocations() + await MainActor.run { + callback(currentLocations) + } + } } } catch { logger.error("Error fetching locations: \(error.localizedDescription)") @@ -79,7 +89,7 @@ public class LocationModule: NSObject, CLLocationManagerDelegate, Module, Defaul let shouldAddLocation = await determineIfShouldAddLocation(coordinate) if shouldAddLocation { - updateLocalLocations(with: coordinate) + await updateLocalLocations(with: coordinate) await saveLocation(coordinate) } } @@ -94,7 +104,7 @@ public class LocationModule: NSObject, CLLocationManagerDelegate, Module, Defaul /// Check if there is a previously saved point, so we can calculate the distance between that and the current point. /// If there's no previously saved point, we can save the current point - guard let lastSaved else { + guard let lastSaved = await storage.getLastSaved() else { return true } @@ -113,10 +123,17 @@ public class LocationModule: NSObject, CLLocationManagerDelegate, Module, Defaul /// Updates the local set of locations and the map with the latest location /// - Parameter coordinate: The `CLLocationCoordinate2D` of the location to be saved. - private func updateLocalLocations(with coordinate: CLLocationCoordinate2D) { - allLocations.append(coordinate) - onLocationsUpdated?(allLocations) - lastSaved = (location: coordinate, date: Date()) + private func updateLocalLocations(with coordinate: CLLocationCoordinate2D) async { + await storage.appendLocation(coordinate) + + if let callback = onLocationsUpdated { + let locations = await storage.getAllLocations() + await MainActor.run { + callback(locations) + } + } + + await storage.updateLastSaved(location: coordinate, date: Date()) } /// Saves a location to Firestore via the Standard. diff --git a/LifeSpace/Location/LocationStorage.swift b/LifeSpace/Location/LocationStorage.swift new file mode 100644 index 0000000..6b4a630 --- /dev/null +++ b/LifeSpace/Location/LocationStorage.swift @@ -0,0 +1,61 @@ +// +// LocationStorage.swift +// LifeSpace +// +// Created by Vishnu Ravi on 12/28/24. +// + +import CoreLocation + +/// An actor that manages storage and retrieval of location coordinates. +/// This class provides thread-safe access to location data and maintains both a collection +/// of locations and information about the last saved location. +actor LocationStorage { + /// Array storing all recorded location coordinates + private var allLocations: [CLLocationCoordinate2D] + + /// Tuple containing the most recently saved location and its timestamp. + /// We use this in `LocationModule` to handle daily location resets. + private var lastSaved: (location: CLLocationCoordinate2D, date: Date)? + + init() { + self.allLocations = [] + } + + /// Adds a new location coordinate to the collection + /// - Parameter coordinate: The location coordinate to append + func appendLocation(_ coordinate: CLLocationCoordinate2D) { + self.allLocations.append(coordinate) + } + + /// Updates the entire collection of locations with a new array + /// - Parameter locations: Array of coordinates to replace the existing locations + func updateAllLocations(_ locations: [CLLocationCoordinate2D]) { + self.allLocations = locations + } + + /// Retrieves all stored location coordinates + /// - Returns: Array of all stored location coordinates + func getAllLocations() -> [CLLocationCoordinate2D] { + allLocations + } + + /// Removes all stored locations from the collection + func clearAllLocations() { + allLocations.removeAll() + } + + /// Retrieves the most recently saved location and its timestamp + /// - Returns: Tuple containing the location coordinate and save date, or nil if no location has been saved + func getLastSaved() -> (location: CLLocationCoordinate2D, date: Date)? { + lastSaved + } + + /// Updates the most recently saved location with a new coordinate and timestamp + /// - Parameters: + /// - location: The location coordinate to save + /// - date: The timestamp of when the location was saved + func updateLastSaved(location: CLLocationCoordinate2D, date: Date) { + self.lastSaved = (location: location, date: date) + } +} diff --git a/LifeSpace/Map/MapboxView.swift b/LifeSpace/Map/MapboxView.swift index 6343a4a..b67ef88 100644 --- a/LifeSpace/Map/MapboxView.swift +++ b/LifeSpace/Map/MapboxView.swift @@ -65,15 +65,23 @@ public class MapManagerView: UIViewController { return } - self.mapView.mapboxMap.onNext(event: .mapLoaded) { _ in - let locations = locationModule.allLocations - do { - try self.addGeoJSONSource(with: locations) - try self.addCircleLayer(sourceId: Constants.geoSourceId) - self.centerCamera(at: locations.last, zoomLevel: Constants.zoomLevel) - self.setupDynamicLocationUpdates() - } catch { - print("[MapboxMap] Error: \(error.localizedDescription)") + self.mapView.mapboxMap.onNext(event: .mapLoaded) { [weak self] _ in + guard let self = self else { + return + } + + Task { + let locations = await locationModule.allLocations + do { + try await MainActor.run { + try self.addGeoJSONSource(with: locations) + try self.addCircleLayer(sourceId: Constants.geoSourceId) + self.centerCamera(at: locations.last, zoomLevel: Constants.zoomLevel) + self.setupDynamicLocationUpdates() + } + } catch { + print("[MapboxMap] Error: \(error.localizedDescription)") + } } } } diff --git a/LifeSpace/Resources/Localizable.xcstrings b/LifeSpace/Resources/Localizable.xcstrings index 1b780ba..e15aef4 100644 --- a/LifeSpace/Resources/Localizable.xcstrings +++ b/LifeSpace/Resources/Localizable.xcstrings @@ -325,12 +325,22 @@ } } }, + "LAST_HEALTHKIT_TRANSMISSION_SECTION" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Last HealthKit Transmission" + } + } + } + }, "LAST_LOCATION_TRANSMISSION_SECTION" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Last Successful Location Data Transmission" + "value" : "Last Location Transmission" } } } @@ -340,7 +350,7 @@ "en" : { "stringUnit" : { "state" : "translated", - "value" : "Last Successful Survey Transmission" + "value" : "Last Survey Transmission" } } } @@ -465,17 +475,6 @@ } } }, - "LOGS" : { - "extractionState" : "stale", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Logs" - } - } - } - }, "LOGS_SHARE_PREVIEW_TITLE" : { }, diff --git a/LifeSpace/SharedContext/StorageKeys.swift b/LifeSpace/SharedContext/StorageKeys.swift index 5bdbe07..4a369e3 100644 --- a/LifeSpace/SharedContext/StorageKeys.swift +++ b/LifeSpace/SharedContext/StorageKeys.swift @@ -26,4 +26,6 @@ enum StorageKeys { static let lastSurveyTransmissionDate = "lastSurveyTransmissionDate" /// `Date`s containing the timestamp of the last successful transmission for location data. static let lastLocationTransmissionDate = "lastLocationTransmissionDate" + /// `Date`s containing the timestamp of the last successful transmission for HealthKit data. + static let lastHealthKitTransmissionDate = "lastHealthKitTransmissionDate" } diff --git a/LifeSpaceStandard.swift b/LifeSpaceStandard.swift index 5c724f7..40a73f5 100644 --- a/LifeSpaceStandard.swift +++ b/LifeSpaceStandard.swift @@ -56,6 +56,9 @@ actor LifeSpaceStandard: Standard, } } + + /// Saves a HealthKit sample to Firestore + /// - Parameter sample: an `HKSample` from HealthKit func add(sample: HKSample) async { guard let userId = Auth.auth().currentUser?.uid else { logger.error("User is not logged in.") @@ -72,6 +75,9 @@ actor LifeSpaceStandard: Standard, dataDict["studyID"] = studyID try await healthKitDocument(id: sample.id).setData(dataDict) + + // Store the timestamp of this transmission for debugging purposes + storeCurrentTimestamp(forKey: StorageKeys.lastHealthKitTransmissionDate) } catch { logger.error("Could not store HealthKit sample: \(error) Sample: \(sample.sampleType)") } @@ -85,6 +91,9 @@ actor LifeSpaceStandard: Standard, } } + + /// Saves a FHIR QuestionnaireResponse to Firestore + /// - Parameter response: A FHIR R4 `QuestionnaireResponse` func add(response: ModelsR4.QuestionnaireResponse) async { let id = response.identifier?.value?.value?.string ?? UUID().uuidString @@ -98,6 +107,9 @@ actor LifeSpaceStandard: Standard, } } + + /// Saves a location data point to Firestore, appending a timestamp, study ID, and user ID. + /// - Parameter location: A `CLLocationCoordinate2D` containing the latitude and longitude of a location. func add(location: CLLocationCoordinate2D) async throws { guard let userId = Auth.auth().currentUser?.uid else { throw LifeSpaceStandardError.userNotAuthenticatedYet @@ -127,9 +139,7 @@ actor LifeSpaceStandard: Standard, .setData(from: dataPoint) // Store a timestamp of this transmission for debugging purposes - let formatter = DateFormatter() - formatter.dateFormat = "yyyy-MM-dd HH:mm:ss" - UserDefaults.standard.set(formatter.string(from: Date.now), forKey: StorageKeys.lastLocationTransmissionDate) + storeCurrentTimestamp(forKey: StorageKeys.lastLocationTransmissionDate) } func fetchLocations(on date: Date = Date()) async throws -> [CLLocationCoordinate2D] { @@ -163,8 +173,9 @@ actor LifeSpaceStandard: Standard, return locations } - - + + /// Saves a LifeSpace daily survey response to Firestore + /// - Parameter response: A `DailySurveyResponse` func add(response: DailySurveyResponse) async throws { guard let userId = Auth.auth().currentUser?.uid else { throw LifeSpaceStandardError.userNotAuthenticatedYet @@ -190,11 +201,12 @@ actor LifeSpaceStandard: Standard, ) // Store a timestamp of this transmission for debugging purposes - let formatter = DateFormatter() - formatter.dateFormat = "yyyy-MM-dd HH:mm:ss" - UserDefaults.standard.set(formatter.string(from: Date.now), forKey: StorageKeys.lastSurveyTransmissionDate) + storeCurrentTimestamp(forKey: StorageKeys.lastSurveyTransmissionDate) } + + /// Gets the date of the latest completed survey from the user document in Firestore, saves it to `UserDefaults` and returns it. + /// - Returns: The latest survey date as a `String` func getLatestSurveyDate() async -> String { let document = try? await configuration.userDocumentReference.getDocument() @@ -207,8 +219,10 @@ actor LifeSpaceStandard: Standard, return "" } } - - + + /// Returns a reference to a given HealthKit document + /// - Parameter uuid: The document's unique identifier as a `UUID`. + /// - Returns: A reference to the document as a `DocumentReference`. private func healthKitDocument(id uuid: UUID) async throws -> DocumentReference { try await configuration.userDocumentReference .collection(Constants.healthKitCollectionName) @@ -225,7 +239,6 @@ actor LifeSpaceStandard: Standard, } /// Stores the given consent form in the user's document directory with a unique timestamped filename. - /// /// - Parameter consent: The consent form's data to be stored as a `PDFDocument`. func store(consent: PDFDocument) async { guard !FeatureFlags.disableFirebase else { @@ -257,7 +270,6 @@ actor LifeSpaceStandard: Standard, } /// Stores the given consent form in the user's document directory and in the consent bucket in Firebase - /// /// - Parameter consentData: The consent form's data to be stored. /// - Parameter name: The name of the consent document. func store(consentData: Data, name: String) async { @@ -284,6 +296,10 @@ actor LifeSpaceStandard: Standard, } } + + /// Check if a consent form with a given name exists in Cloud Storage + /// - Parameter name: A `String` containing the name of the file to check for existence + /// - Returns: A `Bool` representing the existence of the file func isConsentFormUploaded(name: String) async -> Bool { do { let maxSize: Int64 = 10 * 1024 * 1024 @@ -316,4 +332,12 @@ actor LifeSpaceStandard: Standard, logger.error("Unable to set Study ID: \(error)") } } + + /// A helper function to store a current timestamp to `UserDefaults` for a given key. + /// Used to keep track of the last transmission's timestamp for debugging purposes. + func storeCurrentTimestamp(forKey key: String) { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd HH:mm:ss" + UserDefaults.standard.set(formatter.string(from: Date.now), forKey: key) + } }