From 13392b227197995f5c525afd19c3b5abd1fd7ade Mon Sep 17 00:00:00 2001 From: Shamit Surana Date: Thu, 13 Mar 2025 14:20:49 -0700 Subject: [PATCH 1/7] New documentation --- README.md | 370 ++++++++---------------------------------------------- 1 file changed, 51 insertions(+), 319 deletions(-) diff --git a/README.md b/README.md index f0372c3..0789a9f 100644 --- a/README.md +++ b/README.md @@ -24,341 +24,73 @@ Feedbridge is using the [Spezi](https://github.com/StanfordSpezi/Spezi) ecosyste ## Overview -Feedbridge is +Physiologic poor feeding in newborns, leading to poor weight gain, jaundice, and hospital readmissions, is a significant public health concern. FeedBridge aims to provide personalized, data-driven guidance to parents and physicians, enabling timely intervention and improved newborn outcomes. | Screenshot displaying the Dashboard interface of Feedbridge. | Screenshot displaying the Entry Adding interface of Feedbridge. | Screenshot displaying the Settings interface of Feedbridge. | | :----------------------------------------------------------: | :----------------------------------------------------------: | :----------------------------------------------------------: | | `Dashboard View` | `Add Entry View` | `Settings` | +## Setup Instructions -## Feedbridge Features - -*Provide a comprehensive description of your application, including figures showing the application. You can learn more on how to structure a README in the [Stanford Spezi Documentation Guide](https://swiftpackageindex.com/stanfordspezi/spezi/documentation/spezi/documentation-guide)* - -> [!NOTE] -> Do you want to learn more about the Stanford Spezi Template Application and how to use, extend, and modify this application? Check out the [Stanford Spezi Template Application documentation](https://stanfordspezi.github.io/SpeziTemplateApplication) - - -## Contributing - -Contributions to this project are welcome. Please make sure to read the [contribution guidelines](https://github.com/StanfordSpezi/.github/blob/main/CONTRIBUTING.md) and the [contributor covenant code of conduct](https://github.com/StanfordSpezi/.github/blob/main/CODE_OF_CONDUCT.md) first. - - -## License - -This project is licensed under the MIT License. See [Licenses](LICENSES) for more information. - -![Spezi Footer](https://raw.githubusercontent.com/StanfordSpezi/.github/main/assets/FooterLight.png#gh-light-mode-only) -![Spezi Footer](https://raw.githubusercontent.com/StanfordSpezi/.github/main/assets/FooterDark.png#gh-dark-mode-only) - - - - - - - -# Spezi LLM - -[![Build and Test](https://github.com/StanfordSpezi/SpeziLLM/actions/workflows/build-and-test.yml/badge.svg)](https://github.com/StanfordSpezi/SpeziLLM/actions/workflows/build-and-test.yml) -[![codecov](https://codecov.io/gh/StanfordSpezi/SpeziLLM/branch/main/graph/badge.svg?token=pptLyqtoNR)](https://codecov.io/gh/StanfordSpezi/SpeziLLM) -[![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.7954213.svg)](https://doi.org/10.5281/zenodo.7954213) -[![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2FStanfordSpezi%2FSpeziLLM%2Fbadge%3Ftype%3Dswift-versions)](https://swiftpackageindex.com/StanfordSpezi/SpeziLLM) -[![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2FStanfordSpezi%2FSpeziLLM%2Fbadge%3Ftype%3Dplatforms)](https://swiftpackageindex.com/StanfordSpezi/SpeziLLM) - - -## Overview - -The Spezi LLM Swift Package includes modules that are helpful to integrate LLM-related functionality in your application. -The package provides all necessary tools for local LLM execution, the usage of remote OpenAI-based LLMs, as well as LLMs running on Fog node resources within the local network. - -| Screenshot displaying the Chat View utilizing the OpenAI API from SpeziLLMOpenAI. | Screenshot displaying the Local LLM Download View from SpeziLLMLocalDownload. | Screenshot displaying the Chat View utilizing a locally executed LLM via SpeziLLMLocal. | -| :----------------------------------------------------------: | :----------------------------------------------------------: | :----------------------------------------------------------: | -| `OpenAI LLM Chat View` | `Language Model Download` | `Local LLM Chat View` | +You can build and run the application using [Xcode](https://developer.apple.com/xcode/) by opening up the **Feedbridge.xcodeproj**. -## Setup +The application provides a [Firebase Firestore](https://firebase.google.com/docs/firestore)-based data upload and [Firebase Authentication](https://firebase.google.com/docs/auth) login & sign-up. +It is required to have the [Firebase Emulator Suite](https://firebase.google.com/docs/emulator-suite) to be up and running to use these features to build and test the application locally. Please follow the [installation instructions](https://firebase.google.com/docs/emulator-suite/install_and_configure). -### 1. Add Spezi LLM as a Dependency +You do not have to make any modifications to the Firebase configuration, login into the `firebase` CLI using your Google account, or create a project in firebase to run, build, and test the application! -You need to add the SpeziLLM Swift package to -[your app in Xcode](https://developer.apple.com/documentation/xcode/adding-package-dependencies-to-your-app#) or -[Swift package](https://developer.apple.com/documentation/xcode/creating-a-standalone-swift-package-with-xcode#Add-a-dependency-on-another-Swift-package). - -> [!IMPORTANT] -> If your application is not yet configured to use Spezi, follow the [Spezi setup article](https://swiftpackageindex.com/stanfordspezi/spezi/documentation/spezi/initial-setup) to set up the core Spezi infrastructure. - -### 2. Follow the setup steps of the individual targets - -As Spezi LLM contains a variety of different targets for specific LLM functionalities, please follow the additional setup guide in the respective target section of this README. - -## Targets - -Spezi LLM provides a number of targets to help developers integrate LLMs in their Spezi-based applications: -- [SpeziLLM](https://swiftpackageindex.com/stanfordspezi/spezillm/documentation/spezillm): Base infrastructure of LLM execution in the Spezi ecosystem. -- [SpeziLLMLocal](https://swiftpackageindex.com/stanfordspezi/spezillm/documentation/spezillmlocal): Local LLM execution capabilities directly on-device. Enables running open-source LLMs from Hugging Face like [Meta's Llama2](https://ai.meta.com/llama/), [Microsoft's Phi](https://azure.microsoft.com/en-us/products/phi), [Google's Gemma](https://ai.google.dev/gemma), or [DeepSeek-R1](https://huggingface.co/deepseek-ai/DeepSeek-R1), among others. See [LLMLocalModel](https://swiftpackageindex.com/stanfordspezi/spezillm/main/documentation/spezillmlocal/llmlocalmodel) for a list of models tested with SpeziLLM. -- [SpeziLLMLocalDownload](https://swiftpackageindex.com/stanfordspezi/spezillm/documentation/spezillmlocaldownload): Download and storage manager of local Language Models, including onboarding views. -- [SpeziLLMOpenAI](https://swiftpackageindex.com/stanfordspezi/spezillm/documentation/spezillmopenai): Integration with OpenAI's GPT models via using OpenAI's API service. -- [SpeziLLMFog](https://swiftpackageindex.com/stanfordspezi/spezillm/documentation/spezillmfog): Discover and dispatch LLM inference jobs to Fog node resources within the local network. - -The section below highlights the setup and basic use of the [SpeziLLMLocal](https://swiftpackageindex.com/stanfordspezi/spezillm/documentation/spezillmlocal), [SpeziLLMOpenAI](https://swiftpackageindex.com/stanfordspezi/spezillm/documentation/spezillmopenai), and [SpeziLLMFog](https://swiftpackageindex.com/stanfordspezi/spezillm/documentation/spezillmfog) targets in order to integrate Language Models in a Spezi-based application. - -> [!NOTE] -> To learn more about the usage of the individual targets, please refer to the [DocC documentation of the package](https://swiftpackageindex.com/stanfordspezi/spezillm/documentation). - -### Spezi LLM Local - -The target enables developers to easily execute medium-size Language Models (LLMs) locally on-device. The module allows you to interact with the locally run LLM via purely Swift-based APIs, no interaction with low-level code is necessary, building on top of the infrastructure of the [SpeziLLM target](https://swiftpackageindex.com/stanfordspezi/spezillm/documentation/spezillm). - -> [!IMPORTANT] -> Spezi LLM Local is not compatible with simulators. The underlying [`mlx-swift`](https://github.com/ml-explore/mlx-swift) requires a modern Metal MTLGPUFamily and the simulator does not provide that. - -> [!IMPORTANT] -> Important: To use the LLM local target, some LLMs require adding the *Increase Memory Limit* entitlement to the project. - -#### Setup - -You can configure the Spezi Local LLM execution within the typical `SpeziAppDelegate`. -In the example below, the `LLMRunner` from the [SpeziLLM](https://swiftpackageindex.com/stanfordspezi/spezillm/documentation/spezillm) target which is responsible for providing LLM functionality within the Spezi ecosystem is configured with the `LLMLocalPlatform` from the [SpeziLLMLocal](https://swiftpackageindex.com/stanfordspezi/spezillm/documentation/spezillmlocal) target. This prepares the `LLMRunner` to locally execute Language Models. - -```swift -class TestAppDelegate: SpeziAppDelegate { - override var configuration: Configuration { - Configuration { - LLMRunner { - LLMLocalPlatform() - } - } - } -} -``` - -[SpeziLLMLocalDownload](https://swiftpackageindex.com/stanfordspezi/spezillm/documentation/spezillmlocaldownload) can be used to download an LLM from [HuggingFace](https://huggingface.co/) and save it on the device for execution. The `LLMLocalDownloadView` provides an out-of-the-box onboarding view for downloading models locally. - -```swift -struct LLMLocalOnboardingDownloadView: View { - var body: some View { - LLMLocalDownloadView( - model: .llama3_8B_4bit, - downloadDescription: "The Llama3 8B model will be downloaded", - ) { - // Action to perform after the model is downloaded and the user presses the next button. - } - } -} +Startup the [Firebase Emulator Suite](https://firebase.google.com/docs/emulator-suite) using ``` - -> [!TIP] -> The `LLMLocalDownloadView` view can be included in your onboarding process using SpeziOnboarding as [demonstrated in this example](https://swiftpackageindex.com/stanfordspezi/spezillm/main/documentation/spezillmlocaldownload/llmlocaldownloadview#overview). - - -#### Usage - -The code example below showcases the interaction with local LLMs through the the [SpeziLLM](https://swiftpackageindex.com/stanfordspezi/spezillm/documentation/spezillm) [`LLMRunner`](https://swiftpackageindex.com/stanfordspezi/spezillm/documentation/spezillm/llmrunner), which is injected into the SwiftUI `Environment` via the `Configuration` shown above. - -The `LLMLocalSchema` defines the type and configurations of the to-be-executed `LLMLocalSession`. This transformation is done via the [`LLMRunner`](https://swiftpackageindex.com/stanfordspezi/spezillm/documentation/spezillm/llmrunner) that uses the `LLMLocalPlatform`. The inference via `LLMLocalSession/generate()` returns an `AsyncThrowingStream` that yields all generated `String` pieces. - -```swift -struct LLMLocalDemoView: View { - @Environment(LLMRunner.self) var runner - @State var responseText = "" - - var body: some View { - Text(responseText) - .task { - // Instantiate the `LLMLocalSchema` to an `LLMLocalSession` via the `LLMRunner`. - let llmSession: LLMLocalSession = runner( - with: LLMLocalSchema( - model: .llama3_8B_4bit, - ) - ) - - do { - for try await token in try await llmSession.generate() { - responseText.append(token) - } - } catch { - // Handle errors here. E.g., you can use `ViewState` and `viewStateAlert` from SpeziViews. - } - } - } -} +$ firebase emulators:start ``` -The [`LLMChatViewSchema`](https://swiftpackageindex.com/stanfordspezi/spezillm/main/documentation/spezillm/llmchatviewschema) can be used to easily create a conversational chat interface for your chatbot application with a local LLM. - -```swift -struct LLMLocalChatView: View { - var body: some View { - LLMChatViewSchema( - with: LLMLocalSchema( - model: .llama3_8B_4bit - ) - ) - } -} -``` - -> [!NOTE] -> To learn more about the usage of SpeziLLMLocal, please refer to the comprehensive [DocC documentation](https://swiftpackageindex.com/stanfordspezi/spezillm/documentation/spezillmlocal). - -### Spezi LLM Open AI - -A module that allows you to interact with GPT-based Large Language Models (LLMs) from OpenAI within your Spezi application. -`SpeziLLMOpenAI` provides a pure Swift-based API for interacting with the OpenAI GPT API, building on top of the infrastructure of the [SpeziLLM target](https://swiftpackageindex.com/stanfordspezi/spezillm/documentation/spezillm). -In addition, `SpeziLLMOpenAI` provides developers with a declarative Domain Specific Language to utilize OpenAI function calling mechanism. This enables a structured, bidirectional, and reliable communication between the OpenAI LLMs and external tools, such as the Spezi ecosystem. - -#### Setup - -In order to use OpenAI LLMs within the Spezi ecosystem, the [SpeziLLM](https://swiftpackageindex.com/stanfordspezi/spezillm/documentation/spezillm) [`LLMRunner`](https://swiftpackageindex.com/stanfordspezi/spezillm/documentation/spezillm/llmrunner) needs to be initialized in the Spezi `Configuration` with the `LLMOpenAIPlatform`. Only after, the `LLMRunner` can be used for inference of OpenAI LLMs. -See the [SpeziLLM documentation](https://swiftpackageindex.com/stanfordspezi/spezillm/documentation/spezillm) for more details. +After the emulators have started up, you can run the application in your simulator to build, test, and run the application. -```swift -import Spezi -import SpeziLLM -import SpeziLLMOpenAI -class LLMOpenAIAppDelegate: SpeziAppDelegate { - override var configuration: Configuration { - Configuration { - LLMRunner { - LLMOpenAIPlatform() - } - } - } -} -``` +## Feedbridge Features -> [!IMPORTANT] -> If using `SpeziLLMOpenAI` on macOS, ensure to add the *`Keychain Access Groups` entitlement* to the enclosing Xcode project via *PROJECT_NAME > Signing&Capabilities > + Capability*. The array of keychain groups can be left empty, only the base entitlement is required. - -#### Usage - -The code example below showcases the interaction with an OpenAI LLM through the the [SpeziLLM](https://swiftpackageindex.com/stanfordspezi/spezillm/documentation/spezillm) [`LLMRunner`](https://swiftpackageindex.com/stanfordspezi/spezillm/documentation/spezillm/llmrunner), which is injected into the SwiftUI `Environment` via the `Configuration` shown above. - -The `LLMOpenAISchema` defines the type and configurations of the to-be-executed `LLMOpenAISession`. This transformation is done via the [`LLMRunner`](https://swiftpackageindex.com/stanfordspezi/spezillm/documentation/spezillm/llmrunner) that uses the `LLMOpenAIPlatform`. The inference via `LLMOpenAISession/generate()` returns an `AsyncThrowingStream` that yields all generated `String` pieces. - -```swift -import SpeziLLM -import SpeziLLMOpenAI -import SwiftUI - -struct LLMOpenAIDemoView: View { - @Environment(LLMRunner.self) var runner - @State var responseText = "" - - var body: some View { - Text(responseText) - .task { - // Instantiate the `LLMOpenAISchema` to an `LLMOpenAISession` via the `LLMRunner`. - let llmSession: LLMOpenAISession = runner( - with: LLMOpenAISchema( - parameters: .init( - modelType: .gpt4_o, - systemPrompt: "You're a helpful assistant that answers questions from users.", - overwritingToken: "abc123" - ) - ) - ) - - do { - for try await token in try await llmSession.generate() { - responseText.append(token) - } - } catch { - // Handle errors here. E.g., you can use `ViewState` and `viewStateAlert` from SpeziViews. - } - } - } -} -``` +**Manual Data Entry** + +User interface that allows parents to enter and store the following data points: +- Feed Data: + - Feed date, time, and type (direct breastfeeding vs bottle feeding) + - Milk type (breastmilk vs formula) + - Feed time (if type is direct breastfeeding) or feed volume (if type is bottle feeding) +- Wet Diaper Entry: + - Volume: light, medium, heavy + - Color: yellow, pink, or red-tinged + - If pink or red-tinged, alert the parent to seek medical care (may indicate dehydration) +- Stool Entry: + - Volume: light, medium, heavy + - Color: Black, dark green, green, brown, yellow, or beige + - If beige is selected, display an alert to seek medical care (could be a sign of liver failure) +- Dehydration Assessment: + - Skin elasticity: assess if the skin over the abdomen is stretchy (use visual aids from Dr. Sankar) + - Dry mucous membranes: check for dry lips and tongue (use visual aids from Dr. Sankar) + - Alert the parent to seek medical care if either indicator is observed +- Weight Entry: + - Accepts input in grams, kilograms, or pounds and ounces + +**Data Visualization** + +The data below are visualized in a graph and timeline format. +- Feeds + - Trend feed duration/volume and milk type over time. +- Wet Diapers + - Display diaper quantity and quality (light, medium, heavy, and color) over time. +- Stools + - Display stool volume and color over time. +- Weights + - Display one weight point per day (average if multiple weight entries exist in one day). + - Determine the color of the weight dot based on the risk normogram from [newbornweight.org](https://newbornweight.org/). + - For high-risk patients, suggest courses of action at home (e.g., triple feeding using visual aids; course mentors can assist in creating this content). + - Additionally advise high-risk patients to seek medical care. +- Dehydration + - Displays if baby is dehydrated and gives appropriate warnings > [!NOTE] -> To learn more about the usage of SpeziLLMOpenAI, please refer to the [DocC documentation](https://swiftpackageindex.com/stanfordspezi/spezillm/documentation/spezillmopenai). - -### Spezi LLM Fog - -The `SpeziLLMFog` target enables you to use LLMs running on [Fog node](https://en.wikipedia.org/wiki/Fog_computing) computing resources within the local network. The fog nodes advertise their services via [mDNS](https://en.wikipedia.org/wiki/Multicast_DNS), enabling clients to discover all fog nodes serving a specific host within the local network. -`SpeziLLMFog` then dispatches LLM inference jobs dynamically to a random fog node within the local network and streams the response to surface it to the user. - -> [!IMPORTANT] -> `SpeziLLMFog` requires a `SpeziLLMFogNode` within the local network hosted on some computing resource that actually performs the inference requests. `SpeziLLMFog` provides the `SpeziLLMFogNode` Docker-based package that enables an easy setup of these fog nodes. See the `FogNode` directory on the root level of the SPM package as well as the respective `README.md` for more details. - -#### Setup - -In order to use Fog LLMs within the Spezi ecosystem, the [SpeziLLM](https://swiftpackageindex.com/stanfordspezi/spezillm/documentation/spezillm) [`LLMRunner`](https://swiftpackageindex.com/stanfordspezi/spezillm/documentation/spezillm/llmrunner) needs to be initialized in the Spezi `Configuration` with the `LLMFogPlatform`. Only after, the `LLMRunner` can be used for inference with Fog LLMs. See the [SpeziLLM documentation](https://swiftpackageindex.com/stanfordspezi/spezillm/documentation/spezillm) for more details. -The `LLMFogPlatform` needs to be initialized with the custom root CA certificate that was used to sign the fog node web service certificate (see the `FogNode/README.md` documentation for more information). Copy the root CA certificate from the fog node as resource to the application using `SpeziLLMFog` and use it to initialize the `LLMFogPlatform` within the Spezi `Configuration`. - -```swift -class LLMFogAppDelegate: SpeziAppDelegate { - private nonisolated static var caCertificateUrl: URL { - // Return local file URL of root CA certificate in the `.crt` format - } - - override var configuration: Configuration { - Configuration { - LLMRunner { - // Set up the Fog platform with the custom CA certificate - LLMRunner { - LLMFogPlatform(configuration: .init(caCertificate: Self.caCertificateUrl)) - } - } - } - } -} -``` +> Do you want to learn more about the Stanford Spezi Template Application and how to use, extend, and modify the Feedbridge application? Check out the [Stanford Spezi Template Application documentation](https://stanfordspezi.github.io/SpeziTemplateApplication) -#### Usage - -The code example below showcases the interaction with a Fog LLM through the the [SpeziLLM](https://swiftpackageindex.com/stanfordspezi/spezillm/documentation/spezillm) [`LLMRunner`](https://swiftpackageindex.com/stanfordspezi/spezillm/documentation/spezillm/llmrunner), which is injected into the SwiftUI `Environment` via the `Configuration` shown above. - -The `LLMFogSchema` defines the type and configurations of the to-be-executed `LLMFogSession`. This transformation is done via the [`LLMRunner`](https://swiftpackageindex.com/stanfordspezi/spezillm/documentation/spezillm/llmrunner) that uses the `LLMFogPlatform`. The inference via `LLMFogSession/generate()` returns an `AsyncThrowingStream` that yields all generated `String` pieces. -The `LLMFogSession` automatically discovers all available LLM fog nodes within the local network upon setup and the dispatches the LLM inference jobs to the fog computing resource, streaming back the response and surfaces it to the user. - -> [!IMPORTANT] -> The `LLMFogSchema` accepts a closure that returns an authorization token that is passed with every request to the Fog node in the `Bearer` HTTP field via the `LLMFogParameters/init(modelType:systemPrompt:authToken:)`. The token is created via the closure upon every LLM inference request, as the `LLMFogSession` may be long lasting and the token could therefore expire. Ensure that the closure appropriately caches the token in order to prevent unnecessary token refresh roundtrips to external systems. - -```swift -struct LLMFogDemoView: View { - @Environment(LLMRunner.self) var runner - @State var responseText = "" - - var body: some View { - Text(responseText) - .task { - // Instantiate the `LLMFogSchema` to an `LLMFogSession` via the `LLMRunner`. - let llmSession: LLMFogSession = runner( - with: LLMFogSchema( - parameters: .init( - modelType: .llama7B, - systemPrompt: "You're a helpful assistant that answers questions from users.", - authToken: { - // Return authorization token as `String` or `nil` if no token is required by the Fog node. - } - ) - ) - ) - - do { - for try await token in try await llmSession.generate() { - responseText.append(token) - } - } catch { - // Handle errors here. E.g., you can use `ViewState` and `viewStateAlert` from SpeziViews. - } - } - } -} -``` - -> [!NOTE] -> To learn more about the usage of SpeziLLMFog, please refer to the [DocC documentation](https://swiftpackageindex.com/stanfordspezi/spezillm/documentation/spezillmfog). ## Contributing @@ -367,7 +99,7 @@ Contributions to this project are welcome. Please make sure to read the [contrib ## License -This project is licensed under the MIT License. See [Licenses](https://github.com/StanfordSpezi/SpeziLLM/tree/main/LICENSES) for more information. +This project is licensed under the MIT License. See [Licenses](LICENSES) for more information. ![Spezi Footer](https://raw.githubusercontent.com/StanfordSpezi/.github/main/assets/FooterLight.png#gh-light-mode-only) -![Spezi Footer](https://raw.githubusercontent.com/StanfordSpezi/.github/main/assets/FooterDark.png#gh-dark-mode-only) +![Spezi Footer](https://raw.githubusercontent.com/StanfordSpezi/.github/main/assets/FooterDark.png#gh-dark-mode-only) \ No newline at end of file From 415c0d5e413e159f1dc56cba443d0823ba42f1fe Mon Sep 17 00:00:00 2001 From: Shamit Surana Date: Thu, 13 Mar 2025 16:24:57 -0700 Subject: [PATCH 2/7] fixed bugs: --- Feedbridge.xcodeproj/project.pbxproj | 38 +- Feedbridge/Contacts/Contacts.swift | 77 ---- Feedbridge/FeedbridgeStandard.swift | 2 + Feedbridge/Models/Baby.swift | 52 ++- Feedbridge/Models/FeedEntry.swift | 3 - Feedbridge/Models/StoolEntry.swift | 1 - Feedbridge/Models/WeightEntry.swift | 1 - Feedbridge/Models/WetDiaperEntry.swift | 3 - Feedbridge/Resources/Localizable.xcstrings | 11 +- Feedbridge/Views/AddBabyView.swift | 215 ++++++----- Feedbridge/Views/AddEntryView.swift | 2 + Feedbridge/Views/AddSingleBabyView.swift | 87 +++-- Feedbridge/Views/HealthDetailsView.swift | 297 ++++++++++++++++ Feedbridge/Views/SettingsView.swift | 284 --------------- FeedbridgeTests/FeedbridgeTests.swift | 153 +++++++- FeedbridgeTests/TestFeedbridgeStandard.swift | 352 ------------------- FeedbridgeTests/TestModels.swift | 148 -------- FeedbridgeUITests/AddEntryTests.swift | 6 +- FeedbridgeUITests/ContactsTests.swift | 39 -- FeedbridgeUITests/OnboardingTests.swift | 200 ----------- 20 files changed, 661 insertions(+), 1310 deletions(-) delete mode 100644 Feedbridge/Contacts/Contacts.swift create mode 100644 Feedbridge/Views/HealthDetailsView.swift delete mode 100644 FeedbridgeTests/TestFeedbridgeStandard.swift delete mode 100644 FeedbridgeUITests/ContactsTests.swift delete mode 100644 FeedbridgeUITests/OnboardingTests.swift diff --git a/Feedbridge.xcodeproj/project.pbxproj b/Feedbridge.xcodeproj/project.pbxproj index 41c5a0a..391307b 100644 --- a/Feedbridge.xcodeproj/project.pbxproj +++ b/Feedbridge.xcodeproj/project.pbxproj @@ -10,9 +10,7 @@ 2F1AC9DF2B4E840E00C24973 /* Feedbridge.docc in Sources */ = {isa = PBXBuildFile; fileRef = 2F1AC9DE2B4E840E00C24973 /* Feedbridge.docc */; }; 2F3D4ABC2A4E7C290068FB2F /* SpeziScheduler in Frameworks */ = {isa = PBXBuildFile; productRef = 2F3D4ABB2A4E7C290068FB2F /* SpeziScheduler */; }; 2F49B7762980407C00BCB272 /* Spezi in Frameworks */ = {isa = PBXBuildFile; productRef = 2F49B7752980407B00BCB272 /* Spezi */; }; - 2F4E237E2989A2FE0013F3D9 /* OnboardingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F4E237D2989A2FE0013F3D9 /* OnboardingTests.swift */; }; 2F4E23832989D51F0013F3D9 /* FeedbridgeTestingSetup.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F4E23822989D51F0013F3D9 /* FeedbridgeTestingSetup.swift */; }; - 2F4E23872989DB360013F3D9 /* ContactsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F4E23862989DB360013F3D9 /* ContactsTests.swift */; }; 2F5E32BD297E05EA003432F8 /* FeedbridgeDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F5E32BC297E05EA003432F8 /* FeedbridgeDelegate.swift */; }; 2F6025CB29BBE70F0045459E /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 2F6025CA29BBE70F0045459E /* GoogleService-Info.plist */; }; 2FA0BFED2ACC977500E0EF83 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 2FA0BFEC2ACC977500E0EF83 /* Localizable.xcstrings */; }; @@ -24,7 +22,6 @@ 2FC3439129EE6349002D773C /* AppIcon.png in Resources */ = {isa = PBXBuildFile; fileRef = 2FE5DC2A29EDD78D004B9AB4 /* AppIcon.png */; }; 2FC3439229EE634B002D773C /* ConsentDocument.md in Resources */ = {isa = PBXBuildFile; fileRef = 2FE5DC2C29EDD78E004B9AB4 /* ConsentDocument.md */; }; 2FC975A82978F11A00BA99FE /* HomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FC975A72978F11A00BA99FE /* HomeView.swift */; }; - 2FE5DC2629EDD38A004B9AB4 /* Contacts.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FE5DC2529EDD38A004B9AB4 /* Contacts.swift */; }; 2FE5DC3529EDD7CA004B9AB4 /* Consent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FE5DC2F29EDD7CA004B9AB4 /* Consent.swift */; }; 2FE5DC3729EDD7CA004B9AB4 /* OnboardingFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FE5DC3129EDD7CA004B9AB4 /* OnboardingFlow.swift */; }; 2FE5DC3A29EDD7CA004B9AB4 /* Welcome.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FE5DC3429EDD7CA004B9AB4 /* Welcome.swift */; }; @@ -51,6 +48,8 @@ 35B6309F2D82BEA00096904E /* WeightTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35B6309E2D82BE9B0096904E /* WeightTests.swift */; }; 35B630A32D82C0BD0096904E /* DehydrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35B630A22D82C0BA0096904E /* DehydrationTests.swift */; }; 35B630A62D82C1020096904E /* WetDiaperTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35B630A52D82C0FC0096904E /* WetDiaperTests.swift */; }; + 533241962D8392180004F271 /* FeedbridgeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 533241952D8392180004F271 /* FeedbridgeTests.swift */; }; + 5332419A2D8394200004F271 /* HealthDetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 533241992D83941D0004F271 /* HealthDetailsView.swift */; }; 53F30C282D7FBB670077FD21 /* AddDataViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53F30C272D7FBB670077FD21 /* AddDataViewTests.swift */; }; 5680DD3E2AB8CD84004E6D4A /* ContributionsTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5680DD3D2AB8CD84004E6D4A /* ContributionsTest.swift */; }; 56E708352BB06B7100B08F0A /* SpeziLicense in Frameworks */ = {isa = PBXBuildFile; productRef = 56E708342BB06B7100B08F0A /* SpeziLicense */; }; @@ -60,10 +59,8 @@ 5BB4CE322D5B183200DA4CF7 /* AddSingleBabyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BB4CE312D5B183200DA4CF7 /* AddSingleBabyView.swift */; }; 5BC74CDA2D6E19320059AA19 /* AddEntryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BC74CD92D6E19320059AA19 /* AddEntryView.swift */; }; 5BD66F352D7EC73D0043D295 /* TestModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BD66F342D7EC73B0043D295 /* TestModels.swift */; }; - 5BD66F3E2D7ED0650043D295 /* TestFeedbridgeStandard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BD66F3D2D7ED0630043D295 /* TestFeedbridgeStandard.swift */; }; 653A2551283387FE005D4D48 /* Feedbridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 653A2550283387FE005D4D48 /* Feedbridge.swift */; }; 653A255528338800005D4D48 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 653A255428338800005D4D48 /* Assets.xcassets */; }; - 653A256228338800005D4D48 /* FeedbridgeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 653A256128338800005D4D48 /* FeedbridgeTests.swift */; }; 653A256C28338800005D4D48 /* SchedulerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 653A256B28338800005D4D48 /* SchedulerTests.swift */; }; 9733CFC62A8066DE001B7ABC /* SpeziOnboarding in Frameworks */ = {isa = PBXBuildFile; productRef = 2FE5DC8029EDD91D004B9AB4 /* SpeziOnboarding */; }; 9739A0C62AD7B5730084BEA5 /* FirebaseStorage in Frameworks */ = {isa = PBXBuildFile; productRef = 9739A0C52AD7B5730084BEA5 /* FirebaseStorage */; }; @@ -98,16 +95,13 @@ /* Begin PBXFileReference section */ 2F1AC9DE2B4E840E00C24973 /* Feedbridge.docc */ = {isa = PBXFileReference; lastKnownFileType = folder.documentationcatalog; path = Feedbridge.docc; sourceTree = ""; }; - 2F4E237D2989A2FE0013F3D9 /* OnboardingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingTests.swift; sourceTree = ""; }; 2F4E23822989D51F0013F3D9 /* FeedbridgeTestingSetup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedbridgeTestingSetup.swift; sourceTree = ""; }; - 2F4E23862989DB360013F3D9 /* ContactsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactsTests.swift; sourceTree = ""; }; 2F5E32BC297E05EA003432F8 /* FeedbridgeDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedbridgeDelegate.swift; sourceTree = ""; }; 2F6025CA29BBE70F0045459E /* GoogleService-Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = ""; }; 2FA0BFEC2ACC977500E0EF83 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; 2FAEC07F297F583900C11C42 /* Feedbridge.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Feedbridge.entitlements; sourceTree = ""; }; 2FC94CD4298B0A1D009C8209 /* Feedbridge.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = Feedbridge.xctestplan; sourceTree = ""; }; 2FC975A72978F11A00BA99FE /* HomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeView.swift; sourceTree = ""; }; - 2FE5DC2529EDD38A004B9AB4 /* Contacts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Contacts.swift; sourceTree = ""; }; 2FE5DC2A29EDD78D004B9AB4 /* AppIcon.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = AppIcon.png; sourceTree = ""; }; 2FE5DC2C29EDD78E004B9AB4 /* ConsentDocument.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = ConsentDocument.md; sourceTree = ""; }; 2FE5DC2F29EDD7CA004B9AB4 /* Consent.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Consent.swift; sourceTree = ""; }; @@ -132,6 +126,8 @@ 35E52D3E2D794A77005A6BB7 /* FeedsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedsView.swift; sourceTree = ""; }; 35E52E012D7971EC005A6BB7 /* WetDiaperCharts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WetDiaperCharts.swift; sourceTree = ""; }; 35E52E032D79727C005A6BB7 /* WetDiapersView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WetDiapersView.swift; sourceTree = ""; }; + 533241952D8392180004F271 /* FeedbridgeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedbridgeTests.swift; sourceTree = ""; }; + 533241992D83941D0004F271 /* HealthDetailsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthDetailsView.swift; sourceTree = ""; }; 53C427AB2D76496100EC9E29 /* WeightsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeightsView.swift; sourceTree = ""; }; 53F30C272D7FBB670077FD21 /* AddDataViewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddDataViewTests.swift; sourceTree = ""; }; 5680DD3D2AB8CD84004E6D4A /* ContributionsTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContributionsTest.swift; sourceTree = ""; }; @@ -140,12 +136,10 @@ 5BB4CE312D5B183200DA4CF7 /* AddSingleBabyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddSingleBabyView.swift; sourceTree = ""; }; 5BC74CD92D6E19320059AA19 /* AddEntryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddEntryView.swift; sourceTree = ""; }; 5BD66F342D7EC73B0043D295 /* TestModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestModels.swift; sourceTree = ""; }; - 5BD66F3D2D7ED0630043D295 /* TestFeedbridgeStandard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestFeedbridgeStandard.swift; sourceTree = ""; }; 653A254D283387FE005D4D48 /* Feedbridge.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Feedbridge.app; sourceTree = BUILT_PRODUCTS_DIR; }; 653A2550283387FE005D4D48 /* Feedbridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Feedbridge.swift; sourceTree = ""; }; 653A255428338800005D4D48 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 653A255D28338800005D4D48 /* FeedbridgeTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = FeedbridgeTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; - 653A256128338800005D4D48 /* FeedbridgeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedbridgeTests.swift; sourceTree = ""; }; 653A256728338800005D4D48 /* FeedbridgeUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = FeedbridgeUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 653A256B28338800005D4D48 /* SchedulerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SchedulerTests.swift; sourceTree = ""; }; 653A258928339462005D4D48 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; @@ -226,14 +220,6 @@ path = "Supporting Files"; sourceTree = ""; }; - 2FE5DC2729EDD38D004B9AB4 /* Contacts */ = { - isa = PBXGroup; - children = ( - 2FE5DC2529EDD38A004B9AB4 /* Contacts.swift */, - ); - path = Contacts; - sourceTree = ""; - }; 2FE5DC2829EDD398004B9AB4 /* Onboarding */ = { isa = PBXGroup; children = ( @@ -285,6 +271,7 @@ 5B0E57762D5C311B002AC4BB /* Views */ = { isa = PBXGroup; children = ( + 533241992D83941D0004F271 /* HealthDetailsView.swift */, 358B23B92D7D974800D60CF6 /* Dashboard */, 5BC74CD92D6E19320059AA19 /* AddEntryView.swift */, 35B62D5C2D80C20C0096904E /* SettingsView.swift */, @@ -328,7 +315,6 @@ 2FF53D8C2A8729D600042B76 /* FeedbridgeStandard.swift */, 2F4E23822989D51F0013F3D9 /* FeedbridgeTestingSetup.swift */, A9720E412ABB68B300872D23 /* Account */, - 2FE5DC2729EDD38D004B9AB4 /* Contacts */, A9A3DCC62C75CB8D00FC9B69 /* Firestore */, 2FE5DC2829EDD398004B9AB4 /* Onboarding */, 2FE5DC2D29EDD792004B9AB4 /* Resources */, @@ -342,9 +328,8 @@ 653A256028338800005D4D48 /* FeedbridgeTests */ = { isa = PBXGroup; children = ( - 5BD66F3D2D7ED0630043D295 /* TestFeedbridgeStandard.swift */, + 533241952D8392180004F271 /* FeedbridgeTests.swift */, 5BD66F342D7EC73B0043D295 /* TestModels.swift */, - 653A256128338800005D4D48 /* FeedbridgeTests.swift */, ); path = FeedbridgeTests; sourceTree = ""; @@ -360,9 +345,7 @@ 35B62F792D8257E80096904E /* AddBabyTests.swift */, 35B630452D82A20F0096904E /* StoolTests.swift */, 53F30C272D7FBB670077FD21 /* AddDataViewTests.swift */, - 2F4E237D2989A2FE0013F3D9 /* OnboardingTests.swift */, 653A256B28338800005D4D48 /* SchedulerTests.swift */, - 2F4E23862989DB360013F3D9 /* ContactsTests.swift */, 5680DD3D2AB8CD84004E6D4A /* ContributionsTest.swift */, ); path = FeedbridgeUITests; @@ -600,6 +583,7 @@ 2FE5DC3729EDD7CA004B9AB4 /* OnboardingFlow.swift in Sources */, 2F1AC9DF2B4E840E00C24973 /* Feedbridge.docc in Sources */, 2FF53D8D2A8729D600042B76 /* FeedbridgeStandard.swift in Sources */, + 5332419A2D8394200004F271 /* HealthDetailsView.swift in Sources */, 5B2B9CB92D52F9BF0047A55C /* AddBabyView.swift in Sources */, 5BB4CE322D5B183200DA4CF7 /* AddSingleBabyView.swift in Sources */, A9720E432ABB68CC00872D23 /* AccountSetupHeader.swift in Sources */, @@ -610,7 +594,6 @@ 5BC74CDA2D6E19320059AA19 /* AddEntryView.swift in Sources */, 35B62D5D2D80C20C0096904E /* SettingsView.swift in Sources */, 653A2551283387FE005D4D48 /* Feedbridge.swift in Sources */, - 2FE5DC2629EDD38A004B9AB4 /* Contacts.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -618,9 +601,8 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 5BD66F3E2D7ED0650043D295 /* TestFeedbridgeStandard.swift in Sources */, - 653A256228338800005D4D48 /* FeedbridgeTests.swift in Sources */, 5BD66F352D7EC73D0043D295 /* TestModels.swift in Sources */, + 533241962D8392180004F271 /* FeedbridgeTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -634,10 +616,8 @@ 35B630A32D82C0BD0096904E /* DehydrationTests.swift in Sources */, 35B630A62D82C1020096904E /* WetDiaperTests.swift in Sources */, 5B8425C82D829FC1009B00BC /* AddEntryTests.swift in Sources */, - 2F4E23872989DB360013F3D9 /* ContactsTests.swift in Sources */, 53F30C282D7FBB670077FD21 /* AddDataViewTests.swift in Sources */, 35B6309F2D82BEA00096904E /* WeightTests.swift in Sources */, - 2F4E237E2989A2FE0013F3D9 /* OnboardingTests.swift in Sources */, 653A256C28338800005D4D48 /* SchedulerTests.swift in Sources */, 35B62F7A2D8257EC0096904E /* AddBabyTests.swift in Sources */, ); @@ -954,7 +934,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = ""; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = Y6WUS7R97A; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = "Feedbridge/Supporting Files/Info.plist"; diff --git a/Feedbridge/Contacts/Contacts.swift b/Feedbridge/Contacts/Contacts.swift deleted file mode 100644 index 4c0e93d..0000000 --- a/Feedbridge/Contacts/Contacts.swift +++ /dev/null @@ -1,77 +0,0 @@ -// -// This source file is part of the Feedbridge based on the Stanford Spezi Template Application project -// -// SPDX-FileCopyrightText: 2025 Stanford University -// -// SPDX-License-Identifier: MIT -// - -import Foundation -import SpeziAccount -import SpeziContact -import SwiftUI - -/// Displays the contacts for the Feedbridge. -struct Contacts: View { - let contacts = [ - Contact( - name: PersonNameComponents( - givenName: "Leland", - familyName: "Stanford" - ), - image: Image(systemName: "figure.wave.circle"), // swiftlint:disable:this accessibility_label_for_image - title: "University Founder", - description: String(localized: "LELAND_STANFORD_BIO"), - organization: "Stanford University", - address: { - let address = CNMutablePostalAddress() - address.country = "USA" - address.state = "CA" - address.postalCode = "94305" - address.city = "Stanford" - address.street = "450 Serra Mall" - return address - }(), - contactOptions: [ - .call("+1 (650) 723-2300"), - .text("+1 (650) 723-2300"), - .email(addresses: ["contact@stanford.edu"]), - ContactOption( - image: Image(systemName: "safari.fill"), // swiftlint:disable:this accessibility_label_for_image - title: "Website", - action: { - if let url = URL(string: "https://stanford.edu") { - UIApplication.shared.open(url) - } - } - ) - ] - ) - ] - - @Environment(Account.self) private var account: Account? - - @Binding var presentingAccount: Bool - - var body: some View { - NavigationStack { - ContactsList(contacts: contacts) - .navigationTitle("Contacts") - .toolbar { - if account != nil { - AccountButton(isPresented: $presentingAccount) - } - } - } - } - - init(presentingAccount: Binding) { - self._presentingAccount = presentingAccount - } -} - -#if DEBUG -#Preview { - Contacts(presentingAccount: .constant(false)) -} -#endif diff --git a/Feedbridge/FeedbridgeStandard.swift b/Feedbridge/FeedbridgeStandard.swift index 6082970..d634253 100644 --- a/Feedbridge/FeedbridgeStandard.swift +++ b/Feedbridge/FeedbridgeStandard.swift @@ -7,6 +7,8 @@ // // swiftlint:disable type_body_length // swiftlint:disable file_length +// +// Reaoning for swiftlint disable rules: https://github.com/orgs/CS342/discussions/181 import FirebaseAuth @preconcurrency import FirebaseFirestore diff --git a/Feedbridge/Models/Baby.swift b/Feedbridge/Models/Baby.swift index 6c07b00..1151341 100644 --- a/Feedbridge/Models/Baby.swift +++ b/Feedbridge/Models/Baby.swift @@ -9,12 +9,35 @@ // // SPDX-License-Identifier: MIT // -// swiftlint:disable file_types_order @preconcurrency import FirebaseFirestore import Foundation -// periphery:ignore +struct FeedEntries: Codable, Identifiable, Sendable { + @DocumentID var id: String? + var feedEntries: [FeedEntry] +} + +struct WeightEntries: Codable, Identifiable, Sendable { + @DocumentID var id: String? + var weightEntries: [WeightEntry] +} + +struct StoolEntries: Codable, Identifiable, Sendable { + @DocumentID var id: String? + var stoolEntries: [StoolEntry] +} + +struct WetDiaperEntries: Codable, Identifiable, Sendable { + @DocumentID var id: String? + var wetDiaperEntries: [WetDiaperEntry] +} + +struct DehydrationChecks: Codable, Identifiable, Sendable { + @DocumentID var id: String? + var dehydrationChecks: [DehydrationCheck] +} + /// Represents a baby and their associated health tracking data struct Baby: Identifiable, Codable, Sendable, Equatable { /// Unique identifier for the baby @@ -82,28 +105,3 @@ struct Baby: Identifiable, Codable, Sendable, Equatable { lhs.dateOfBirth == rhs.dateOfBirth } } - -struct FeedEntries: Codable, Identifiable, Sendable { - @DocumentID var id: String? - var feedEntries: [FeedEntry] -} - -struct WeightEntries: Codable, Identifiable, Sendable { - @DocumentID var id: String? - var weightEntries: [WeightEntry] -} - -struct StoolEntries: Codable, Identifiable, Sendable { - @DocumentID var id: String? - var stoolEntries: [StoolEntry] -} - -struct WetDiaperEntries: Codable, Identifiable, Sendable { - @DocumentID var id: String? - var wetDiaperEntries: [WetDiaperEntry] -} - -struct DehydrationChecks: Codable, Identifiable, Sendable { - @DocumentID var id: String? - var dehydrationChecks: [DehydrationCheck] -} diff --git a/Feedbridge/Models/FeedEntry.swift b/Feedbridge/Models/FeedEntry.swift index fb99afd..bb99828 100644 --- a/Feedbridge/Models/FeedEntry.swift +++ b/Feedbridge/Models/FeedEntry.swift @@ -12,20 +12,17 @@ import Foundation // Represents method of feeding -// periphery:ignore enum FeedType: String, Codable { case directBreastfeeding case bottle } // Represents the type of milk used -// periphery:ignore enum MilkType: String, Codable { case breastmilk case formula } -// periphery:ignore /// Stores feeding-related data struct FeedEntry: Identifiable, Codable, Sendable { /// Use UUID to generate a unique identifier for Firebase diff --git a/Feedbridge/Models/StoolEntry.swift b/Feedbridge/Models/StoolEntry.swift index bfcc8b7..cfffbe3 100644 --- a/Feedbridge/Models/StoolEntry.swift +++ b/Feedbridge/Models/StoolEntry.swift @@ -12,7 +12,6 @@ import Foundation // Represents stool volume classifications -// periphery:ignore enum StoolVolume: String, Codable { case light case medium diff --git a/Feedbridge/Models/WeightEntry.swift b/Feedbridge/Models/WeightEntry.swift index 0edddac..1bf78d4 100644 --- a/Feedbridge/Models/WeightEntry.swift +++ b/Feedbridge/Models/WeightEntry.swift @@ -11,7 +11,6 @@ @preconcurrency import FirebaseFirestore import Foundation -// periphery:ignore /// Stores weight measurements (accepts grams, kilograms, or pounds and ounces) struct WeightEntry: Identifiable, Codable, Sendable { @DocumentID var id: String? diff --git a/Feedbridge/Models/WetDiaperEntry.swift b/Feedbridge/Models/WetDiaperEntry.swift index 88f4ec5..f6e3ac8 100644 --- a/Feedbridge/Models/WetDiaperEntry.swift +++ b/Feedbridge/Models/WetDiaperEntry.swift @@ -12,7 +12,6 @@ import Foundation // Represents diaper volume classifications -// periphery:ignore enum DiaperVolume: String, Codable { case light case medium @@ -20,14 +19,12 @@ enum DiaperVolume: String, Codable { } // Represents color variations for wet diaper entries -// periphery:ignore enum WetDiaperColor: String, Codable { case yellow case pink case redTinged } -// periphery:ignore /// Stores wet diaper data struct WetDiaperEntry: Identifiable, Codable, Sendable { @DocumentID var id: String? diff --git a/Feedbridge/Resources/Localizable.xcstrings b/Feedbridge/Resources/Localizable.xcstrings index deb8960..cf43f4d 100644 --- a/Feedbridge/Resources/Localizable.xcstrings +++ b/Feedbridge/Resources/Localizable.xcstrings @@ -179,16 +179,6 @@ } } }, - "Contacts" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Contacts" - } - } - } - }, "Continue" : { }, @@ -305,6 +295,7 @@ }, "LELAND_STANFORD_BIO" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { diff --git a/Feedbridge/Views/AddBabyView.swift b/Feedbridge/Views/AddBabyView.swift index 3d54550..72c2971 100644 --- a/Feedbridge/Views/AddBabyView.swift +++ b/Feedbridge/Views/AddBabyView.swift @@ -8,13 +8,68 @@ // // SPDX-License-Identifier: MIT // -// swiftlint:disable closure_body_length import FirebaseFirestore import SpeziOnboarding import SpeziViews import SwiftUI +// Supporting view that's used by the main view +struct BabyFormRow: View { + let baby: (id: Int, baby: Baby) + @Binding var babies: [(id: Int, baby: Baby)] + let existingBabies: [Baby] + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + TextField("Baby's Name", text: Binding( + get: { baby.baby.name }, + set: { newValue in + if let index = babies.firstIndex(where: { $0.id == baby.id }) { + babies[index].baby.name = newValue + } + } + )) + .textFieldStyle(.roundedBorder) + + if !baby.baby.name.isEmpty && isDuplicateName(baby.baby.name) { + Text("This name is already taken") + .foregroundColor(.red) + .font(.caption) + } + + DatePicker( + "Date of Birth", + selection: Binding( + get: { baby.baby.dateOfBirth }, + set: { newValue in + if let index = babies.firstIndex(where: { $0.id == baby.id }) { + babies[index].baby.dateOfBirth = newValue + } + } + ), + in: ...Date(), + displayedComponents: [.date, .hourAndMinute] + ) + } + .padding() + .background(Color(.systemBackground)) + .cornerRadius(10) + .shadow(radius: 2) + } + + private func isDuplicateName(_ name: String) -> Bool { + let lowercaseName = name.lowercased() + + if existingBabies.contains(where: { $0.name.lowercased() == lowercaseName }) { + return true + } + + return babies.contains(where: { $0.id != baby.id && $0.baby.name.lowercased() == lowercaseName }) + } +} + +// Main view struct AddBabyView: View { @Environment(FeedbridgeStandard.self) private var standard @Environment(OnboardingNavigationPath.self) private var onboardingNavigationPath @@ -25,89 +80,49 @@ struct AddBabyView: View { @State private var errorMessage = "" @State private var existingBabies: [Baby] = [] @State private var isLoading = true + + private var hasDuplicateNames: Bool { + // Check for duplicates within new babies + let newBabyNames = babies.map { $0.baby.name.lowercased() } + if Set(newBabyNames).count != newBabyNames.count { + return true + } + + // Check against existing babies + let existingNames = Set(existingBabies.map { $0.name.lowercased() }) + return !newBabyNames.filter { !$0.isEmpty } + .allSatisfy { !existingNames.contains($0) } + } + + private var babyFormContent: some View { + VStack(spacing: 24) { + OnboardingTitleView( + title: "Add Your Baby", + subtitle: "Please enter your baby's information" + ) + + ForEach(babies, id: \.id) { baby in + BabyFormRow( + baby: baby, + babies: $babies, + existingBabies: existingBabies + ) + } + + Button { + nextId += 1 + babies.append((id: nextId, baby: Baby(name: "", dateOfBirth: Date()))) + } label: { + Label("Add Another Baby", systemImage: "plus.circle.fill") + } + .padding(.vertical) + } + } var body: some View { OnboardingView( - contentView: { - Group { - if isLoading { - ProgressView() - } else { - VStack(spacing: 24) { - OnboardingTitleView( - title: "Add Your Baby", - subtitle: "Please enter your baby's information" - ) - - ForEach(babies, id: \.id) { baby in - VStack(alignment: .leading, spacing: 16) { - TextField("Baby's Name", text: Binding( - get: { baby.baby.name }, - set: { newValue in - if let index = babies.firstIndex(where: { $0.id == baby.id }) { - babies[index].baby.name = newValue - } - } - )) - .textFieldStyle(.roundedBorder) - - if !baby.baby.name.isEmpty && isDuplicateName(baby.baby.name, forBabyId: baby.id) { - Text("This name is already taken") - .foregroundColor(.red) - .font(.caption) - } - - DatePicker( - "Date of Birth", - selection: Binding( - get: { baby.baby.dateOfBirth }, - set: { newValue in - if let index = babies.firstIndex(where: { $0.id == baby.id }) { - babies[index].baby.dateOfBirth = newValue - } - } - ), - in: ...Date(), - displayedComponents: [.date, .hourAndMinute] - ) - } - .padding() - .background(Color(.systemBackground)) - .cornerRadius(10) - .shadow(radius: 2) - } - - Button { - nextId += 1 - babies.append((id: nextId, baby: Baby(name: "", dateOfBirth: Date()))) - } label: { - Label("Add Another Baby", systemImage: "plus.circle.fill") - } - .padding(.vertical) - } - } - } - .padding() - }, - actionView: { - VStack { - OnboardingActionsView( - "Continue", - action: { - Task { - await saveBabies() - } - } - ) - .disabled(babies.contains(where: { $0.baby.name.isEmpty }) || hasDuplicateNames || isLoading) - - Button("Add babies later") { - onboardingNavigationPath.nextStep() - } - .buttonStyle(.automatic) - .padding(.top, 8) - } - } + contentView: { createContentView() }, + actionView: { createActionView() } ) .alert("Error", isPresented: $showAlert) { Button("OK", role: .cancel) {} @@ -118,18 +133,36 @@ struct AddBabyView: View { await loadExistingBabies() } } - - private var hasDuplicateNames: Bool { - // Check for duplicates within new babies - let newBabyNames = babies.map { $0.baby.name.lowercased() } - if Set(newBabyNames).count != newBabyNames.count { - return true + + private func createContentView() -> some View { + Group { + if isLoading { + ProgressView() + } else { + babyFormContent + } } + .padding() + } + + private func createActionView() -> some View { + VStack { + OnboardingActionsView( + "Continue", + action: { + Task { + await saveBabies() + } + } + ) + .disabled(babies.contains(where: { $0.baby.name.isEmpty }) || hasDuplicateNames || isLoading) - // Check against existing babies - let existingNames = Set(existingBabies.map { $0.name.lowercased() }) - return !newBabyNames.filter { !$0.isEmpty } - .allSatisfy { !existingNames.contains($0) } + Button("Add babies later") { + onboardingNavigationPath.nextStep() + } + .buttonStyle(.automatic) + .padding(.top, 8) + } } private func isDuplicateName(_ name: String, forBabyId id: Int) -> Bool { diff --git a/Feedbridge/Views/AddEntryView.swift b/Feedbridge/Views/AddEntryView.swift index 20c9fd5..2f36b97 100644 --- a/Feedbridge/Views/AddEntryView.swift +++ b/Feedbridge/Views/AddEntryView.swift @@ -10,6 +10,8 @@ // // swiftlint:disable closure_body_length // swiftlint:disable file_length +// +// Reaoning for swiftlint disable rules: https://github.com/orgs/CS342/discussions/181 import FirebaseFirestore import SwiftUI diff --git a/Feedbridge/Views/AddSingleBabyView.swift b/Feedbridge/Views/AddSingleBabyView.swift index d68b17e..7411f40 100644 --- a/Feedbridge/Views/AddSingleBabyView.swift +++ b/Feedbridge/Views/AddSingleBabyView.swift @@ -8,7 +8,6 @@ // // SPDX-License-Identifier: MIT // -// swiftlint:disable closure_body_length import SwiftUI @@ -27,48 +26,60 @@ struct AddSingleBabyView: View { var body: some View { NavigationStack { - Group { - if isLoading { - ProgressView() - } else { - Form { - VStack(alignment: .leading, spacing: 4) { - TextField("Baby's Name", text: $babyName) - if hasDuplicateName { - Text("This name is already taken") - .foregroundColor(.red) - .font(.caption) - } - } - DatePicker( - "Date of Birth", - selection: $dateOfBirth, - in: ...Date(), - displayedComponents: [.date, .hourAndMinute] - ) - } + contentView + .navigationTitle("Add Baby") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + toolbarContent } - } - .navigationTitle("Add Baby") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .confirmationAction) { - Button("Save") { - Task { - await saveBaby() - } - } - .disabled(babyName.isEmpty || isLoading || hasDuplicateName) + .alert("Error", isPresented: $showAlert) { + Button("OK", role: .cancel) {} + } message: { + Text(errorMessage) + } + .task { + await loadExistingBabies() } + } + } + + private var contentView: some View { + Group { + if isLoading { + ProgressView() + } else { + formContent } - .alert("Error", isPresented: $showAlert) { - Button("OK", role: .cancel) {} - } message: { - Text(errorMessage) + } + } + + private var formContent: some View { + Form { + VStack(alignment: .leading, spacing: 4) { + TextField("Baby's Name", text: $babyName) + if hasDuplicateName { + Text("This name is already taken") + .foregroundColor(.red) + .font(.caption) + } } - .task { - await loadExistingBabies() + DatePicker( + "Date of Birth", + selection: $dateOfBirth, + in: ...Date(), + displayedComponents: [.date, .hourAndMinute] + ) + } + } + + private var toolbarContent: some ToolbarContent { + ToolbarItem(placement: .confirmationAction) { + Button("Save") { + Task { + await saveBaby() + } } + .disabled(babyName.isEmpty || isLoading || hasDuplicateName) } } diff --git a/Feedbridge/Views/HealthDetailsView.swift b/Feedbridge/Views/HealthDetailsView.swift new file mode 100644 index 0000000..668c845 --- /dev/null +++ b/Feedbridge/Views/HealthDetailsView.swift @@ -0,0 +1,297 @@ +// +// SettingsEntriesView.swift +// Feedbridge +// +// Created by Shamit Surana on 3/13/25. +// +// SPDX-FileCopyrightText: 2025 Stanford University +// +// SPDX-License-Identifier: MIT +// + +import SwiftUI + +struct BasicInfoSection: View { + let baby: Baby + @Binding var weightUnitPreference: WeightUnit + + var body: some View { + Section("Basic Info") { + LabeledContent("Name", value: baby.name) + LabeledContent("Date of Birth", value: baby.dateOfBirth.formatted()) + LabeledContent("Age", value: "\(baby.ageInMonths) months") + } + } +} + +struct FeedEntriesSection: View { + let entries: [FeedEntry] + var babyId: String + var standard: FeedbridgeStandard + @State private var refreshID = UUID() // For forcing view refresh + + var body: some View { + Section("Feed Entries") { + ForEach(entries.sorted(by: { $0.dateTime > $1.dateTime })) { entry in + VStack(alignment: .leading) { + Text(entry.dateTime.formatted()) + .font(.caption) + Text("Type: \(entry.feedType.rawValue)") + if entry.feedType == .bottle { + Text("Milk Type: \(entry.milkType?.rawValue ?? "N/A")") + if let volume = entry.feedVolumeInML { + Text("Amount: \(volume)ml") + } + } else if let minutes = entry.feedTimeInMinutes { + Text("Duration: \(minutes) minutes") + } + } + .swipeActions { + Button(role: .destructive) { + Task { + if let entryId = entry.id { + try await standard.deleteFeedEntry(babyId: babyId, entryId: entryId) + // Force view refresh + refreshID = UUID() + } + } + } label: { + Label("Delete", systemImage: "trash") + } + } + } + .id(refreshID) // Force refresh when an item is deleted + } + } +} + +struct WeightEntriesSection: View { + let entries: [WeightEntry] + @Binding var weightUnitPreference: WeightUnit + var babyId: String + var standard: FeedbridgeStandard + @State private var refreshID = UUID() // For forcing view refresh + + var body: some View { + Section("Weight Entries") { + ForEach(entries.sorted(by: { $0.dateTime > $1.dateTime })) { entry in + VStack(alignment: .leading, spacing: 4) { + Text(entry.dateTime.formatted()) + .font(.caption) + .foregroundColor(.secondary) + Text( + "\(weightUnitPreference == .kilograms ? entry.asKilograms.value : entry.asPounds.value, specifier: "%.2f") \(weightUnitPreference == .kilograms ? "kg" : "lb")" + ) + .font(.body) + } + .swipeActions { + Button(role: .destructive) { + Task { + if let entryId = entry.id { + try await standard.deleteWeightEntry(babyId: babyId, entryId: entryId) + // Force view refresh + refreshID = UUID() + } + } + } label: { + Label("Delete", systemImage: "trash") + } + } + } + .id(refreshID) // Force refresh when an item is deleted + } + } +} + +struct StoolEntriesSection: View { + let entries: [StoolEntry] + var babyId: String + var standard: FeedbridgeStandard + @State private var refreshID = UUID() // For forcing view refresh + + var body: some View { + Section("Stool Entries") { + ForEach(entries.sorted(by: { $0.dateTime > $1.dateTime })) { entry in + VStack(alignment: .leading) { + Text(entry.dateTime.formatted()) + .font(.caption) + Text("Volume: \(entry.volume.rawValue)") + Text("Color: \(entry.color.rawValue)") + if entry.medicalAlert { + Text("⚠️ Medical Alert") + .foregroundColor(.red) + } + } + .swipeActions { + Button(role: .destructive) { + Task { + if let entryId = entry.id { + try await standard.deleteStoolEntry(babyId: babyId, entryId: entryId) + // Force view refresh + refreshID = UUID() + } + } + } label: { + Label("Delete", systemImage: "trash") + } + } + } + .id(refreshID) // Force refresh when an item is deleted + } + } +} + +struct WetDiaperEntriesSection: View { + let entries: [WetDiaperEntry] + var babyId: String + var standard: FeedbridgeStandard + @State private var refreshID = UUID() // For forcing view refresh + + var body: some View { + Section("Void Entries") { + ForEach(entries.sorted(by: { $0.dateTime > $1.dateTime })) { entry in + VStack(alignment: .leading) { + Text(entry.dateTime.formatted()) + .font(.caption) + Text("Volume: \(entry.volume.rawValue)") + Text("Color: \(entry.color.rawValue)") + if entry.dehydrationAlert { + Text("⚠️ Dehydration Alert") + .foregroundColor(.red) + } + } + .swipeActions { + Button(role: .destructive) { + Task { + if let entryId = entry.id { + try await standard.deleteWetDiaperEntry(babyId: babyId, entryId: entryId) + // Force view refresh + refreshID = UUID() + } + } + } label: { + Label("Delete", systemImage: "trash") + } + } + } + .id(refreshID) // Force refresh when an item is deleted + } + } +} + +struct DehydrationChecksSection: View { + let checks: [DehydrationCheck] + var babyId: String + var standard: FeedbridgeStandard + @State private var refreshID = UUID() // For forcing view refresh + + var body: some View { + Section("Dehydration Checks") { + ForEach(checks.sorted(by: { $0.dateTime > $1.dateTime })) { check in + VStack(alignment: .leading) { + Text(check.dateTime.formatted()) + .font(.caption) + Text("Poor Skin Elasticity: \(check.poorSkinElasticity ? "Yes" : "No")") + Text("Dry Mucous Membranes: \(check.dryMucousMembranes ? "Yes" : "No")") + if check.dehydrationAlert { + Text("⚠️ Dehydration Alert") + .foregroundColor(.red) + } + } + .swipeActions { + Button(role: .destructive) { + Task { + if let checkId = check.id { + try await standard.deleteDehydrationCheck(babyId: babyId, entryId: checkId) + // Force view refresh + refreshID = UUID() + } + } + } label: { + Label("Delete", systemImage: "trash") + } + } + } + .id(refreshID) // Force refresh when an item is deleted + } + } +} + + +struct HealthDetailsView: View { + // Use the shared viewModel instead of a direct baby reference + var viewModel: DashboardViewModel + @Binding var weightUnitPreference: WeightUnit + @AppStorage(UserDefaults.selectedBabyIdKey) private var selectedBabyId: String? + @State private var isRefreshing = false + @Environment(FeedbridgeStandard.self) private var standard + @State private var refreshID = UUID() // For forcing view refresh + + var body: some View { + Group { + if let baby = viewModel.baby { + List { + FeedEntriesSection( + entries: baby.feedEntries.feedEntries, babyId: baby.id ?? "", standard: standard + ) + WeightEntriesSection( + entries: baby.weightEntries.weightEntries, + weightUnitPreference: $weightUnitPreference, + babyId: baby.id ?? "", + standard: standard + ) + StoolEntriesSection( + entries: baby.stoolEntries.stoolEntries, babyId: baby.id ?? "", standard: standard + ) + WetDiaperEntriesSection( + entries: baby.wetDiaperEntries.wetDiaperEntries, + babyId: baby.id ?? "", + standard: standard + ) + DehydrationChecksSection( + checks: baby.dehydrationChecks.dehydrationChecks, + babyId: baby.id ?? "", + standard: standard + ) + } + .id(refreshID) // Force refresh when data changes + .refreshable { + await refreshData() + } + } else { + ProgressView() + } + } + .navigationTitle("Health Details") + .onAppear { + // Ensure we have the latest data when the view appears + if !isRefreshing { + Task { + await refreshData() + } + } + } + .onChange(of: viewModel.baby) { _, _ in + // When the baby data changes in the viewModel, update the refreshID + refreshID = UUID() + } + } + + private func refreshData() async { + isRefreshing = true + + // Stop and restart the listener to refresh all data + viewModel.stopListening() + if let id = selectedBabyId { + viewModel.startListening(babyId: id) + } + + // Add a small delay to ensure the UI shows the refresh indicator + try? await Task.sleep(nanoseconds: 500_000_000) // 0.5 seconds + + // Update the refreshID to force a view refresh + refreshID = UUID() + + isRefreshing = false + } +} diff --git a/Feedbridge/Views/SettingsView.swift b/Feedbridge/Views/SettingsView.swift index 23d078f..d2c2c7f 100644 --- a/Feedbridge/Views/SettingsView.swift +++ b/Feedbridge/Views/SettingsView.swift @@ -7,293 +7,9 @@ // // SPDX-License-Identifier: MIT // -// swiftlint:disable file_length import SwiftUI -private struct BasicInfoSection: View { - let baby: Baby - @Binding var weightUnitPreference: WeightUnit - - var body: some View { - Section("Basic Info") { - LabeledContent("Name", value: baby.name) - LabeledContent("Date of Birth", value: baby.dateOfBirth.formatted()) - LabeledContent("Age", value: "\(baby.ageInMonths) months") - } - } -} - -private struct FeedEntriesSection: View { - let entries: [FeedEntry] - var babyId: String - var standard: FeedbridgeStandard - @State private var refreshID = UUID() // For forcing view refresh - - var body: some View { - Section("Feed Entries") { - ForEach(entries.sorted(by: { $0.dateTime > $1.dateTime })) { entry in - VStack(alignment: .leading) { - Text(entry.dateTime.formatted()) - .font(.caption) - Text("Type: \(entry.feedType.rawValue)") - if entry.feedType == .bottle { - Text("Milk Type: \(entry.milkType?.rawValue ?? "N/A")") - if let volume = entry.feedVolumeInML { - Text("Amount: \(volume)ml") - } - } else if let minutes = entry.feedTimeInMinutes { - Text("Duration: \(minutes) minutes") - } - } - .swipeActions { - Button(role: .destructive) { - Task { - if let entryId = entry.id { - try await standard.deleteFeedEntry(babyId: babyId, entryId: entryId) - // Force view refresh - refreshID = UUID() - } - } - } label: { - Label("Delete", systemImage: "trash") - } - } - } - .id(refreshID) // Force refresh when an item is deleted - } - } -} - -private struct WeightEntriesSection: View { - let entries: [WeightEntry] - @Binding var weightUnitPreference: WeightUnit - var babyId: String - var standard: FeedbridgeStandard - @State private var refreshID = UUID() // For forcing view refresh - - var body: some View { - Section("Weight Entries") { - ForEach(entries.sorted(by: { $0.dateTime > $1.dateTime })) { entry in - VStack(alignment: .leading, spacing: 4) { - Text(entry.dateTime.formatted()) - .font(.caption) - .foregroundColor(.secondary) - Text( - "\(weightUnitPreference == .kilograms ? entry.asKilograms.value : entry.asPounds.value, specifier: "%.2f") \(weightUnitPreference == .kilograms ? "kg" : "lb")" - ) - .font(.body) - } - .swipeActions { - Button(role: .destructive) { - Task { - if let entryId = entry.id { - try await standard.deleteWeightEntry(babyId: babyId, entryId: entryId) - // Force view refresh - refreshID = UUID() - } - } - } label: { - Label("Delete", systemImage: "trash") - } - } - } - .id(refreshID) // Force refresh when an item is deleted - } - } -} - -private struct StoolEntriesSection: View { - let entries: [StoolEntry] - var babyId: String - var standard: FeedbridgeStandard - @State private var refreshID = UUID() // For forcing view refresh - - var body: some View { - Section("Stool Entries") { - ForEach(entries.sorted(by: { $0.dateTime > $1.dateTime })) { entry in - VStack(alignment: .leading) { - Text(entry.dateTime.formatted()) - .font(.caption) - Text("Volume: \(entry.volume.rawValue)") - Text("Color: \(entry.color.rawValue)") - if entry.medicalAlert { - Text("⚠️ Medical Alert") - .foregroundColor(.red) - } - } - .swipeActions { - Button(role: .destructive) { - Task { - if let entryId = entry.id { - try await standard.deleteStoolEntry(babyId: babyId, entryId: entryId) - // Force view refresh - refreshID = UUID() - } - } - } label: { - Label("Delete", systemImage: "trash") - } - } - } - .id(refreshID) // Force refresh when an item is deleted - } - } -} - -private struct WetDiaperEntriesSection: View { - let entries: [WetDiaperEntry] - var babyId: String - var standard: FeedbridgeStandard - @State private var refreshID = UUID() // For forcing view refresh - - var body: some View { - Section("Void Entries") { - ForEach(entries.sorted(by: { $0.dateTime > $1.dateTime })) { entry in - VStack(alignment: .leading) { - Text(entry.dateTime.formatted()) - .font(.caption) - Text("Volume: \(entry.volume.rawValue)") - Text("Color: \(entry.color.rawValue)") - if entry.dehydrationAlert { - Text("⚠️ Dehydration Alert") - .foregroundColor(.red) - } - } - .swipeActions { - Button(role: .destructive) { - Task { - if let entryId = entry.id { - try await standard.deleteWetDiaperEntry(babyId: babyId, entryId: entryId) - // Force view refresh - refreshID = UUID() - } - } - } label: { - Label("Delete", systemImage: "trash") - } - } - } - .id(refreshID) // Force refresh when an item is deleted - } - } -} - -private struct DehydrationChecksSection: View { - let checks: [DehydrationCheck] - var babyId: String - var standard: FeedbridgeStandard - @State private var refreshID = UUID() // For forcing view refresh - - var body: some View { - Section("Dehydration Checks") { - ForEach(checks.sorted(by: { $0.dateTime > $1.dateTime })) { check in - VStack(alignment: .leading) { - Text(check.dateTime.formatted()) - .font(.caption) - Text("Poor Skin Elasticity: \(check.poorSkinElasticity ? "Yes" : "No")") - Text("Dry Mucous Membranes: \(check.dryMucousMembranes ? "Yes" : "No")") - if check.dehydrationAlert { - Text("⚠️ Dehydration Alert") - .foregroundColor(.red) - } - } - .swipeActions { - Button(role: .destructive) { - Task { - if let checkId = check.id { - try await standard.deleteDehydrationCheck(babyId: babyId, entryId: checkId) - // Force view refresh - refreshID = UUID() - } - } - } label: { - Label("Delete", systemImage: "trash") - } - } - } - .id(refreshID) // Force refresh when an item is deleted - } - } -} - -struct HealthDetailsView: View { - // Use the shared viewModel instead of a direct baby reference - var viewModel: DashboardViewModel - @Binding var weightUnitPreference: WeightUnit - @AppStorage(UserDefaults.selectedBabyIdKey) private var selectedBabyId: String? - @State private var isRefreshing = false - @Environment(FeedbridgeStandard.self) private var standard - @State private var refreshID = UUID() // For forcing view refresh - - var body: some View { - Group { - if let baby = viewModel.baby { - List { - FeedEntriesSection( - entries: baby.feedEntries.feedEntries, babyId: baby.id ?? "", standard: standard - ) - WeightEntriesSection( - entries: baby.weightEntries.weightEntries, - weightUnitPreference: $weightUnitPreference, - babyId: baby.id ?? "", - standard: standard - ) - StoolEntriesSection( - entries: baby.stoolEntries.stoolEntries, babyId: baby.id ?? "", standard: standard - ) - WetDiaperEntriesSection( - entries: baby.wetDiaperEntries.wetDiaperEntries, - babyId: baby.id ?? "", - standard: standard - ) - DehydrationChecksSection( - checks: baby.dehydrationChecks.dehydrationChecks, - babyId: baby.id ?? "", - standard: standard - ) - } - .id(refreshID) // Force refresh when data changes - .refreshable { - await refreshData() - } - } else { - ProgressView() - } - } - .navigationTitle("Health Details") - .onAppear { - // Ensure we have the latest data when the view appears - if !isRefreshing { - Task { - await refreshData() - } - } - } - .onChange(of: viewModel.baby) { _, _ in - // When the baby data changes in the viewModel, update the refreshID - refreshID = UUID() - } - } - - private func refreshData() async { - isRefreshing = true - - // Stop and restart the listener to refresh all data - viewModel.stopListening() - if let id = selectedBabyId { - viewModel.startListening(babyId: id) - } - - // Add a small delay to ensure the UI shows the refresh indicator - try? await Task.sleep(nanoseconds: 500_000_000) // 0.5 seconds - - // Update the refreshID to force a view refresh - refreshID = UUID() - - isRefreshing = false - } -} struct Settings: View { @Environment(FeedbridgeStandard.self) private var standard diff --git a/FeedbridgeTests/FeedbridgeTests.swift b/FeedbridgeTests/FeedbridgeTests.swift index 464b676..9630c13 100644 --- a/FeedbridgeTests/FeedbridgeTests.swift +++ b/FeedbridgeTests/FeedbridgeTests.swift @@ -7,12 +7,155 @@ // @testable import Feedbridge -import XCTest +import Foundation +import Testing -class FeedbridgeTests: XCTestCase { - @MainActor - func testContactsCount() throws { - XCTAssertEqual(Contacts(presentingAccount: .constant(true)).contacts.count, 1) +@MainActor +struct FeedbridgeTests { + // MARK: - Baby Tests + + @Test + func testBabyInitialization() async throws { + let name = "Baby John" + let dateOfBirth = Date(timeIntervalSince1970: 1_600_000_000) // Some fixed date + let baby = Baby(name: name, dateOfBirth: dateOfBirth) + + #expect(baby.name == name, "Baby name should match the assigned value.") + #expect(baby.dateOfBirth == dateOfBirth, "Date of birth should match the assigned value.") + #expect(baby.feedEntries.feedEntries.isEmpty, "feedEntries should be empty by default.") + #expect(baby.weightEntries.weightEntries.isEmpty, "weightEntries should be empty by default.") + #expect(baby.stoolEntries.stoolEntries.isEmpty, "stoolEntries should be empty by default.") + #expect(baby.wetDiaperEntries.wetDiaperEntries.isEmpty, "wetDiaperEntries should be empty by default.") + #expect(baby.dehydrationChecks.dehydrationChecks.isEmpty, "dehydrationChecks should be empty by default.") + } + + @Test + func testBabyEqualityWithIDs() async throws { + // Both babies have the same id. + var babyA = Baby(name: "Baby A", dateOfBirth: .now) + var babyB = Baby(name: "Baby B", dateOfBirth: .now) + babyA.id = "same-id" + babyB.id = "same-id" + + #expect(babyA == babyB, "Babies should be equal when their IDs match.") + + // Different IDs + babyB.id = "different-id" + + #expect(babyA != babyB, "Babies should not be equal when their IDs differ.") + } + + @Test + func testBabyEqualityWithoutIDs() async throws { + // Babies have no IDs but the same name + DOB + let dateOfBirth = Date() + let babyA = Baby(name: "Baby A", dateOfBirth: dateOfBirth) + let babyB = Baby(name: "Baby A", dateOfBirth: dateOfBirth) + + #expect(babyA == babyB, "Babies should be equal when both ID are nil but name and dateOfBirth match.") + + // Different name + let babyC = Baby(name: "Baby C", dateOfBirth: dateOfBirth) + #expect(babyA != babyC, "Babies should not be equal when name differs and no IDs are set.") + } + + @Test + func testBabyAgeInMonths() async throws { + // Assuming "dateOfBirth" is 3 months ago from "now" in a simplified scenario + let threeMonthsAgo = Calendar.current.date(byAdding: .month, value: -3, to: Date()) ?? Date() + let baby = Baby(name: "Baby Age Test", dateOfBirth: threeMonthsAgo) + + #expect(baby.ageInMonths == 3, "Baby ageInMonths should be 3 (approximately).") + } + + @Test + func testBabyCurrentWeight() async throws { + let baby = Baby(name: "WeightTest", dateOfBirth: .now) + #expect(baby.currentWeight == nil, "currentWeight should be nil when no entries exist.") + + var weightEntries = WeightEntries(weightEntries: []) + weightEntries.weightEntries.append(WeightEntry( + grams: 3000, + dateTime: Date(timeIntervalSinceNow: -3600) + )) + weightEntries.weightEntries.append(WeightEntry( + grams: 3500, + dateTime: Date(timeIntervalSinceNow: -1800) + )) + weightEntries.weightEntries.append(WeightEntry( + grams: 3600, + dateTime: Date(timeIntervalSinceNow: -60) + )) + + var modifiableBaby = baby + modifiableBaby.weightEntries = weightEntries + + #expect(modifiableBaby.currentWeight?.weightInGrams == 3600, + "Most recent weight entry should be 3600 grams.") + } + + @Test + func testBabyLatestDehydrationCheck() async throws { + let baby = Baby(name: "DehydrationTest", dateOfBirth: .now) + #expect(baby.latestDehydrationCheck == nil, "Should be nil when no dehydration checks exist.") + + var dehydrationChecks = DehydrationChecks(dehydrationChecks: []) + let oldCheck = DehydrationCheck( + dateTime: Date(timeIntervalSinceNow: -3600), + poorSkinElasticity: false, + dryMucousMembranes: false + ) + let recentCheck = DehydrationCheck( + dateTime: Date(timeIntervalSinceNow: -300), + poorSkinElasticity: true, + dryMucousMembranes: true + ) + + dehydrationChecks.dehydrationChecks.append(oldCheck) + dehydrationChecks.dehydrationChecks.append(recentCheck) + + var modifiableBaby = baby + modifiableBaby.dehydrationChecks = dehydrationChecks + + #expect(modifiableBaby.latestDehydrationCheck?.dateTime == recentCheck.dateTime, + "Latest check should be the one with the greatest dateTime.") + } + + @Test + func testBabyHasActiveAlerts() async throws { + // Baby with no alerts + let babyNoAlerts = Baby(name: "NoAlerts", dateOfBirth: .now) + #expect(!babyNoAlerts.hasActiveAlerts, "Should have no active alerts initially.") + + // Baby with an active dehydration check + var babyDehydrationAlert = babyNoAlerts + let dehydratedCheck = DehydrationCheck( + dateTime: .now, + poorSkinElasticity: true, + dryMucousMembranes: false + ) + babyDehydrationAlert.dehydrationChecks = DehydrationChecks(dehydrationChecks: [dehydratedCheck]) + #expect(babyDehydrationAlert.hasActiveAlerts, "Should have an active alert from dehydration check.") + + // Baby with an active wet diaper alert + var babyWetDiaperAlert = babyNoAlerts + let wetDiaperAlert = WetDiaperEntry( + dateTime: .now, + volume: .heavy, + color: .redTinged + ) + babyWetDiaperAlert.wetDiaperEntries = WetDiaperEntries(wetDiaperEntries: [wetDiaperAlert]) + #expect(babyWetDiaperAlert.hasActiveAlerts, "Should have an active alert from wet diaper entry.") + + // Baby with an active stool alert + var babyStoolAlert = babyNoAlerts + let stoolAlert = StoolEntry( + dateTime: .now, + volume: .heavy, + color: .beige + ) + babyStoolAlert.stoolEntries = StoolEntries(stoolEntries: [stoolAlert]) + #expect(babyStoolAlert.hasActiveAlerts, "Should have an active alert from stool entry.") } } diff --git a/FeedbridgeTests/TestFeedbridgeStandard.swift b/FeedbridgeTests/TestFeedbridgeStandard.swift deleted file mode 100644 index 8e56c41..0000000 --- a/FeedbridgeTests/TestFeedbridgeStandard.swift +++ /dev/null @@ -1,352 +0,0 @@ -// // -// // TestFeedbridgeStandard.swift -// // Feedbridge -// // -// // Created by Calvin Xu on 3/10/25. -// // -// // SPDX-FileCopyrightText: 2025 Stanford University -// // -// // SPDX-License-Identifier: MIT -// // - -// import FirebaseAuth -// import Foundation -// import Testing - -// @testable import Feedbridge - -// /// These tests demonstrate integration with Firestore using the `FeedbridgeStandard` actor. -// /// -// /// 1. Firestore must be configured in the test environment (emulator or real Firebase project). -// /// 2. A test user must be signed in for these tests to succeed. -// /// - This file automatically creates and signs in a new test user if none is available. -// /// 3. Make sure `FeatureFlags.disableFirebase` is set to `false` if you want the Firestore writes. -// /// 4. If using an emulator, confirm your `FeedbridgeDelegate` is configured to point to your emulator settings. -// struct TestFeedbridgeStandard { -// private let standard = FeedbridgeStandard() - -// // MARK: - Test User Setup - -// /// Creates or reuses a test Firebase user for Firestore write operations. -// /// - Returns: The signed-in Firebase user. -// private func ensureTestUserIsSignedIn() async throws -> User { -// if let user = Auth.auth().currentUser { -// return user -// } -// // Generate a random test email to reduce collisions between runs -// let testEmail = "test\(UUID().uuidString.prefix(5))@example.com" -// let testPassword = "Test1234!" - -// do { -// let result = try await Auth.auth().createUser(withEmail: testEmail, password: testPassword) -// return result.user -// } catch { -// // If the user already exists or another issue arises, try sign in -// let signInResult = try await Auth.auth().signIn(withEmail: testEmail, password: testPassword) -// return signInResult.user -// } -// } - -// // MARK: - Helper: Create Test Baby - -// /// Creates a new `Baby` with a unique name for test usage. -// private func createTestBaby() -> Baby { -// Baby(name: "TestBaby-\(UUID().uuidString.prefix(5))", dateOfBirth: Date()) -// } - -// // MARK: - Tests - -// @Test -// func testAddAndRetrieveBaby() async throws { -// if FeatureFlags.disableFirebase { -// #expect(Bool(true), "Skipping test because Firebase is disabled.") -// return -// } - -// // Ensure we have a signed-in user -// let user = try await ensureTestUserIsSignedIn() -// #expect(Bool(!user.uid.isEmpty), "We have an authenticated user for Firestore writes.") - -// let testBaby = createTestBaby() - -// // 1) Add a baby -// try await standard.addBabies(babies: [testBaby]) - -// // 2) Retrieve all babies -// let babies = try await standard.getBabies() -// #expect( -// Bool(babies.contains { $0.name == testBaby.name }), -// "Expected to find newly added baby in the list." -// ) - -// // 3) Retrieve baby by ID -// guard let newlyAddedBaby = babies.first(where: { $0.name == testBaby.name }), -// let newlyAddedID = newlyAddedBaby.id -// else { -// #expect(Bool(false), "Newly added baby has no Firestore ID or wasn't found.") -// return -// } - -// let fetchedBaby = try await standard.getBaby(id: newlyAddedID) -// #expect( -// Bool(fetchedBaby?.name == testBaby.name), -// "Fetched baby name should match the one we created." -// ) - -// // 4) Cleanup: delete the test baby -// try await standard.deleteBaby(id: newlyAddedID) - -// let babiesAfterDelete = try await standard.getBabies() -// #expect( -// Bool(!babiesAfterDelete.contains(where: { $0.id == newlyAddedID })), -// "Expected the baby to be deleted from Firestore." -// ) -// } - -// @Test -// func testAddWeightEntryToBaby() async throws { -// if FeatureFlags.disableFirebase { -// #expect(Bool(true), "Skipping test because Firebase is disabled.") -// return -// } - -// let user = try await ensureTestUserIsSignedIn() -// #expect(Bool(!user.uid.isEmpty), "We have an authenticated user for Firestore writes.") - -// // 1) Create and add a test baby -// let testBaby = createTestBaby() -// try await standard.addBabies(babies: [testBaby]) - -// // 2) Retrieve the baby to confirm Firestore ID -// let babies = try await standard.getBabies() -// guard let newlyAddedBaby = babies.first(where: { $0.name == testBaby.name }), -// let babyId = newlyAddedBaby.id -// else { -// #expect(Bool(false), "Could not retrieve newly added baby from Firestore.") -// return -// } - -// // 3) Add a weight entry -// let weightEntry = WeightEntry(grams: 3500) -// try await standard.addWeightEntry(weightEntry, toBabyWithId: babyId) - -// // 4) Fetch baby details to confirm the weight entry was stored -// let fetchedBaby = try await standard.getBaby(id: babyId) -// #expect( -// Bool(fetchedBaby?.weightEntries.weightEntries.count == 1), -// "Baby should have exactly one weight entry." -// ) -// #expect( -// Bool(fetchedBaby?.weightEntries.weightEntries.first?.weightInGrams == 3500), -// "The weight entry value should match the one we saved." -// ) - -// // 5) Cleanup -// try await standard.deleteBaby(id: babyId) -// let babiesAfterDelete = try await standard.getBabies() -// #expect( -// Bool(!babiesAfterDelete.contains(where: { $0.id == babyId })), -// "Expected the baby to be deleted from Firestore." -// ) -// } - -// @Test -// func testAddAndDeleteFeedEntry() async throws { -// if FeatureFlags.disableFirebase { -// #expect(Bool(true), "Skipping test because Firebase is disabled.") -// return -// } - -// let user = try await ensureTestUserIsSignedIn() -// #expect(Bool(!user.uid.isEmpty), "We have an authenticated user for Firestore writes.") - -// // 1) Create and add a baby -// let testBaby = createTestBaby() -// try await standard.addBabies(babies: [testBaby]) - -// // 2) Retrieve the baby -// let babies = try await standard.getBabies() -// guard let newBaby = babies.first(where: { $0.name == testBaby.name }), -// let babyId = newBaby.id -// else { -// #expect(Bool(false), "Could not retrieve newly added baby from Firestore.") -// return -// } - -// // 3) Add a feed entry -// let feedEntry = FeedEntry(directBreastfeeding: 15) -// try await standard.addFeedEntry(feedEntry, toBabyWithId: babyId) - -// // 4) Verify the feed entry was stored -// let fetchedBaby = try await standard.getBaby(id: babyId) -// let feedCountBeforeDelete = fetchedBaby?.feedEntries.feedEntries.count ?? 0 -// #expect(Bool(feedCountBeforeDelete == 1), "Baby should have exactly one feed entry.") - -// // 5) Delete the feed entry -// guard let feedDocId = fetchedBaby?.feedEntries.feedEntries.first?.id else { -// #expect(Bool(false), "FeedEntry has no Firestore ID; cannot delete.") -// return -// } -// try await standard.deleteFeedEntry(babyId: babyId, entryId: feedDocId) - -// // 6) Validate removal -// let babyAfterFeedRemoval = try await standard.getBaby(id: babyId) -// let feedCountAfterDelete = babyAfterFeedRemoval?.feedEntries.feedEntries.count ?? 0 -// #expect(Bool(feedCountAfterDelete == 0), "Expected feed entry to be deleted from Firestore.") - -// // 7) Cleanup -// try await standard.deleteBaby(id: babyId) -// } - -// @Test -// func testAddAndDeleteStoolEntry() async throws { -// if FeatureFlags.disableFirebase { -// #expect(Bool(true), "Skipping test because Firebase is disabled.") -// return -// } - -// let user = try await ensureTestUserIsSignedIn() -// #expect(Bool(!user.uid.isEmpty), "We have an authenticated user for Firestore writes.") - -// // 1) Create and add a baby -// let testBaby = createTestBaby() -// try await standard.addBabies(babies: [testBaby]) - -// // 2) Retrieve the baby -// let babies = try await standard.getBabies() -// guard let newBaby = babies.first(where: { $0.name == testBaby.name }), -// let babyId = newBaby.id -// else { -// #expect(Bool(false), "Could not retrieve newly added baby from Firestore.") -// return -// } - -// // 3) Add a stool entry -// let stoolEntry = StoolEntry(dateTime: Date(), volume: .medium, color: .brown) -// try await standard.addStoolEntry(stoolEntry, toBabyWithId: babyId) - -// // 4) Verify the stool entry was stored -// let fetchedBaby = try await standard.getBaby(id: babyId) -// let stoolCountBeforeDelete = fetchedBaby?.stoolEntries.stoolEntries.count ?? 0 -// #expect(Bool(stoolCountBeforeDelete == 1), "Baby should have exactly one stool entry.") - -// // 5) Delete the stool entry -// guard let stoolDocId = fetchedBaby?.stoolEntries.stoolEntries.first?.id else { -// #expect(Bool(false), "StoolEntry has no Firestore ID; cannot delete.") -// return -// } -// try await standard.deleteStoolEntry(babyId: babyId, entryId: stoolDocId) - -// // 6) Validate removal -// let babyAfterStoolRemoval = try await standard.getBaby(id: babyId) -// let stoolCountAfterDelete = babyAfterStoolRemoval?.stoolEntries.stoolEntries.count ?? 0 -// #expect(Bool(stoolCountAfterDelete == 0), "Expected stool entry to be deleted from Firestore.") - -// // 7) Cleanup -// try await standard.deleteBaby(id: babyId) -// } - -// @Test -// func testAddAndDeleteWetDiaperEntry() async throws { -// if FeatureFlags.disableFirebase { -// #expect(Bool(true), "Skipping test because Firebase is disabled.") -// return -// } - -// let user = try await ensureTestUserIsSignedIn() -// #expect(Bool(!user.uid.isEmpty), "We have an authenticated user for Firestore writes.") - -// // 1) Create and add a baby -// let testBaby = createTestBaby() -// try await standard.addBabies(babies: [testBaby]) - -// // 2) Retrieve the baby -// let babies = try await standard.getBabies() -// guard let newBaby = babies.first(where: { $0.name == testBaby.name }), -// let babyId = newBaby.id -// else { -// #expect(Bool(false), "Could not retrieve newly added baby from Firestore.") -// return -// } - -// // 3) Add a wet diaper entry -// let wetDiaperEntry = WetDiaperEntry(dateTime: Date(), volume: .heavy, color: .pink) -// try await standard.addWetDiaperEntry(wetDiaperEntry, toBabyWithId: babyId) - -// // 4) Verify the wet diaper entry was stored -// let fetchedBaby = try await standard.getBaby(id: babyId) -// let diaperCountBeforeDelete = fetchedBaby?.wetDiaperEntries.wetDiaperEntries.count ?? 0 -// #expect(Bool(diaperCountBeforeDelete == 1), "Baby should have exactly one wet diaper entry.") - -// // 5) Delete the wet diaper entry -// guard let diaperDocId = fetchedBaby?.wetDiaperEntries.wetDiaperEntries.first?.id else { -// #expect(Bool(false), "WetDiaperEntry has no Firestore ID; cannot delete.") -// return -// } -// try await standard.deleteWetDiaperEntry(babyId: babyId, entryId: diaperDocId) - -// // 6) Validate removal -// let babyAfterDiaperRemoval = try await standard.getBaby(id: babyId) -// let diaperCountAfterDelete = -// babyAfterDiaperRemoval?.wetDiaperEntries.wetDiaperEntries.count ?? 0 -// #expect( -// Bool(diaperCountAfterDelete == 0), "Expected wet diaper entry to be deleted from Firestore.") - -// // 7) Cleanup -// try await standard.deleteBaby(id: babyId) -// } - -// @Test -// func testAddAndDeleteDehydrationCheck() async throws { -// if FeatureFlags.disableFirebase { -// #expect(Bool(true), "Skipping test because Firebase is disabled.") -// return -// } - -// let user = try await ensureTestUserIsSignedIn() -// #expect(Bool(!user.uid.isEmpty), "We have an authenticated user for Firestore writes.") - -// // 1) Create and add a baby -// let testBaby = createTestBaby() -// try await standard.addBabies(babies: [testBaby]) - -// // 2) Retrieve the baby -// let babies = try await standard.getBabies() -// guard let newBaby = babies.first(where: { $0.name == testBaby.name }), -// let babyId = newBaby.id -// else { -// #expect(Bool(false), "Could not retrieve newly added baby from Firestore.") -// return -// } - -// // 3) Add a dehydration check -// let check = DehydrationCheck( -// dateTime: Date(), poorSkinElasticity: true, dryMucousMembranes: false) -// try await standard.addDehydrationCheck(check, toBabyWithId: babyId) - -// // 4) Verify the dehydration check was stored -// let fetchedBaby = try await standard.getBaby(id: babyId) -// let checkCountBeforeDelete = fetchedBaby?.dehydrationChecks.dehydrationChecks.count ?? 0 -// #expect(Bool(checkCountBeforeDelete == 1), "Baby should have exactly one dehydration check.") - -// // 5) Delete the dehydration check -// guard let checkDocId = fetchedBaby?.dehydrationChecks.dehydrationChecks.first?.id else { -// #expect( -// Bool(false), -// "DehydrationCheck has no Firestore ID; cannot delete." -// ) -// return -// } -// try await standard.deleteDehydrationCheck(babyId: babyId, entryId: checkDocId) - -// // 6) Validate removal -// let babyAfterCheckRemoval = try await standard.getBaby(id: babyId) -// let checkCountAfterDelete = -// babyAfterCheckRemoval?.dehydrationChecks.dehydrationChecks.count ?? 0 -// #expect( -// Bool(checkCountAfterDelete == 0), "Expected dehydration check to be deleted from Firestore.") - -// // 7) Cleanup -// try await standard.deleteBaby(id: babyId) -// } -// } diff --git a/FeedbridgeTests/TestModels.swift b/FeedbridgeTests/TestModels.swift index c824512..c9bcff9 100644 --- a/FeedbridgeTests/TestModels.swift +++ b/FeedbridgeTests/TestModels.swift @@ -7,8 +7,6 @@ // SPDX-FileCopyrightText: 2025 Stanford University // // SPDX-License-Identifier: MIT -// -// swiftlint:disable type_body_length import Foundation import Testing @@ -16,152 +14,6 @@ import Testing @testable import Feedbridge struct TestModels { - // MARK: - Baby Tests - - @Test - func testBabyInitialization() async throws { - let name = "Baby John" - let dateOfBirth = Date(timeIntervalSince1970: 1_600_000_000) // Some fixed date - let baby = Baby(name: name, dateOfBirth: dateOfBirth) - - #expect(baby.name == name, "Baby name should match the assigned value.") - #expect(baby.dateOfBirth == dateOfBirth, "Date of birth should match the assigned value.") - #expect(baby.feedEntries.feedEntries.isEmpty, "feedEntries should be empty by default.") - #expect(baby.weightEntries.weightEntries.isEmpty, "weightEntries should be empty by default.") - #expect(baby.stoolEntries.stoolEntries.isEmpty, "stoolEntries should be empty by default.") - #expect(baby.wetDiaperEntries.wetDiaperEntries.isEmpty, "wetDiaperEntries should be empty by default.") - #expect(baby.dehydrationChecks.dehydrationChecks.isEmpty, "dehydrationChecks should be empty by default.") - } - - @Test - func testBabyEqualityWithIDs() async throws { - // Both babies have the same id. - var babyA = Baby(name: "Baby A", dateOfBirth: .now) - var babyB = Baby(name: "Baby B", dateOfBirth: .now) - babyA.id = "same-id" - babyB.id = "same-id" - - #expect(babyA == babyB, "Babies should be equal when their IDs match.") - - // Different IDs - babyB.id = "different-id" - - #expect(babyA != babyB, "Babies should not be equal when their IDs differ.") - } - - @Test - func testBabyEqualityWithoutIDs() async throws { - // Babies have no IDs but the same name + DOB - let dateOfBirth = Date() - let babyA = Baby(name: "Baby A", dateOfBirth: dateOfBirth) - let babyB = Baby(name: "Baby A", dateOfBirth: dateOfBirth) - - #expect(babyA == babyB, "Babies should be equal when both ID are nil but name and dateOfBirth match.") - - // Different name - let babyC = Baby(name: "Baby C", dateOfBirth: dateOfBirth) - #expect(babyA != babyC, "Babies should not be equal when name differs and no IDs are set.") - } - - @Test - func testBabyAgeInMonths() async throws { - // Assuming "dateOfBirth" is 3 months ago from "now" in a simplified scenario - let threeMonthsAgo = Calendar.current.date(byAdding: .month, value: -3, to: Date()) ?? Date() - let baby = Baby(name: "Baby Age Test", dateOfBirth: threeMonthsAgo) - - #expect(baby.ageInMonths == 3, "Baby ageInMonths should be 3 (approximately).") - } - - @Test - func testBabyCurrentWeight() async throws { - let baby = Baby(name: "WeightTest", dateOfBirth: .now) - #expect(baby.currentWeight == nil, "currentWeight should be nil when no entries exist.") - - var weightEntries = WeightEntries(weightEntries: []) - weightEntries.weightEntries.append(WeightEntry( - grams: 3000, - dateTime: Date(timeIntervalSinceNow: -3600) - )) - weightEntries.weightEntries.append(WeightEntry( - grams: 3500, - dateTime: Date(timeIntervalSinceNow: -1800) - )) - weightEntries.weightEntries.append(WeightEntry( - grams: 3600, - dateTime: Date(timeIntervalSinceNow: -60) - )) - - var modifiableBaby = baby - modifiableBaby.weightEntries = weightEntries - - #expect(modifiableBaby.currentWeight?.weightInGrams == 3600, - "Most recent weight entry should be 3600 grams.") - } - - @Test - func testBabyLatestDehydrationCheck() async throws { - let baby = Baby(name: "DehydrationTest", dateOfBirth: .now) - #expect(baby.latestDehydrationCheck == nil, "Should be nil when no dehydration checks exist.") - - var dehydrationChecks = DehydrationChecks(dehydrationChecks: []) - let oldCheck = DehydrationCheck( - dateTime: Date(timeIntervalSinceNow: -3600), - poorSkinElasticity: false, - dryMucousMembranes: false - ) - let recentCheck = DehydrationCheck( - dateTime: Date(timeIntervalSinceNow: -300), - poorSkinElasticity: true, - dryMucousMembranes: true - ) - - dehydrationChecks.dehydrationChecks.append(oldCheck) - dehydrationChecks.dehydrationChecks.append(recentCheck) - - var modifiableBaby = baby - modifiableBaby.dehydrationChecks = dehydrationChecks - - #expect(modifiableBaby.latestDehydrationCheck?.dateTime == recentCheck.dateTime, - "Latest check should be the one with the greatest dateTime.") - } - - @Test - func testBabyHasActiveAlerts() async throws { - // Baby with no alerts - let babyNoAlerts = Baby(name: "NoAlerts", dateOfBirth: .now) - #expect(!babyNoAlerts.hasActiveAlerts, "Should have no active alerts initially.") - - // Baby with an active dehydration check - var babyDehydrationAlert = babyNoAlerts - let dehydratedCheck = DehydrationCheck( - dateTime: .now, - poorSkinElasticity: true, - dryMucousMembranes: false - ) - babyDehydrationAlert.dehydrationChecks = DehydrationChecks(dehydrationChecks: [dehydratedCheck]) - #expect(babyDehydrationAlert.hasActiveAlerts, "Should have an active alert from dehydration check.") - - // Baby with an active wet diaper alert - var babyWetDiaperAlert = babyNoAlerts - let wetDiaperAlert = WetDiaperEntry( - dateTime: .now, - volume: .heavy, - color: .redTinged - ) - babyWetDiaperAlert.wetDiaperEntries = WetDiaperEntries(wetDiaperEntries: [wetDiaperAlert]) - #expect(babyWetDiaperAlert.hasActiveAlerts, "Should have an active alert from wet diaper entry.") - - // Baby with an active stool alert - var babyStoolAlert = babyNoAlerts - let stoolAlert = StoolEntry( - dateTime: .now, - volume: .heavy, - color: .beige - ) - babyStoolAlert.stoolEntries = StoolEntries(stoolEntries: [stoolAlert]) - #expect(babyStoolAlert.hasActiveAlerts, "Should have an active alert from stool entry.") - } - // MARK: - DehydrationCheck Tests @Test diff --git a/FeedbridgeUITests/AddEntryTests.swift b/FeedbridgeUITests/AddEntryTests.swift index 62459e1..5b004c2 100644 --- a/FeedbridgeUITests/AddEntryTests.swift +++ b/FeedbridgeUITests/AddEntryTests.swift @@ -171,12 +171,14 @@ final class AddEntryTests: XCTestCase { // Fill in pounds and ounces let poundsField = app.textFields["Pounds"] - let ouncesField = app.textFields["Ounces"] + XCTAssertTrue(poundsField.waitForExistence(timeout: 2), "Pounds text field not found.") - XCTAssertTrue(ouncesField.waitForExistence(timeout: 2), "Ounces text field not found.") poundsField.tap() poundsField.typeText("7") + + let ouncesField = app.textFields["Ounces"] + XCTAssertTrue(ouncesField.waitForExistence(timeout: 2), "Ounces text field not found.") ouncesField.tap() ouncesField.typeText("5.5") diff --git a/FeedbridgeUITests/ContactsTests.swift b/FeedbridgeUITests/ContactsTests.swift deleted file mode 100644 index 1fac156..0000000 --- a/FeedbridgeUITests/ContactsTests.swift +++ /dev/null @@ -1,39 +0,0 @@ -// // -// // This source file is part of the Feedbridge based on the Stanford Spezi Template Application project -// // -// // SPDX-FileCopyrightText: 2025 Stanford University -// // -// // SPDX-License-Identifier: MIT -// // - -// import XCTest - -// class ContactsTests: XCTestCase { -// @MainActor -// override func setUp() async throws { -// continueAfterFailure = false - -// let app = XCUIApplication() -// app.launchArguments = ["--skipOnboarding"] -// app.launch() -// } - -// @MainActor -// func testContacts() throws { -// let app = XCUIApplication() - -// XCTAssertTrue(app.wait(for: .runningForeground, timeout: 2.0)) - -// XCTAssertTrue(app.tabBars["Tab Bar"].buttons["Contacts"].exists) -// app.tabBars["Tab Bar"].buttons["Contacts"].tap() - -// XCTAssertTrue(app.staticTexts["Contact: Leland Stanford"].waitForExistence(timeout: 2)) - -// XCTAssertTrue(app.buttons["Call"].exists) -// XCTAssertTrue(app.buttons["Text"].exists) -// XCTAssertTrue(app.buttons["Email"].exists) -// XCTAssertTrue(app.buttons["Website"].exists) - -// XCTAssertTrue(app.buttons["Address: 450 Serra Mall\nStanford CA 94305\nUSA"].exists) -// } -// } diff --git a/FeedbridgeUITests/OnboardingTests.swift b/FeedbridgeUITests/OnboardingTests.swift deleted file mode 100644 index 3830e2b..0000000 --- a/FeedbridgeUITests/OnboardingTests.swift +++ /dev/null @@ -1,200 +0,0 @@ -// // -// // This source file is part of the Feedbridge based on the Stanford Spezi Template Application project -// // -// // SPDX-FileCopyrightText: 2025 Stanford University -// // -// // SPDX-License-Identifier: MIT -// // - -// import XCTest -// import XCTestExtensions -// import XCTHealthKit -// import XCTSpeziAccount -// import XCTSpeziNotifications - -// class OnboardingTests: XCTestCase { -// @MainActor -// override func setUp() async throws { -// continueAfterFailure = false - -// let app = XCUIApplication() -// app.launchArguments = ["--showOnboarding"] -// app.deleteAndLaunch(withSpringboardAppName: "Feedbridge") -// } - -// @MainActor -// func testOnboardingFlow() throws { -// let app = XCUIApplication() -// let email = "leland@onboarding.stanford.edu" - -// try app.navigateOnboardingFlow(email: email) - -// app.assertOnboardingComplete() -// try app.assertAccountInformation(email: email) -// } - -// @MainActor -// func testOnboardingFlowRepeated() throws { -// let app = XCUIApplication() -// app.launchArguments = ["--showOnboarding", "--disableFirebase"] -// app.terminate() -// app.launch() - -// try app.navigateOnboardingFlow() -// app.assertOnboardingComplete() - -// app.terminate() - -// // Second onboarding round shouldn't display HealthKit and Notification authorizations anymore -// app.activate() - -// try app.navigateOnboardingFlow(repeated: true) -// // Do not show HealthKit and Notification authorization view again -// app.assertOnboardingComplete() -// } -// } - -// extension XCUIApplication { -// fileprivate func navigateOnboardingFlow( -// email: String = "leland@stanford.edu", -// repeated skippedIfRepeated: Bool = false -// ) throws { -// try navigateOnboardingFlowWelcome() -// try navigateOnboardingFlowInterestingModules() -// if staticTexts["Your Account"].waitForExistence(timeout: 2.0) { -// try navigateOnboardingAccount(email: email) -// } -// if staticTexts["Consent"].waitForExistence(timeout: 2.0) { -// try navigateOnboardingFlowConsent() -// } -// if !skippedIfRepeated { -// try navigateOnboardingFlowHealthKitAccess() -// try navigateOnboardingFlowNotification() -// } -// } - -// private func navigateOnboardingFlowWelcome() throws { -// XCTAssertTrue(staticTexts["Spezi\nFeedbridge"].waitForExistence(timeout: 5)) - -// XCTAssertTrue(buttons["Learn More"].exists) -// buttons["Learn More"].tap() -// } - -// private func navigateOnboardingFlowInterestingModules() throws { -// XCTAssertTrue(staticTexts["Interesting Modules"].waitForExistence(timeout: 5)) - -// for _ in 1..<4 { -// XCTAssertTrue(buttons["Next"].waitForExistence(timeout: 2)) -// buttons["Next"].tap() -// } - -// XCTAssertTrue(buttons["Next"].waitForExistence(timeout: 2)) -// buttons["Next"].tap() -// } - -// private func navigateOnboardingAccount(email: String) throws { -// if buttons["Logout"].exists { -// buttons["Logout"].tap() -// } - -// XCTAssertTrue(buttons["Signup"].exists) -// buttons["Signup"].tap() - -// XCTAssertTrue(staticTexts["Create a new Account"].waitForExistence(timeout: 2)) - -// try fillSignupForm(email: email, password: "StanfordRocks", name: PersonNameComponents(givenName: "Leland", familyName: "Stanford")) - -// XCTAssertTrue(collectionViews.buttons["Signup"].exists) -// collectionViews.buttons["Signup"].tap() - -// if staticTexts["Consent"].waitForExistence(timeout: 4.0) && navigationBars.buttons["Back"].exists { -// navigationBars.buttons["Back"].tap() - -// XCTAssertTrue(staticTexts["Leland Stanford"].waitForExistence(timeout: 2)) -// XCTAssertTrue(staticTexts[email].exists) - -// XCTAssertTrue(buttons["Next"].exists) -// buttons["Next"].tap() -// } -// } - -// private func navigateOnboardingFlowConsent() throws { -// XCTAssertTrue(staticTexts["Consent"].waitForExistence(timeout: 2)) - -// XCTAssertTrue(staticTexts["First Name"].exists) -// try textFields["Enter your first name ..."].enter(value: "Leland") - -// XCTAssertTrue(staticTexts["Last Name"].exists) -// try textFields["Enter your last name ..."].enter(value: "Stanford") - -// XCTAssertTrue(scrollViews["Signature Field"].exists) -// scrollViews["Signature Field"].swipeRight() - -// XCTAssertTrue(buttons["I Consent"].exists) -// buttons["I Consent"].tap() -// } - -// private func navigateOnboardingFlowHealthKitAccess() throws { -// XCTAssertTrue(staticTexts["HealthKit Access"].waitForExistence(timeout: 5)) - -// XCTAssertTrue(buttons["Grant Access"].exists) -// buttons["Grant Access"].tap() - -// try handleHealthKitAuthorization() -// } - -// private func navigateOnboardingFlowNotification() throws { -// XCTAssertTrue(staticTexts["Notifications"].waitForExistence(timeout: 5)) - -// XCTAssertTrue(buttons["Allow Notifications"].exists) -// buttons["Allow Notifications"].tap() - -// confirmNotificationAuthorization(action: .allow) -// } - -// fileprivate func assertOnboardingComplete() { -// let tabBar = tabBars["Tab Bar"] -// XCTAssertTrue(tabBar.buttons["Schedule"].waitForExistence(timeout: 2)) -// XCTAssertTrue(tabBar.buttons["Contacts"].exists) -// } - -// fileprivate func assertAccountInformation(email: String) throws { -// XCTAssertTrue(navigationBars.buttons["Your Account"].waitForExistence(timeout: 2)) -// navigationBars.buttons["Your Account"].tap() - -// XCTAssertTrue(staticTexts["Account Overview"].waitForExistence(timeout: 5.0)) -// XCTAssertTrue(staticTexts["Leland Stanford"].exists) -// XCTAssertTrue(staticTexts[email].exists) -// XCTAssertTrue(staticTexts["Gender Identity, Choose not to answer"].exists) - -// XCTAssertTrue(navigationBars.buttons["Close"].waitForExistence(timeout: 0.5)) -// navigationBars.buttons["Close"].tap() - -// XCTAssertTrue(navigationBars.buttons["Your Account"].waitForExistence(timeout: 2)) -// navigationBars.buttons["Your Account"].tap() - -// XCTAssertTrue(navigationBars.buttons["Edit"].waitForExistence(timeout: 2)) -// navigationBars.buttons["Edit"].tap() - -// XCTAssertTrue(navigationBars.buttons["Close"].waitForNonExistence(timeout: 0.5)) - -// XCTAssertTrue(buttons["Delete Account"].waitForExistence(timeout: 2)) -// buttons["Delete Account"].tap() - -// let alert = "Are you sure you want to delete your account?" -// XCTAssertTrue(alerts[alert].waitForExistence(timeout: 6.0)) -// alerts[alert].buttons["Delete"].tap() - -// XCTAssertTrue(alerts["Authentication Required"].waitForExistence(timeout: 2.0)) -// XCTAssertTrue(alerts["Authentication Required"].secureTextFields["Password"].waitForExistence(timeout: 0.5)) -// typeText("StanfordRocks") // the password field has focus already -// XCTAssertTrue(alerts["Authentication Required"].buttons["Login"].waitForExistence(timeout: 0.5)) -// alerts["Authentication Required"].buttons["Login"].tap() - -// sleep(2) - -// try login(email: email, password: "StanfordRocks") - -// XCTAssertTrue(alerts["Invalid Credentials"].waitForExistence(timeout: 2.0)) -// } -// } From 4d2f36ee2b8532af86597ae56223f25d47ed5670 Mon Sep 17 00:00:00 2001 From: Shamit Surana Date: Thu, 13 Mar 2025 16:25:04 -0700 Subject: [PATCH 3/7] periphery update --- Feedbridge/Views/AddBabyView.swift | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/Feedbridge/Views/AddBabyView.swift b/Feedbridge/Views/AddBabyView.swift index 72c2971..2faa051 100644 --- a/Feedbridge/Views/AddBabyView.swift +++ b/Feedbridge/Views/AddBabyView.swift @@ -165,16 +165,6 @@ struct AddBabyView: View { } } - private func isDuplicateName(_ name: String, forBabyId id: Int) -> Bool { - let lowercaseName = name.lowercased() - - if existingBabies.contains(where: { $0.name.lowercased() == lowercaseName }) { - return true - } - - return babies.contains(where: { $0.id != id && $0.baby.name.lowercased() == lowercaseName }) - } - private func loadExistingBabies() async { do { existingBabies = try await standard.getBabies() From 122cf55b9f24d9a13c99d92d3438f2855d70b63c Mon Sep 17 00:00:00 2001 From: Shamit Surana Date: Thu, 13 Mar 2025 16:27:38 -0700 Subject: [PATCH 4/7] changed project.pbxproj back to normal --- Feedbridge.xcodeproj/project.pbxproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Feedbridge.xcodeproj/project.pbxproj b/Feedbridge.xcodeproj/project.pbxproj index 391307b..b50d13e 100644 --- a/Feedbridge.xcodeproj/project.pbxproj +++ b/Feedbridge.xcodeproj/project.pbxproj @@ -934,7 +934,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = ""; - DEVELOPMENT_TEAM = Y6WUS7R97A; + DEVELOPMENT_TEAM = ""; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = "Feedbridge/Supporting Files/Info.plist"; From 5c8541fae5d73e69d8040ac532cd971c9c449d55 Mon Sep 17 00:00:00 2001 From: Shamit Surana Date: Thu, 13 Mar 2025 22:42:19 -0700 Subject: [PATCH 5/7] updated testing to fix bug --- FeedbridgeUITests/AddBabyTests.swift | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/FeedbridgeUITests/AddBabyTests.swift b/FeedbridgeUITests/AddBabyTests.swift index a5863e4..30d7135 100644 --- a/FeedbridgeUITests/AddBabyTests.swift +++ b/FeedbridgeUITests/AddBabyTests.swift @@ -79,6 +79,14 @@ class AddBabyTests: XCTestCase { XCTAssertTrue(saveButton.isEnabled, "Save button should be enabled when valid data is entered") // Save the baby data + // Make sure the Save button is visible and hittable + if !saveButton.isHittable { + app.swipeUp() // Try scrolling if button isn't visible + XCTAssertTrue(saveButton.waitForExistence(timeout: 2), "Save button should be visible after scrolling") + } + + // Save the baby data with a single tap + XCTAssertTrue(saveButton.isHittable, "Save button should be hittable") saveButton.tap() // Verify that the new baby is correctly added and displayed in the UI From 1caea74a6cd415f0815f078ef2e83170ad7e1075 Mon Sep 17 00:00:00 2001 From: Shamit Surana Date: Fri, 14 Mar 2025 09:52:05 -0700 Subject: [PATCH 6/7] fixed tests --- Feedbridge/Views/Dashboard/DehydrationCharts.swift | 1 + FeedbridgeUITests/AddEntryTests.swift | 1 - FeedbridgeUITests/DehydrationTests.swift | 2 +- 3 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Feedbridge/Views/Dashboard/DehydrationCharts.swift b/Feedbridge/Views/Dashboard/DehydrationCharts.swift index d7f9308..69b689b 100644 --- a/Feedbridge/Views/Dashboard/DehydrationCharts.swift +++ b/Feedbridge/Views/Dashboard/DehydrationCharts.swift @@ -104,6 +104,7 @@ struct DehydrationSummaryView: View { summaryCard() } .buttonStyle(PlainButtonStyle()) + .accessibilityIdentifier("dehydrationSummaryView") } private func summaryCard() -> some View { diff --git a/FeedbridgeUITests/AddEntryTests.swift b/FeedbridgeUITests/AddEntryTests.swift index 2d32c32..efa3168 100644 --- a/FeedbridgeUITests/AddEntryTests.swift +++ b/FeedbridgeUITests/AddEntryTests.swift @@ -450,7 +450,6 @@ final class AddEntryTests: XCTestCase { // The formCheck would return error for 0 => "Invalid feed time (minutes)." let confirmButton = app.buttons["Confirm"] - XCTAssertTrue(!confirmButton.isEnabled, "Button should not be enabled") confirmButton.tap() diff --git a/FeedbridgeUITests/DehydrationTests.swift b/FeedbridgeUITests/DehydrationTests.swift index c1283c4..b1576d7 100644 --- a/FeedbridgeUITests/DehydrationTests.swift +++ b/FeedbridgeUITests/DehydrationTests.swift @@ -35,7 +35,7 @@ class DehydrationTests: XCTestCase { XCTAssertTrue(nav.exists, "Dehydration symptoms navigation should exist") // Tap on the first dehydration entry - let button = app.buttons["Heart icon, Dehydration Symptoms, Next page, 3/9/25, 3/10/25, 3/11/25, 3/12/25, 3/13/25"] + let button = app.buttons["dehydrationSummaryView"] button.tap() // Check if "Dehydration Symptoms" title and sample dehydration alert exist From 2a5de62d191c4e9abbba24b91ecc81f7d956a32f Mon Sep 17 00:00:00 2001 From: Calvin Xu Date: Fri, 14 Mar 2025 12:07:11 -0700 Subject: [PATCH 7/7] Larger timeout in AddBabyTests.swift --- FeedbridgeUITests/AddBabyTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/FeedbridgeUITests/AddBabyTests.swift b/FeedbridgeUITests/AddBabyTests.swift index 30d7135..4dd9986 100644 --- a/FeedbridgeUITests/AddBabyTests.swift +++ b/FeedbridgeUITests/AddBabyTests.swift @@ -54,7 +54,7 @@ class AddBabyTests: XCTestCase { let dropdown = app.buttons["Baby icon, Select Baby, Menu dropdown"] dropdown.tap() let addNew = app.buttons["Add New Baby"] - XCTAssertTrue(addNew.waitForExistence(timeout: 5), "Should be an option to add a baby") + XCTAssertTrue(addNew.waitForExistence(timeout: 10), "Should be an option to add a baby") addNew.tap() // Ensure that the Save button is initially disabled