generated from StanfordSpezi/SpeziTemplateApplication
-
-
Notifications
You must be signed in to change notification settings - Fork 1
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
# Build notification system ## ♻️ Current situation & Problem Currently, the home view is empty. Moving forward, the UI for the Home Screen will include notifications, most recent vitals recorded, and a button to start the biweekly survey. To begin, we implemented a notification system that pulls notifications linked to the user's document in Firebase cloud storage, then displays them at the top of the dashboard in the Home Screen. ## ⚙️ Release Notes - Restructured the Home Screen dashboard as a sectioned list including Notifications - Implemented a NotificationManager class that attaches a snapshot listener to a query on the currently signed in user's notification collection in Firestore - The query retrieves notifications that are younger than 10 days old, and the snapshot listener in the NotificationManager then filters for notifications that haven't been completed yet. - The NotificationManager is an environment accessible module that is configured in the ENGAGEDelegate. ## 📚 Documentation See inline documentation in NotificationManager.swift and Notification.swift. ## ✅ Testing Thorough UI testing ensures that notifications are properly displayed when they are added to the user's backend Firestore, and that UI components such as the "mark complete" button and the "show more" / "show less" buttons work as intended. ### 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). --------- Co-authored-by: Paul Schmiedmayer <[email protected]>
- Loading branch information
1 parent
adf5392
commit c0a95d5
Showing
26 changed files
with
979 additions
and
200 deletions.
There are no files selected for viewing
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,25 @@ | ||
// | ||
// This source file is part of the ENGAGE-HF project based on the Stanford Spezi Template Application project | ||
// | ||
// SPDX-FileCopyrightText: 2023 Stanford University | ||
// | ||
// SPDX-License-Identifier: MIT | ||
// | ||
|
||
import Foundation | ||
|
||
|
||
enum FetchingError: LocalizedError { | ||
case invalidTimestamp | ||
case userNotAuthenticated | ||
|
||
|
||
var errorDescription: String? { | ||
switch self { | ||
case .invalidTimestamp: | ||
String(localized: "Unable to get notification timestamp.", comment: "Invalid Timestamp") | ||
case .userNotAuthenticated: | ||
String(localized: "User not authenticated. Please sign in an try again.", comment: "User Not Authenticated") | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,27 @@ | ||
// | ||
// This source file is part of the ENGAGE-HF project based on the Stanford Spezi Template Application project | ||
// | ||
// SPDX-FileCopyrightText: 2023 Stanford University | ||
// | ||
// SPDX-License-Identifier: MIT | ||
// | ||
|
||
import FirebaseFirestore | ||
import Foundation | ||
|
||
|
||
// A notification | ||
// | ||
// Mirrors the representation of a notification in firestore | ||
// When assigned to a patient, the title will be displayed | ||
// and the description will be displayed in a drop-down field | ||
// | ||
// Title and Descprition may be markdown text | ||
struct Notification: Identifiable, Equatable, Codable { | ||
@DocumentID var id: String? | ||
var type: String | ||
var title: String | ||
var description: String | ||
var created: Timestamp | ||
var completed: Bool | ||
} |
200 changes: 200 additions & 0 deletions
200
ENGAGEHF/Dashboard/Notifications/NotificationManager.swift
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,200 @@ | ||
// | ||
// This source file is part of the ENGAGE-HF project based on the Stanford Spezi Template Application project | ||
// | ||
// SPDX-FileCopyrightText: 2023 Stanford University | ||
// | ||
// SPDX-License-Identifier: MIT | ||
// | ||
|
||
import FirebaseAuth | ||
import FirebaseFirestore | ||
import Foundation | ||
import OSLog | ||
import Spezi | ||
import SpeziFirebaseConfiguration | ||
|
||
|
||
// Notification manager | ||
// | ||
// Maintains a list of Notifications associated with the current user in firebase | ||
// On configuration of the app, adds a snapshot listener to the user's notification collection | ||
// | ||
@Observable | ||
class NotificationManager: Module, EnvironmentAccessible { | ||
@ObservationIgnored @Dependency private var configureFirebaseApp: ConfigureFirebaseApp | ||
@ObservationIgnored @StandardActor var standard: ENGAGEHFStandard | ||
|
||
private var authStateDidChangeListenerHandle: AuthStateDidChangeListenerHandle? | ||
private var snapshotListener: ListenerRegistration? | ||
|
||
private let logger = Logger(subsystem: "ENGAGEHF", category: "NotificationManager") | ||
|
||
private let expirationDate = 10 | ||
|
||
var notifications: [Notification] = [] | ||
|
||
|
||
func configure() { | ||
if ProcessInfo.processInfo.isPreviewSimulator { | ||
let dummyNotification = Notification( | ||
id: String(describing: UUID()), | ||
type: "Mock Notification", | ||
title: "Weight Recorded", | ||
description: "A weight measurement has been recorded.", | ||
created: Timestamp(date: .now), | ||
completed: false | ||
) | ||
notifications.append(dummyNotification) | ||
return | ||
} | ||
|
||
authStateDidChangeListenerHandle = Auth.auth().addStateDidChangeListener { [weak self] _, user in | ||
self?.registerSnapshotListener(user: user) | ||
|
||
// If testing, add 3 notifications to firestore | ||
// Called when a user's sign in status changes | ||
if FeatureFlags.setupTestEnvironment, let user { | ||
// Make sure to not load the mock notifications multiple times | ||
if let notifications = self?.notifications, notifications.isEmpty { | ||
Task { | ||
try await self?.setupNotificationTests(user: user) | ||
} | ||
} | ||
} | ||
} | ||
self.registerSnapshotListener(user: Auth.auth().currentUser) | ||
} | ||
|
||
|
||
// Adds three mock notifications to the user's notification collection in firestore | ||
func setupNotificationTests(user: User) async throws { | ||
let firestore = Firestore.firestore() | ||
let notificationsCollection = firestore.collection("users").document(user.uid).collection("notifications") | ||
|
||
let querySnapshot = try await notificationsCollection.getDocuments() | ||
|
||
guard querySnapshot.documents.isEmpty else { | ||
// Notifications collections exists and is not empty, so skip | ||
self.logger.debug("Notifications already exist, skipping user.") | ||
return | ||
} | ||
|
||
self.logger.debug("Adding test notifications for user \(user.uid)") | ||
|
||
for idx in 1...3 { | ||
let newNotification = Notification( | ||
type: "Mock Notification \(idx)", | ||
title: "This is a mock notification.", | ||
description: "This is a long string that should be truncated by the expandable text class.", | ||
created: Timestamp(date: .now), | ||
completed: false | ||
) | ||
|
||
do { | ||
try notificationsCollection.addDocument(from: newNotification) | ||
} catch { | ||
self.logger.error("Unable to load notifications to firestore: \(error)") | ||
} | ||
} | ||
} | ||
|
||
|
||
// Call on initialization | ||
// | ||
// Creates a snapshot listener to save new notifications to the manager | ||
// as they are added to the user's directory in Firebase | ||
func registerSnapshotListener(user: User?) { | ||
logger.info("Initializing notifiation snapshot listener...") | ||
|
||
// Remove previous snapshot listener for the user before creating new one | ||
snapshotListener?.remove() | ||
guard let uid = user?.uid else { | ||
return | ||
} | ||
|
||
let firestore = Firestore.firestore() | ||
|
||
// Ignore notifications older than expirationDate | ||
guard let thresholdDate = Calendar.current.date(byAdding: .day, value: -expirationDate, to: .now) else { | ||
logger.error("Unable to get threshold date: \(FetchingError.invalidTimestamp)") | ||
return | ||
} | ||
|
||
let thesholdTimeStamp = Timestamp(date: thresholdDate) | ||
|
||
// Set a snapshot listener on the query for valid notifications | ||
firestore | ||
.collection("users") | ||
.document(uid) | ||
.collection("notifications") | ||
.whereField("created", isGreaterThan: thesholdTimeStamp) | ||
.whereField("completed", isEqualTo: false) | ||
.addSnapshotListener { querySnapshot, error in | ||
guard let documentRefs = querySnapshot?.documents else { | ||
self.logger.error("Error fetching documents: \(error)") | ||
return | ||
} | ||
|
||
self.notifications = documentRefs.compactMap { | ||
do { | ||
return try $0.data(as: Notification.self) | ||
} catch { | ||
self.logger.error("Error decoding notifications: \(error)") | ||
return nil | ||
} | ||
} | ||
|
||
self.logger.debug("Notifications updated") | ||
} | ||
} | ||
|
||
func markComplete(id: String) async { | ||
if ProcessInfo.processInfo.isPreviewSimulator { | ||
notifications.removeAll { $0.id == id } | ||
return | ||
} | ||
|
||
logger.debug("Marking notitification complete with the following id: \(id)") | ||
|
||
let firestore = Firestore.firestore() | ||
|
||
guard let user = Auth.auth().currentUser else { | ||
logger.error("Unable to mark notitificaitons complete: \(FetchingError.userNotAuthenticated)") | ||
return | ||
} | ||
|
||
// Mark the notifications as completed in the Firestore | ||
let timestamp = Timestamp(date: .now) | ||
|
||
let docRef = firestore.collection("users") | ||
.document(user.uid) | ||
.collection("notifications") | ||
.document(id) | ||
|
||
do { | ||
try await docRef.updateData([ | ||
"completed": timestamp | ||
]) | ||
} catch { | ||
logger.error("Unable to update notification \(id): \(error)") | ||
} | ||
|
||
logger.debug("Successfully marked notifications complete!") | ||
} | ||
} | ||
|
||
|
||
extension NotificationManager { | ||
// Function for adding a mock notification for the preview simulator | ||
func addMock() { | ||
let dummyNotification = Notification( | ||
id: String(describing: UUID()), | ||
type: "Medication Change", | ||
title: "Your dose of XXX was changed.", | ||
description: "Your dose of XXX was changed. You can review medication information in the Education Page.", | ||
created: Timestamp(date: .now), | ||
completed: false | ||
) | ||
notifications.append(dummyNotification) | ||
} | ||
} |
Oops, something went wrong.