Skip to content

Commit

Permalink
UI/notifications (#10)
Browse files Browse the repository at this point in the history
# 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
nriedman and PSchmiedmayer authored Jun 15, 2024
1 parent adf5392 commit c0a95d5
Show file tree
Hide file tree
Showing 26 changed files with 979 additions and 200 deletions.
116 changes: 90 additions & 26 deletions ENGAGEHF.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@
{
"identity" : "fhirmodels",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/FHIRModels",
"location" : "https://github.com/apple/FHIRModels.git",
"state" : {
"revision" : "861afd5816a98d38f86220eab2f812d76cad84a0",
"version" : "0.5.0"
Expand Down
8 changes: 4 additions & 4 deletions ENGAGEHF.xcodeproj/xcshareddata/xcschemes/ENGAGEHF.xcscheme
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1520"
LastUpgradeVersion = "1530"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
Expand Down Expand Up @@ -91,10 +91,10 @@
</CommandLineArgument>
<CommandLineArgument
argument = "--testMockDevices"
isEnabled = "YES">
isEnabled = "NO">
</CommandLineArgument>
<CommandLineArgument
argument = "--showOnboarding"
argument = "--skipOnboarding"
isEnabled = "NO">
</CommandLineArgument>
<CommandLineArgument
Expand All @@ -107,7 +107,7 @@
</CommandLineArgument>
<CommandLineArgument
argument = "--useFirebaseEmulator"
isEnabled = "NO">
isEnabled = "YES">
</CommandLineArgument>
</CommandLineArguments>
</LaunchAction>
Expand Down
40 changes: 22 additions & 18 deletions ENGAGEHF/Dashboard/Dashboard.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,35 +11,38 @@ import SwiftUI

struct Dashboard: View {
@Binding var presentingAccount: Bool

@State var showSurvey = false

#if DEBUG || TEST
@Environment(MeasurementManager.self) private var measurementManager
#endif


var body: some View {
NavigationStack {
VStack {
Greeting()
Spacer()
List {
NotificationSection()
}
.navigationTitle("Home")
.toolbar {
if AccountButton.shouldDisplay {
AccountButton(isPresented: $presentingAccount)
}
.studyApplicationList()
.navigationTitle("Home")
.toolbar {
if AccountButton.shouldDisplay {
AccountButton(isPresented: $presentingAccount)
}
}
#if DEBUG || TEST
.toolbar {
if FeatureFlags.testMockDevices {
ToolbarItemGroup(placement: .secondaryAction) {
Button("Trigger Weight Measurement", systemImage: "scalemass.fill") {
measurementManager.loadMockWeightMeasurement()
}
Button("Trigger Blood Pressure Measurement", systemImage: "drop.fill") {
measurementManager.loadMockBloodPressureMeasurement()
}
.toolbar {
if FeatureFlags.testMockDevices {
ToolbarItemGroup(placement: .secondaryAction) {
Button("Trigger Weight Measurement", systemImage: "scalemass.fill") {
measurementManager.loadMockWeightMeasurement()
}
Button("Trigger Blood Pressure Measurement", systemImage: "drop.fill") {
measurementManager.loadMockBloodPressureMeasurement()
}
}
}
}
#endif
}
}
Expand All @@ -50,6 +53,7 @@ struct Dashboard: View {
#Preview {
Dashboard(presentingAccount: .constant(false))
.previewWith(standard: ENGAGEHFStandard()) {
NotificationManager()
MeasurementManager()
}
}
Expand Down
25 changes: 25 additions & 0 deletions ENGAGEHF/Dashboard/FetchingError.swift
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")
}
}
}
27 changes: 27 additions & 0 deletions ENGAGEHF/Dashboard/Notifications/Notification.swift
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 ENGAGEHF/Dashboard/Notifications/NotificationManager.swift
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)
}
}
Loading

0 comments on commit c0a95d5

Please sign in to comment.