diff --git a/.allstar/binary_artifacts.yaml b/.allstar/binary_artifacts.yaml new file mode 100644 index 000000000..e940d3d12 --- /dev/null +++ b/.allstar/binary_artifacts.yaml @@ -0,0 +1,4 @@ +# Exemption reason: This repo uses binary artifacts for gradle builds +# Exemption timeframe: permanent +optConfig: + optOut: true diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 000000000..2bbc1cc85 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,36 @@ +// https://aka.ms/devcontainer.json +{ + "name": "Android FHIR, by Google", + + // https://github.com/devcontainers/templates/tree/main/src/java + "image": "mcr.microsoft.com/devcontainers/java:1-17-bookworm", + + "features": { + // https://github.com/akhildevelops/devcontainer-features/issues/7 + "ghcr.io/akhildevelops/devcontainer-features/android-cli:0": {}, + "ghcr.io/devcontainers-contrib/features/pipenv:2": {} + + // https://github.com/CASL0/devcontainer-features/issues/4 + // "ghcr.io/casl0/devcontainer-features/android-sdk:1": {} + + // https://github.com/NordcomInc/devcontainer-features/issues/20 + // "ghcr.io/nordcominc/devcontainer-features/android-sdk:1": { + // "platform": "33", + // "build_tools": "33.0.1" + // } + }, + + // Use 'forwardPorts' to make a list of ports inside the container available locally. + // "forwardPorts": [], + + // Use 'postCreateCommand' to run commands after the container is created. + // See https://github.com/google/android-fhir/issues/2614, which is blocked by + // https://github.com/akhildevelops/devcontainer-features/issues/9. + "postCreateCommand": "sudo chown -R vscode:vscode /opt/android/" + + // Configure tool-specific properties. + // "customizations": {}, + + // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. + // "remoteUser": "root" +} diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 000000000..2377cc2a5 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,39 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: 'bug' +assignees: '' + +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**Component** +Core library, SDC library, reference app, or SDC gallery app + +**To Reproduce** +Steps to reproduce the behavior: +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Screenshots** +If applicable, add screenshots to help explain your problem. + +**Smartphone (please complete the following information):** + - Device: [e.g. Pixel4a emulator] + - Android version: [e.g. Settings -> About phone -> Android version] + - Build number: [e.g. Settings -> About phone -> Build number] + - Version [e.g. 22] + +**Additional context** +Add any other context about the problem here. + +**Would you like to work on the issue?** +Please state if this issue should be assigned to you or who you think could help to solve this issue. \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/code_health.md b/.github/ISSUE_TEMPLATE/code_health.md new file mode 100644 index 000000000..a78c110ec --- /dev/null +++ b/.github/ISSUE_TEMPLATE/code_health.md @@ -0,0 +1,12 @@ +--- +name: Code health +about: Code refactoring, testing infrastructure, documentation, etc. +title: '' +labels: 'code health' +assignees: '' + +--- + +**Describe the Issue** + +**Would you like to work on the issue?** diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 000000000..627805a97 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,23 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: '' +labels: 'enhancement' +assignees: '' + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. + +**Would you like to work on the issue?** +Please state if this issue should be assigned to you or who you think could help to solve this issue. \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/process.md b/.github/ISSUE_TEMPLATE/process.md new file mode 100644 index 000000000..f5c080c29 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/process.md @@ -0,0 +1,12 @@ +--- +name: Process +about: Builds, releases, communications, etc. +title: '' +labels: 'process' +assignees: '' + +--- + +**Describe the Issue** + +**Would you like to work on the issue?** diff --git a/.github/ISSUE_TEMPLATE/research.md b/.github/ISSUE_TEMPLATE/research.md new file mode 100644 index 000000000..b09de1ac5 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/research.md @@ -0,0 +1,19 @@ +--- +name: Research +about: Research tasks +title: '' +labels: 'research' +assignees: '' + +--- + +**Describe the issue to be researched** +Include any background information and available resources. + +**Describe the goal of the research** +What's the desired outcome of this task? What artifacts should be produced? + +**Describe the methodology** +Where can more information be found? Who should the assignee approach to ask questions? How can a decision be made? + +**Would you like to work on the issue?** diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 000000000..a14d5aea5 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,24 @@ +**IMPORTANT: All PRs must be linked to an issue (except for extremely trivial and straightforward changes).** + +Fixes #[issue number] + +**Description** +Clear and concise code change description. + +**Alternative(s) considered** +Have you considered any alternatives? And if so, why have you chosen the approach in this PR? + +**Type** +Choose one: (Bug fix | Feature | Documentation | Testing | Code health | Builds | Releases | Other) + +**Screenshots (if applicable)** + +**Checklist** + +- [ ] I have read and acknowledged the [Code of conduct](https://github.com/google/android-fhir/blob/master/CODE_OF_CONDUCT.md). +- [ ] I have read the [Contributing](https://google.github.io/android-fhir/contrib/) page. +- [ ] I have signed the Google [Individual CLA](https://cla.developers.google.com/about/google-individual), or I am covered by my company's [Corporate CLA](https://cla.developers.google.com/about/google-corporate ). +- [ ] I have discussed my proposed solution with code owners in the linked issue(s) and we have agreed upon the general approach. +- [ ] I have run `./gradlew spotlessApply` and `./gradlew spotlessCheck` to check my code follows the style guide of this project. +- [ ] I have run `./gradlew check` and `./gradlew connectedCheck` to test my changes locally. +- [ ] I have built and run the demo app(s) to verify my change fixes the issue and/or does not break the demo app(s). diff --git a/.github/actions/commonSetup/action.yml b/.github/actions/commonSetup/action.yml new file mode 100644 index 000000000..1ac1c2079 --- /dev/null +++ b/.github/actions/commonSetup/action.yml @@ -0,0 +1,21 @@ +name: "common machine setup" +description: "Prepares the machine" +runs: + using: "composite" + steps: + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "21" + + - name: "Setup Gradle" + uses: gradle/gradle-build-action@v2 + with: + # See https://github.com/marketplace/actions/gradle-build-action#enable-dependency-graph-generation-for-a-workflow + dependency-graph: generate-and-submit + # Limit the size of the cache entry. + # These directories contain instrumented/transformed dependency jars which can be reconstructed relatively quickly. + gradle-home-cache-excludes: | + caches/jars-9 + caches/transforms-3 diff --git a/.github/dependabot.yaml b/.github/dependabot.yaml new file mode 100644 index 000000000..6ca6a70b7 --- /dev/null +++ b/.github/dependabot.yaml @@ -0,0 +1,24 @@ +# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates +version: 2 + +updates: + - package-ecosystem: gradle + directory: / + schedule: + interval: weekly + day: monday + time: "04:00" + + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + day: monday + time: "04:00" + + - package-ecosystem: "devcontainers" + directory: / + schedule: + interval: monthly + day: monday + time: "04:00" diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 000000000..9621d3144 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,163 @@ +# Copyright 2020 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +name: "Build" + +# Controls when the action will run. Triggers the workflow on push or pull request +# events for the `master` branch +on: + push: + branches: [master] + tags: build* + pull_request: + # The branches below must be a subset of the branches above + branches: [master] + schedule: + # Run once a week (even if no new code or PRs) to detect random regressions + - cron: "12 13 * * 2" + +env: + # Allow precise monitoring of the save/restore of Gradle User Home by `gradle-build-action` + # See https://github.com/marketplace/actions/gradle-build-action?version=v2.1.1#cache-debugging-and-analysis + GRADLE_BUILD_ACTION_CACHE_DEBUG_ENABLED: true + GRADLE_BUILD_ACTION_CACHE_KEY_PREFIX: "fhir" # change this to invalidate cache + +concurrency: + # github.head_ref uniquely identifies Pull Requests (but is not available when building branches like main or master) + # github.ref is the fallback used when building for workflows triggered by push + # Note that || are fallback values (not "concatenations") + group: ${{ github.workflow }}-${{ github.head_ref || github.ref }} + cancel-in-progress: true # Use e.g. ${{ github.ref != 'refs/heads/main' }} (or master, until #2180) to only cancel for PRs not on branch + + # A workflow run is made up of one or more jobs that can run sequentially or in parallel +jobs: + # Build will compile APK, test APK and run tests, lint, etc. + build: + runs-on: 'ubuntu-24.04-8core' + timeout-minutes: 90 + permissions: + actions: read + contents: read + packages: write + + strategy: + fail-fast: false + + # Steps represent a sequence of tasks that will be executed as part of the job + steps: + # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it + - name: Checkout Repo + uses: actions/checkout@v4 + with: + # Fetch origin/master for spotless ratchet to work + # https://github.com/diffplug/spotless/issues/1242 + fetch-depth: 0 + + - name: Setup machine + uses: ./.github/actions/commonSetup + + - name: Spotless check + run: ./gradlew spotlessCheck --scan --full-stacktrace + + - name: Build (full) with Gradle + run: ./gradlew build -x :datacapture:build --scan --full-stacktrace + + - name: Check with Gradle + run: ./gradlew check -x :datacapture:check --scan --full-stacktrace + + - name: Run tests for datacapture Kotlin Multiplatform desktop environment with X Virtual Framebuffer + run: | + sudo apt-get install -y xvfb + xvfb-run --auto-servernum ./gradlew :datacapture:desktopTest + + - name: Publish Maven packages to GitHub Packages + if: ${{ github.event_name == 'push' }} + run: ./gradlew publish + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPOSITORY_URL: 'https://maven.pkg.github.com/ohs-foundation/kotlin-fhir-data-capture' + # Use SNAPSHOT Prefix to follow Maven convention + ARTIFACT_VERSION_SUFFIX: SNAPSHOT + + - name: Release artifacts to local repo + run: ./gradlew publishReleasePublicationToCIRepository --scan + env: + ARTIFACT_VERSION_SUFFIX: build_${{ github.run_id }} + + - name: Upload artifact maven-repository.zip + uses: actions/upload-artifact@v4 + with: + name: maven-repository + path: build/ci-repo + + - name: Zip artifact for debugging + if: ${{ failure() }} + run: zip build.zip ./*/build -r + + # Upload the build dir for all the modules for diagnosis + - name: Upload build dir + if: ${{ failure() }} + uses: actions/upload-artifact@v4 + with: + name: build + path: build.zip + + # Steps that follow are related to the Documentation Web Site + # This deploys content based on docs/ which ends up in site/ + # to https://google.github.io/android-fhir/ using GitHub Pages. + + - name: Install Python + uses: actions/setup-python@v5 + with: + python-version: 3.11 + cache: "pipenv" + - name: Install PipEnv + uses: tiagovrtr/actions-pipenv@v1 + with: + pipenv-version: 2023.12.1 + + - name: Build Docs Site + # This 1st step intentionally runs not just on the main ("master") branch + # but also for all pull requests. This serves to detect doc breakages. + # (But the following steps, which do the actual deploy, only run for + # the main branch, of course.) + run: ./build-docs.bash + + - name: Setup GitHub Pages + if: ${{ github.event_name == 'push' }} + uses: actions/configure-pages@v5 + + - name: Upload site/ directory as GitHub Pages artifact + if: ${{ github.event_name == 'push' }} + uses: actions/upload-pages-artifact@v3 + with: + path: "site/" + + deploy-website: + needs: build + if: ${{ github.event_name == 'push' }} + runs-on: ubuntu-latest + # https://docs.github.com/en/actions/security-guides/automatic-token-authentication + # Sets required permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages. + permissions: + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + # https://github.com/actions/deploy-pages + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.github/workflows/codeql.yaml b/.github/workflows/codeql.yaml new file mode 100644 index 000000000..4bada68d0 --- /dev/null +++ b/.github/workflows/codeql.yaml @@ -0,0 +1,74 @@ +# Copyright 2020 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +name: "CodeQL" + +# NB: This GitHub Action for https://codeql.github.com seems to be +# a bit "special"; it does not appear to be (easily) possible to just +# integrate and run this as part of the main build.yaml action; see +# https://github.com/google/android-fhir/issues/2310. + +on: + push: + branches: ["master"] + pull_request: + # The branches below must be a subset of the branches above + branches: ["master"] + schedule: + - cron: "32 13 * * 2" + +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.ref || github.run_id }} + cancel-in-progress: true + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-22.04-8core + timeout-minutes: 60 + permissions: + actions: read + contents: read + security-events: write + + strategy: + fail-fast: false + matrix: + language: ["java"] + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Switch to Java 17 from Eclipse Temurin distro + uses: actions/setup-java@v4 + with: + java-version: 17 + distribution: temurin + + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: ${{ matrix.language }} + + # TODO: use Autobuild instead of ./gradlew after https://github.com/github/codeql-action/issues/1417 is fixed + # - name: Autobuild + # uses: github/codeql-action/autobuild@v2 + - name: Build with Gradle + run: ./gradlew --scan --full-stacktrace -Dorg.gradle.dependency.verification=off compileDebugAndroidTestSources + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v3 + with: + category: "/language:${{matrix.language}}" diff --git a/.github/workflows/runFlank.sh b/.github/workflows/runFlank.sh new file mode 100755 index 000000000..ee3e6a6a7 --- /dev/null +++ b/.github/workflows/runFlank.sh @@ -0,0 +1,31 @@ +#!/bin/bash +# +# Copyright 2022 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +# Fail on any error. +set -e + +lib_names=("datacapture") +firebase_pids=() + +for lib_name in "${lib_names[@]}"; do + ./gradlew :$lib_name:runFlank --scan --stacktrace & + firebase_pids+=("$!") +done + +for firebase_pid in ${firebase_pids[*]}; do + wait $firebase_pid +done diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..945a20e53 --- /dev/null +++ b/.gitignore @@ -0,0 +1,109 @@ +# Built application files +*.apk +*.aar +*.ap_ +*.aab + +# Files for the ART/Dalvik VM +*.dex + +# Java class files +*.class + +# Generated files +bin/ +gen/ +out/ + +# Uncomment the following line in case you need and you don't have the release build type files in your app +# release/ + +# Gradle files +.gradle/ +build/ + +# Local configuration file (sdk path, etc) +local.properties + +# Proguard folder generated by Eclipse +proguard/ + +# Log Files +*.log + +# Android Studio Navigation editor temp files +.navigation/ + +# Android Studio captures folder +captures/ + +# IntelliJ +*.iml +.idea + +# VSCode/Eclipse +.classpath +.project +.settings +.vscode + +# Keystore files +# Uncomment the following lines if you do not want to check your keystore files in. +# (Please carefully consider the security implications of doing so! ASK, if unclear.) +#*.jks +#*.keystore + +# External native build folder generated in Android Studio 2.2 and later +.externalNativeBuild +.cxx/ + +# Google Services (e.g. APIs or Firebase) +# (Because this file contains secret credentials, like passwords; +# please carefully consider the security implications of doing so! ASK, if unclear.) +# google-services.json + +# Freeline +freeline.py +freeline/ +freeline_project_description.json + +# fastlane +fastlane/report.xml +fastlane/Preview.html +fastlane/screenshots +fastlane/test_output +fastlane/readme.md + +# Version control +vcs.xml + +# lint +lint/intermediates/ +lint/generated/ +lint/outputs/ +lint/tmp/ +# lint/reports/ + +# Mac OS +.DS_Store + +# MkDocs Documentation Generation +# Note: The docs/ directory is our SOURCE (in MD), +# and the site/ directory is our TARGET (in HTML) +site/ +# but the Dokka API Doc is generated into docs/ +# (which is slightly confusing, but helps mkdocs to validate links) +# so we ignore that here, as it's a built artifact that should not be committed +docs/use/api/*/** + +# Kotlin 2.0 +.kotlin/ + +# Synthea +synthea + + ### Kotlin/JS + kotlin-js-store/ + node_modules/ + +**/xcuserdata/ diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 000000000..7cd5e5d4b --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,93 @@ +# Code of Conduct + +## Our Pledge + +In the interest of fostering an open and welcoming environment, we as +contributors and maintainers pledge to making participation in our project and +our community a harassment-free experience for everyone, regardless of age, body +size, disability, ethnicity, gender identity and expression, level of +experience, education, socio-economic status, nationality, personal appearance, +race, religion, or sexual identity and orientation. + +## Our Standards + +Examples of behavior that contributes to creating a positive environment +include: + +* Using welcoming and inclusive language +* Being respectful of differing viewpoints and experiences +* Gracefully accepting constructive criticism +* Focusing on what is best for the community +* Showing empathy towards other community members + +Examples of unacceptable behavior by participants include: + +* The use of sexualized language or imagery and unwelcome sexual attention or + advances +* Trolling, insulting/derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or electronic + address, without explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Our Responsibilities + +Project maintainers are responsible for clarifying the standards of acceptable +behavior and are expected to take appropriate and fair corrective action in +response to any instances of unacceptable behavior. + +Project maintainers have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, or to ban temporarily or permanently any +contributor for other behaviors that they deem inappropriate, threatening, +offensive, or harmful. + +## Scope + +This Code of Conduct applies both within project spaces and in public spaces +when an individual is representing the project or its community. Examples of +representing a project or community include using an official project e-mail +address, posting via an official social media account, or acting as an appointed +representative at an online or offline event. Representation of a project may be +further defined and clarified by project maintainers. + +This Code of Conduct also applies outside the project spaces when the Project +Steward has a reasonable belief that an individual's behavior may have a +negative impact on the project or its community. + +## Conflict Resolution + +We do not believe that all conflict is bad; healthy debate and disagreement +often yield positive results. However, it is never okay to be disrespectful or +to engage in behavior that violates the project’s code of conduct. + +If you see someone violating the code of conduct, you are encouraged to address +the behavior directly with those involved. Many issues can be resolved quickly +and easily, and this gives people more control over the outcome of their +dispute. If you are unable to resolve the matter for any reason, or if the +behavior is threatening or harassing, report it. We are dedicated to providing +an environment where participants feel welcome and safe. + +Reports should be directed to Jing Tang (jingtang@google.com), the +Project Steward(s) for Android FHIR SDK. It is the Project Steward’s duty to +receive and address reported violations of the code of conduct. They will then +work with a committee consisting of representatives from the Open Source +Programs Office and the Google Open Source Strategy team. If for any reason you +are uncomfortable reaching out to the Project Steward, please email +opensource@google.com. + +We will investigate every complaint, but you may not receive a direct response. +We will use our discretion in determining when and how to follow up on reported +incidents, which may range from not taking action to permanent expulsion from +the project and project-sponsored spaces. We will notify the accused of the +report and provide them an opportunity to discuss it before any action is taken. +The identity of the reporter will be omitted from the details of the report +supplied to the accused. In potentially harmful situations, such as ongoing +harassment or threats to anyone's safety, we may take action without notice. + +## Attribution + +This Code of Conduct is adapted from the Contributor Covenant, version 1.4, +available at +https://www.contributor-covenant.org/version/1/4/code-of-conduct.html diff --git a/Contributing.md b/Contributing.md new file mode 100644 index 000000000..3ecbcc7cc --- /dev/null +++ b/Contributing.md @@ -0,0 +1,30 @@ +# How to Contribute + +We'd love to accept your patches and contributions to this project. There are +just a few small guidelines you need to follow. + +## Contributor License Agreement + +Contributions to this project must be accompanied by a Contributor License +Agreement. You (or your employer) retain the copyright to your contribution; +this simply gives us permission to use and redistribute your contributions as +part of the project. Head over to to see +your current agreements on file or to sign a new one. + +You generally only need to submit a CLA once, so if you've already submitted one +(even if it was for a different project), you probably don't need to do it +again. + +## Code reviews + +All submissions, including submissions by project members, require review. We +use GitHub pull requests for this purpose. Consult +[GitHub Help](https://help.github.com/articles/about-pull-requests/) for more +information on using pull requests. + +See [Contributor's Guide](https://google.github.io/android-fhir/contrib/) for more information. + +## Community Guidelines + +This project follows [Google's Open Source Community +Guidelines](https://opensource.google/conduct/). diff --git a/LICENSE b/LICENSE index 261eeb9e9..d64569567 100644 --- a/LICENSE +++ b/LICENSE @@ -1,3 +1,4 @@ + Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ diff --git a/LICENSES-3RD-PARTY/LICENSE-BSD3 b/LICENSES-3RD-PARTY/LICENSE-BSD3 new file mode 100644 index 000000000..07825a39d --- /dev/null +++ b/LICENSES-3RD-PARTY/LICENSE-BSD3 @@ -0,0 +1,6 @@ +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. +2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. +3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/LICENSES-3RD-PARTY/LICENSE-MIT b/LICENSES-3RD-PARTY/LICENSE-MIT new file mode 100644 index 000000000..1f95d26c5 --- /dev/null +++ b/LICENSES-3RD-PARTY/LICENSE-MIT @@ -0,0 +1,5 @@ +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/LICENSES-3RD-PARTY/README.md b/LICENSES-3RD-PARTY/README.md new file mode 100644 index 000000000..14d684511 --- /dev/null +++ b/LICENSES-3RD-PARTY/README.md @@ -0,0 +1,8 @@ +# 3rd-party LICENSE files + +Android FHIR SDK uses 3rd-party libraries whose licenses mandate that we +redistribute a copy of the license with our library. These licenses are +contained in this folder. + +Our [LicenseeConfig.kt](../buildSrc/src/main/kotlin/LicenseeConfig.kt) file +describes the licenses used by our 3rd-party dependencies in greater detail. diff --git a/Pipfile b/Pipfile new file mode 100644 index 000000000..605959c25 --- /dev/null +++ b/Pipfile @@ -0,0 +1,14 @@ +[[source]] +url = "https://pypi.org/simple" +verify_ssl = true +name = "pypi" + +[packages] +mkdocs-material = "~=9.5.21" +mkdocs-minify-plugin = "~=0.8.0" +mkdocs-git-revision-date-localized-plugin = "~=1.2.5" + +[dev-packages] + +[requires] +python_version = "3.11" diff --git a/Pipfile.lock b/Pipfile.lock new file mode 100644 index 000000000..c7ee56cc5 --- /dev/null +++ b/Pipfile.lock @@ -0,0 +1,620 @@ +{ + "_meta": { + "hash": { + "sha256": "4c0fdc204681b0030cadaf374a59084d9c0678e874dee11ff3d2273175c44202" + }, + "pipfile-spec": 6, + "requires": { + "python_version": "3.11" + }, + "sources": [ + { + "name": "pypi", + "url": "https://pypi.org/simple", + "verify_ssl": true + } + ] + }, + "default": { + "babel": { + "hashes": [ + "sha256:08706bdad8d0a3413266ab61bd6c34d0c28d6e1e7badf40a2cebe67644e2e1fb", + "sha256:8daf0e265d05768bc6c7a314cf1321e9a123afc328cc635c18622a2f30a04413" + ], + "markers": "python_version >= '3.8'", + "version": "==2.15.0" + }, + "certifi": { + "hashes": [ + "sha256:5a1e7645bc0ec61a09e26c36f6106dd4cf40c6db3a1fb6352b0244e7fb057c7b", + "sha256:c198e21b1289c2ab85ee4e67bb4b4ef3ead0892059901a8d5b622f24a1101e90" + ], + "index": "pypi", + "markers": "python_version >= '3.6'", + "version": "==2024.7.4" + }, + "charset-normalizer": { + "hashes": [ + "sha256:06435b539f889b1f6f4ac1758871aae42dc3a8c0e24ac9e60c2384973ad73027", + "sha256:06a81e93cd441c56a9b65d8e1d043daeb97a3d0856d177d5c90ba85acb3db087", + "sha256:0a55554a2fa0d408816b3b5cedf0045f4b8e1a6065aec45849de2d6f3f8e9786", + "sha256:0b2b64d2bb6d3fb9112bafa732def486049e63de9618b5843bcdd081d8144cd8", + "sha256:10955842570876604d404661fbccbc9c7e684caf432c09c715ec38fbae45ae09", + "sha256:122c7fa62b130ed55f8f285bfd56d5f4b4a5b503609d181f9ad85e55c89f4185", + "sha256:1ceae2f17a9c33cb48e3263960dc5fc8005351ee19db217e9b1bb15d28c02574", + "sha256:1d3193f4a680c64b4b6a9115943538edb896edc190f0b222e73761716519268e", + "sha256:1f79682fbe303db92bc2b1136016a38a42e835d932bab5b3b1bfcfbf0640e519", + "sha256:2127566c664442652f024c837091890cb1942c30937add288223dc895793f898", + "sha256:22afcb9f253dac0696b5a4be4a1c0f8762f8239e21b99680099abd9b2b1b2269", + "sha256:25baf083bf6f6b341f4121c2f3c548875ee6f5339300e08be3f2b2ba1721cdd3", + "sha256:2e81c7b9c8979ce92ed306c249d46894776a909505d8f5a4ba55b14206e3222f", + "sha256:3287761bc4ee9e33561a7e058c72ac0938c4f57fe49a09eae428fd88aafe7bb6", + "sha256:34d1c8da1e78d2e001f363791c98a272bb734000fcef47a491c1e3b0505657a8", + "sha256:37e55c8e51c236f95b033f6fb391d7d7970ba5fe7ff453dad675e88cf303377a", + "sha256:3d47fa203a7bd9c5b6cee4736ee84ca03b8ef23193c0d1ca99b5089f72645c73", + "sha256:3e4d1f6587322d2788836a99c69062fbb091331ec940e02d12d179c1d53e25fc", + "sha256:42cb296636fcc8b0644486d15c12376cb9fa75443e00fb25de0b8602e64c1714", + "sha256:45485e01ff4d3630ec0d9617310448a8702f70e9c01906b0d0118bdf9d124cf2", + "sha256:4a78b2b446bd7c934f5dcedc588903fb2f5eec172f3d29e52a9096a43722adfc", + "sha256:4ab2fe47fae9e0f9dee8c04187ce5d09f48eabe611be8259444906793ab7cbce", + "sha256:4d0d1650369165a14e14e1e47b372cfcb31d6ab44e6e33cb2d4e57265290044d", + "sha256:549a3a73da901d5bc3ce8d24e0600d1fa85524c10287f6004fbab87672bf3e1e", + "sha256:55086ee1064215781fff39a1af09518bc9255b50d6333f2e4c74ca09fac6a8f6", + "sha256:572c3763a264ba47b3cf708a44ce965d98555f618ca42c926a9c1616d8f34269", + "sha256:573f6eac48f4769d667c4442081b1794f52919e7edada77495aaed9236d13a96", + "sha256:5b4c145409bef602a690e7cfad0a15a55c13320ff7a3ad7ca59c13bb8ba4d45d", + "sha256:6463effa3186ea09411d50efc7d85360b38d5f09b870c48e4600f63af490e56a", + "sha256:65f6f63034100ead094b8744b3b97965785388f308a64cf8d7c34f2f2e5be0c4", + "sha256:663946639d296df6a2bb2aa51b60a2454ca1cb29835324c640dafb5ff2131a77", + "sha256:6897af51655e3691ff853668779c7bad41579facacf5fd7253b0133308cf000d", + "sha256:68d1f8a9e9e37c1223b656399be5d6b448dea850bed7d0f87a8311f1ff3dabb0", + "sha256:6ac7ffc7ad6d040517be39eb591cac5ff87416c2537df6ba3cba3bae290c0fed", + "sha256:6b3251890fff30ee142c44144871185dbe13b11bab478a88887a639655be1068", + "sha256:6c4caeef8fa63d06bd437cd4bdcf3ffefe6738fb1b25951440d80dc7df8c03ac", + "sha256:6ef1d82a3af9d3eecdba2321dc1b3c238245d890843e040e41e470ffa64c3e25", + "sha256:753f10e867343b4511128c6ed8c82f7bec3bd026875576dfd88483c5c73b2fd8", + "sha256:7cd13a2e3ddeed6913a65e66e94b51d80a041145a026c27e6bb76c31a853c6ab", + "sha256:7ed9e526742851e8d5cc9e6cf41427dfc6068d4f5a3bb03659444b4cabf6bc26", + "sha256:7f04c839ed0b6b98b1a7501a002144b76c18fb1c1850c8b98d458ac269e26ed2", + "sha256:802fe99cca7457642125a8a88a084cef28ff0cf9407060f7b93dca5aa25480db", + "sha256:80402cd6ee291dcb72644d6eac93785fe2c8b9cb30893c1af5b8fdd753b9d40f", + "sha256:8465322196c8b4d7ab6d1e049e4c5cb460d0394da4a27d23cc242fbf0034b6b5", + "sha256:86216b5cee4b06df986d214f664305142d9c76df9b6512be2738aa72a2048f99", + "sha256:87d1351268731db79e0f8e745d92493ee2841c974128ef629dc518b937d9194c", + "sha256:8bdb58ff7ba23002a4c5808d608e4e6c687175724f54a5dade5fa8c67b604e4d", + "sha256:8c622a5fe39a48f78944a87d4fb8a53ee07344641b0562c540d840748571b811", + "sha256:8d756e44e94489e49571086ef83b2bb8ce311e730092d2c34ca8f7d925cb20aa", + "sha256:8f4a014bc36d3c57402e2977dada34f9c12300af536839dc38c0beab8878f38a", + "sha256:9063e24fdb1e498ab71cb7419e24622516c4a04476b17a2dab57e8baa30d6e03", + "sha256:90d558489962fd4918143277a773316e56c72da56ec7aa3dc3dbbe20fdfed15b", + "sha256:923c0c831b7cfcb071580d3f46c4baf50f174be571576556269530f4bbd79d04", + "sha256:95f2a5796329323b8f0512e09dbb7a1860c46a39da62ecb2324f116fa8fdc85c", + "sha256:96b02a3dc4381e5494fad39be677abcb5e6634bf7b4fa83a6dd3112607547001", + "sha256:9f96df6923e21816da7e0ad3fd47dd8f94b2a5ce594e00677c0013018b813458", + "sha256:a10af20b82360ab00827f916a6058451b723b4e65030c5a18577c8b2de5b3389", + "sha256:a50aebfa173e157099939b17f18600f72f84eed3049e743b68ad15bd69b6bf99", + "sha256:a981a536974bbc7a512cf44ed14938cf01030a99e9b3a06dd59578882f06f985", + "sha256:a9a8e9031d613fd2009c182b69c7b2c1ef8239a0efb1df3f7c8da66d5dd3d537", + "sha256:ae5f4161f18c61806f411a13b0310bea87f987c7d2ecdbdaad0e94eb2e404238", + "sha256:aed38f6e4fb3f5d6bf81bfa990a07806be9d83cf7bacef998ab1a9bd660a581f", + "sha256:b01b88d45a6fcb69667cd6d2f7a9aeb4bf53760d7fc536bf679ec94fe9f3ff3d", + "sha256:b261ccdec7821281dade748d088bb6e9b69e6d15b30652b74cbbac25e280b796", + "sha256:b2b0a0c0517616b6869869f8c581d4eb2dd83a4d79e0ebcb7d373ef9956aeb0a", + "sha256:b4a23f61ce87adf89be746c8a8974fe1c823c891d8f86eb218bb957c924bb143", + "sha256:bd8f7df7d12c2db9fab40bdd87a7c09b1530128315d047a086fa3ae3435cb3a8", + "sha256:beb58fe5cdb101e3a055192ac291b7a21e3b7ef4f67fa1d74e331a7f2124341c", + "sha256:c002b4ffc0be611f0d9da932eb0f704fe2602a9a949d1f738e4c34c75b0863d5", + "sha256:c083af607d2515612056a31f0a8d9e0fcb5876b7bfc0abad3ecd275bc4ebc2d5", + "sha256:c180f51afb394e165eafe4ac2936a14bee3eb10debc9d9e4db8958fe36afe711", + "sha256:c235ebd9baae02f1b77bcea61bce332cb4331dc3617d254df3323aa01ab47bd4", + "sha256:cd70574b12bb8a4d2aaa0094515df2463cb429d8536cfb6c7ce983246983e5a6", + "sha256:d0eccceffcb53201b5bfebb52600a5fb483a20b61da9dbc885f8b103cbe7598c", + "sha256:d965bba47ddeec8cd560687584e88cf699fd28f192ceb452d1d7ee807c5597b7", + "sha256:db364eca23f876da6f9e16c9da0df51aa4f104a972735574842618b8c6d999d4", + "sha256:ddbb2551d7e0102e7252db79ba445cdab71b26640817ab1e3e3648dad515003b", + "sha256:deb6be0ac38ece9ba87dea880e438f25ca3eddfac8b002a2ec3d9183a454e8ae", + "sha256:e06ed3eb3218bc64786f7db41917d4e686cc4856944f53d5bdf83a6884432e12", + "sha256:e27ad930a842b4c5eb8ac0016b0a54f5aebbe679340c26101df33424142c143c", + "sha256:e537484df0d8f426ce2afb2d0f8e1c3d0b114b83f8850e5f2fbea0e797bd82ae", + "sha256:eb00ed941194665c332bf8e078baf037d6c35d7c4f3102ea2d4f16ca94a26dc8", + "sha256:eb6904c354526e758fda7167b33005998fb68c46fbc10e013ca97f21ca5c8887", + "sha256:eb8821e09e916165e160797a6c17edda0679379a4be5c716c260e836e122f54b", + "sha256:efcb3f6676480691518c177e3b465bcddf57cea040302f9f4e6e191af91174d4", + "sha256:f27273b60488abe721a075bcca6d7f3964f9f6f067c8c4c605743023d7d3944f", + "sha256:f30c3cb33b24454a82faecaf01b19c18562b1e89558fb6c56de4d9118a032fd5", + "sha256:fb69256e180cb6c8a894fee62b3afebae785babc1ee98b81cdf68bbca1987f33", + "sha256:fd1abc0d89e30cc4e02e4064dc67fcc51bd941eb395c502aac3ec19fab46b519", + "sha256:ff8fa367d09b717b2a17a052544193ad76cd49979c805768879cb63d9ca50561" + ], + "markers": "python_full_version >= '3.7.0'", + "version": "==3.3.2" + }, + "click": { + "hashes": [ + "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28", + "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de" + ], + "markers": "python_version >= '3.7'", + "version": "==8.1.7" + }, + "colorama": { + "hashes": [ + "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", + "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5, 3.6'", + "version": "==0.4.6" + }, + "csscompressor": { + "hashes": [ + "sha256:afa22badbcf3120a4f392e4d22f9fff485c044a1feda4a950ecc5eba9dd31a05" + ], + "version": "==0.9.5" + }, + "ghp-import": { + "hashes": [ + "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619", + "sha256:9c535c4c61193c2df8871222567d7fd7e5014d835f97dc7b7439069e2413d343" + ], + "version": "==2.1.0" + }, + "gitdb": { + "hashes": [ + "sha256:81a3407ddd2ee8df444cbacea00e2d038e40150acfa3001696fe0dcf1d3adfa4", + "sha256:bf5421126136d6d0af55bc1e7c1af1c397a34f5b7bd79e776cd3e89785c2b04b" + ], + "markers": "python_version >= '3.7'", + "version": "==4.0.11" + }, + "gitpython": { + "hashes": [ + "sha256:35f314a9f878467f5453cc1fee295c3e18e52f1b99f10f6cf5b1682e968a9e7c", + "sha256:eec7ec56b92aad751f9912a73404bc02ba212a23adb2c7098ee668417051a1ff" + ], + "markers": "python_version >= '3.7'", + "version": "==3.1.43" + }, + "htmlmin2": { + "hashes": [ + "sha256:75609f2a42e64f7ce57dbff28a39890363bde9e7e5885db633317efbdf8c79a2" + ], + "version": "==0.1.13" + }, + "idna": { + "hashes": [ + "sha256:028ff3aadf0609c1fd278d8ea3089299412a7a8b9bd005dd08b9f8285bcb5cfc", + "sha256:82fee1fc78add43492d3a1898bfa6d8a904cc97d8427f683ed8e798d07761aa0" + ], + "markers": "python_version >= '3.5'", + "version": "==3.7" + }, + "jinja2": { + "hashes": [ + "sha256:4a3aee7acbbe7303aede8e9648d13b8bf88a429282aa6122a993f0ac800cb369", + "sha256:bc5dd2abb727a5319567b7a813e6a2e7318c39f4f487cfe6c89c6f9c7d25197d" + ], + "markers": "python_version >= '3.7'", + "version": "==3.1.4" + }, + "jsmin": { + "hashes": [ + "sha256:c0959a121ef94542e807a674142606f7e90214a2b3d1eb17300244bbb5cc2bfc" + ], + "version": "==3.0.1" + }, + "markdown": { + "hashes": [ + "sha256:48f276f4d8cfb8ce6527c8f79e2ee29708508bf4d40aa410fbc3b4ee832c850f", + "sha256:ed4f41f6daecbeeb96e576ce414c41d2d876daa9a16cb35fa8ed8c2ddfad0224" + ], + "markers": "python_version >= '3.8'", + "version": "==3.6" + }, + "markupsafe": { + "hashes": [ + "sha256:00e046b6dd71aa03a41079792f8473dc494d564611a8f89bbbd7cb93295ebdcf", + "sha256:075202fa5b72c86ad32dc7d0b56024ebdbcf2048c0ba09f1cde31bfdd57bcfff", + "sha256:0e397ac966fdf721b2c528cf028494e86172b4feba51d65f81ffd65c63798f3f", + "sha256:17b950fccb810b3293638215058e432159d2b71005c74371d784862b7e4683f3", + "sha256:1f3fbcb7ef1f16e48246f704ab79d79da8a46891e2da03f8783a5b6fa41a9532", + "sha256:2174c595a0d73a3080ca3257b40096db99799265e1c27cc5a610743acd86d62f", + "sha256:2b7c57a4dfc4f16f7142221afe5ba4e093e09e728ca65c51f5620c9aaeb9a617", + "sha256:2d2d793e36e230fd32babe143b04cec8a8b3eb8a3122d2aceb4a371e6b09b8df", + "sha256:30b600cf0a7ac9234b2638fbc0fb6158ba5bdcdf46aeb631ead21248b9affbc4", + "sha256:397081c1a0bfb5124355710fe79478cdbeb39626492b15d399526ae53422b906", + "sha256:3a57fdd7ce31c7ff06cdfbf31dafa96cc533c21e443d57f5b1ecc6cdc668ec7f", + "sha256:3c6b973f22eb18a789b1460b4b91bf04ae3f0c4234a0a6aa6b0a92f6f7b951d4", + "sha256:3e53af139f8579a6d5f7b76549125f0d94d7e630761a2111bc431fd820e163b8", + "sha256:4096e9de5c6fdf43fb4f04c26fb114f61ef0bf2e5604b6ee3019d51b69e8c371", + "sha256:4275d846e41ecefa46e2015117a9f491e57a71ddd59bbead77e904dc02b1bed2", + "sha256:4c31f53cdae6ecfa91a77820e8b151dba54ab528ba65dfd235c80b086d68a465", + "sha256:4f11aa001c540f62c6166c7726f71f7573b52c68c31f014c25cc7901deea0b52", + "sha256:5049256f536511ee3f7e1b3f87d1d1209d327e818e6ae1365e8653d7e3abb6a6", + "sha256:58c98fee265677f63a4385256a6d7683ab1832f3ddd1e66fe948d5880c21a169", + "sha256:598e3276b64aff0e7b3451b72e94fa3c238d452e7ddcd893c3ab324717456bad", + "sha256:5b7b716f97b52c5a14bffdf688f971b2d5ef4029127f1ad7a513973cfd818df2", + "sha256:5dedb4db619ba5a2787a94d877bc8ffc0566f92a01c0ef214865e54ecc9ee5e0", + "sha256:619bc166c4f2de5caa5a633b8b7326fbe98e0ccbfacabd87268a2b15ff73a029", + "sha256:629ddd2ca402ae6dbedfceeba9c46d5f7b2a61d9749597d4307f943ef198fc1f", + "sha256:656f7526c69fac7f600bd1f400991cc282b417d17539a1b228617081106feb4a", + "sha256:6ec585f69cec0aa07d945b20805be741395e28ac1627333b1c5b0105962ffced", + "sha256:72b6be590cc35924b02c78ef34b467da4ba07e4e0f0454a2c5907f473fc50ce5", + "sha256:7502934a33b54030eaf1194c21c692a534196063db72176b0c4028e140f8f32c", + "sha256:7a68b554d356a91cce1236aa7682dc01df0edba8d043fd1ce607c49dd3c1edcf", + "sha256:7b2e5a267c855eea6b4283940daa6e88a285f5f2a67f2220203786dfa59b37e9", + "sha256:823b65d8706e32ad2df51ed89496147a42a2a6e01c13cfb6ffb8b1e92bc910bb", + "sha256:8590b4ae07a35970728874632fed7bd57b26b0102df2d2b233b6d9d82f6c62ad", + "sha256:8dd717634f5a044f860435c1d8c16a270ddf0ef8588d4887037c5028b859b0c3", + "sha256:8dec4936e9c3100156f8a2dc89c4b88d5c435175ff03413b443469c7c8c5f4d1", + "sha256:97cafb1f3cbcd3fd2b6fbfb99ae11cdb14deea0736fc2b0952ee177f2b813a46", + "sha256:a17a92de5231666cfbe003f0e4b9b3a7ae3afb1ec2845aadc2bacc93ff85febc", + "sha256:a549b9c31bec33820e885335b451286e2969a2d9e24879f83fe904a5ce59d70a", + "sha256:ac07bad82163452a6884fe8fa0963fb98c2346ba78d779ec06bd7a6262132aee", + "sha256:ae2ad8ae6ebee9d2d94b17fb62763125f3f374c25618198f40cbb8b525411900", + "sha256:b91c037585eba9095565a3556f611e3cbfaa42ca1e865f7b8015fe5c7336d5a5", + "sha256:bc1667f8b83f48511b94671e0e441401371dfd0f0a795c7daa4a3cd1dde55bea", + "sha256:bec0a414d016ac1a18862a519e54b2fd0fc8bbfd6890376898a6c0891dd82e9f", + "sha256:bf50cd79a75d181c9181df03572cdce0fbb75cc353bc350712073108cba98de5", + "sha256:bff1b4290a66b490a2f4719358c0cdcd9bafb6b8f061e45c7a2460866bf50c2e", + "sha256:c061bb86a71b42465156a3ee7bd58c8c2ceacdbeb95d05a99893e08b8467359a", + "sha256:c8b29db45f8fe46ad280a7294f5c3ec36dbac9491f2d1c17345be8e69cc5928f", + "sha256:ce409136744f6521e39fd8e2a24c53fa18ad67aa5bc7c2cf83645cce5b5c4e50", + "sha256:d050b3361367a06d752db6ead6e7edeb0009be66bc3bae0ee9d97fb326badc2a", + "sha256:d283d37a890ba4c1ae73ffadf8046435c76e7bc2247bbb63c00bd1a709c6544b", + "sha256:d9fad5155d72433c921b782e58892377c44bd6252b5af2f67f16b194987338a4", + "sha256:daa4ee5a243f0f20d528d939d06670a298dd39b1ad5f8a72a4275124a7819eff", + "sha256:db0b55e0f3cc0be60c1f19efdde9a637c32740486004f20d1cff53c3c0ece4d2", + "sha256:e61659ba32cf2cf1481e575d0462554625196a1f2fc06a1c777d3f48e8865d46", + "sha256:ea3d8a3d18833cf4304cd2fc9cbb1efe188ca9b5efef2bdac7adc20594a0e46b", + "sha256:ec6a563cff360b50eed26f13adc43e61bc0c04d94b8be985e6fb24b81f6dcfdf", + "sha256:f5dfb42c4604dddc8e4305050aa6deb084540643ed5804d7455b5df8fe16f5e5", + "sha256:fa173ec60341d6bb97a89f5ea19c85c5643c1e7dedebc22f5181eb73573142c5", + "sha256:fa9db3f79de01457b03d4f01b34cf91bc0048eb2c3846ff26f66687c2f6d16ab", + "sha256:fce659a462a1be54d2ffcacea5e3ba2d74daa74f30f5f143fe0c58636e355fdd", + "sha256:ffee1f21e5ef0d712f9033568f8344d5da8cc2869dbd08d87c84656e6a2d2f68" + ], + "markers": "python_version >= '3.7'", + "version": "==2.1.5" + }, + "mergedeep": { + "hashes": [ + "sha256:0096d52e9dad9939c3d975a774666af186eda617e6ca84df4c94dec30004f2a8", + "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307" + ], + "markers": "python_version >= '3.6'", + "version": "==1.3.4" + }, + "mkdocs": { + "hashes": [ + "sha256:1eb5cb7676b7d89323e62b56235010216319217d4af5ddc543a91beb8d125ea7", + "sha256:a73f735824ef83a4f3bcb7a231dcab23f5a838f88b7efc54a0eef5fbdbc3c512" + ], + "markers": "python_version >= '3.8'", + "version": "==1.6.0" + }, + "mkdocs-get-deps": { + "hashes": [ + "sha256:162b3d129c7fad9b19abfdcb9c1458a651628e4b1dea628ac68790fb3061c60c", + "sha256:2bf11d0b133e77a0dd036abeeb06dec8775e46efa526dc70667d8863eefc6134" + ], + "markers": "python_version >= '3.8'", + "version": "==0.2.0" + }, + "mkdocs-git-revision-date-localized-plugin": { + "hashes": [ + "sha256:0c439816d9d0dba48e027d9d074b2b9f1d7cd179f74ba46b51e4da7bb3dc4b9b", + "sha256:d796a18b07cfcdb154c133e3ec099d2bb5f38389e4fd54d3eb516a8a736815b8" + ], + "index": "pypi", + "markers": "python_version >= '3.8'", + "version": "==1.2.5" + }, + "mkdocs-material": { + "hashes": [ + "sha256:049f82770f40559d3c2aa2259c562ea7257dbb4aaa9624323b5ef27b2d95a450", + "sha256:210e1f179682cd4be17d5c641b2f4559574b9dea2f589c3f0e7c17c5bd1959bc" + ], + "index": "pypi", + "markers": "python_version >= '3.8'", + "version": "==9.5.21" + }, + "mkdocs-material-extensions": { + "hashes": [ + "sha256:10c9511cea88f568257f960358a467d12b970e1f7b2c0e5fb2bb48cab1928443", + "sha256:adff8b62700b25cb77b53358dad940f3ef973dd6db797907c49e3c2ef3ab4e31" + ], + "markers": "python_version >= '3.8'", + "version": "==1.3.1" + }, + "mkdocs-minify-plugin": { + "hashes": [ + "sha256:5fba1a3f7bd9a2142c9954a6559a57e946587b21f133165ece30ea145c66aee6", + "sha256:bc11b78b8120d79e817308e2b11539d790d21445eb63df831e393f76e52e753d" + ], + "index": "pypi", + "markers": "python_version >= '3.8'", + "version": "==0.8.0" + }, + "packaging": { + "hashes": [ + "sha256:2ddfb553fdf02fb784c234c7ba6ccc288296ceabec964ad2eae3777778130bc5", + "sha256:eb82c5e3e56209074766e6885bb04b8c38a0c015d0a30036ebe7ece34c9989e9" + ], + "markers": "python_version >= '3.7'", + "version": "==24.0" + }, + "paginate": { + "hashes": [ + "sha256:5e6007b6a9398177a7e1648d04fdd9f8c9766a1a945bceac82f1929e8c78af2d" + ], + "version": "==0.5.6" + }, + "pathspec": { + "hashes": [ + "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", + "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712" + ], + "markers": "python_version >= '3.8'", + "version": "==0.12.1" + }, + "platformdirs": { + "hashes": [ + "sha256:031cd18d4ec63ec53e82dceaac0417d218a6863f7745dfcc9efe7793b7039bdf", + "sha256:17d5a1161b3fd67b390023cb2d3b026bbd40abde6fdb052dfbd3a29c3ba22ee1" + ], + "markers": "python_version >= '3.8'", + "version": "==4.2.1" + }, + "pygments": { + "hashes": [ + "sha256:786ff802f32e91311bff3889f6e9a86e81505fe99f2735bb6d60ae0c5004f199", + "sha256:b8e6aca0523f3ab76fee51799c488e38782ac06eafcf95e7ba832985c8e7b13a" + ], + "markers": "python_version >= '3.8'", + "version": "==2.18.0" + }, + "pymdown-extensions": { + "hashes": [ + "sha256:3ab1db5c9e21728dabf75192d71471f8e50f216627e9a1fa9535ecb0231b9940", + "sha256:f938326115884f48c6059c67377c46cf631c733ef3629b6eed1349989d1b30cb" + ], + "markers": "python_version >= '3.8'", + "version": "==10.8.1" + }, + "python-dateutil": { + "hashes": [ + "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", + "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==2.9.0.post0" + }, + "pytz": { + "hashes": [ + "sha256:2a29735ea9c18baf14b448846bde5a48030ed267578472d8955cd0e7443a9812", + "sha256:328171f4e3623139da4983451950b28e95ac706e13f3f2630a879749e7a8b319" + ], + "version": "==2024.1" + }, + "pyyaml": { + "hashes": [ + "sha256:04ac92ad1925b2cff1db0cfebffb6ffc43457495c9b3c39d3fcae417d7125dc5", + "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc", + "sha256:0d3304d8c0adc42be59c5f8a4d9e3d7379e6955ad754aa9d6ab7a398b59dd1df", + "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741", + "sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206", + "sha256:18aeb1bf9a78867dc38b259769503436b7c72f7a1f1f4c93ff9a17de54319b27", + "sha256:1d4c7e777c441b20e32f52bd377e0c409713e8bb1386e1099c2415f26e479595", + "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62", + "sha256:1fe35611261b29bd1de0070f0b2f47cb6ff71fa6595c077e42bd0c419fa27b98", + "sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696", + "sha256:326c013efe8048858a6d312ddd31d56e468118ad4cdeda36c719bf5bb6192290", + "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9", + "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d", + "sha256:49a183be227561de579b4a36efbb21b3eab9651dd81b1858589f796549873dd6", + "sha256:4fb147e7a67ef577a588a0e2c17b6db51dda102c71de36f8549b6816a96e1867", + "sha256:50550eb667afee136e9a77d6dc71ae76a44df8b3e51e41b77f6de2932bfe0f47", + "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486", + "sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6", + "sha256:596106435fa6ad000c2991a98fa58eeb8656ef2325d7e158344fb33864ed87e3", + "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007", + "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938", + "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0", + "sha256:704219a11b772aea0d8ecd7058d0082713c3562b4e271b849ad7dc4a5c90c13c", + "sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735", + "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d", + "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28", + "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4", + "sha256:9046c58c4395dff28dd494285c82ba00b546adfc7ef001486fbf0324bc174fba", + "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8", + "sha256:a08c6f0fe150303c1c6b71ebcd7213c2858041a7e01975da3a99aed1e7a378ef", + "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5", + "sha256:afd7e57eddb1a54f0f1a974bc4391af8bcce0b444685d936840f125cf046d5bd", + "sha256:b1275ad35a5d18c62a7220633c913e1b42d44b46ee12554e5fd39c70a243d6a3", + "sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0", + "sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515", + "sha256:baa90d3f661d43131ca170712d903e6295d1f7a0f595074f151c0aed377c9b9c", + "sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c", + "sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924", + "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34", + "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43", + "sha256:c8098ddcc2a85b61647b2590f825f3db38891662cfc2fc776415143f599bb859", + "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673", + "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54", + "sha256:d858aa552c999bc8a8d57426ed01e40bef403cd8ccdd0fc5f6f04a00414cac2a", + "sha256:e7d73685e87afe9f3b36c799222440d6cf362062f78be1013661b00c5c6f678b", + "sha256:f003ed9ad21d6a4713f0a9b5a7a0a79e08dd0f221aff4525a2be4c346ee60aab", + "sha256:f22ac1c3cac4dbc50079e965eba2c1058622631e526bd9afd45fedd49ba781fa", + "sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c", + "sha256:fca0e3a251908a499833aa292323f32437106001d436eca0e6e7833256674585", + "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d", + "sha256:fd66fc5d0da6d9815ba2cebeb4205f95818ff4b79c3ebe268e75d961704af52f" + ], + "markers": "python_version >= '3.6'", + "version": "==6.0.1" + }, + "pyyaml-env-tag": { + "hashes": [ + "sha256:70092675bda14fdec33b31ba77e7543de9ddc88f2e5b99160396572d11525bdb", + "sha256:af31106dec8a4d68c60207c1886031cbf839b68aa7abccdb19868200532c2069" + ], + "markers": "python_version >= '3.6'", + "version": "==0.1" + }, + "regex": { + "hashes": [ + "sha256:05d9b6578a22db7dedb4df81451f360395828b04f4513980b6bd7a1412c679cc", + "sha256:08a1749f04fee2811c7617fdd46d2e46d09106fa8f475c884b65c01326eb15c5", + "sha256:0940038bec2fe9e26b203d636c44d31dd8766abc1fe66262da6484bd82461ccf", + "sha256:0a2a512d623f1f2d01d881513af9fc6a7c46e5cfffb7dc50c38ce959f9246c94", + "sha256:0a54a047b607fd2d2d52a05e6ad294602f1e0dec2291152b745870afc47c1397", + "sha256:0dd3f69098511e71880fb00f5815db9ed0ef62c05775395968299cb400aeab82", + "sha256:1031a5e7b048ee371ab3653aad3030ecfad6ee9ecdc85f0242c57751a05b0ac4", + "sha256:108e2dcf0b53a7c4ab8986842a8edcb8ab2e59919a74ff51c296772e8e74d0ae", + "sha256:144a1fc54765f5c5c36d6d4b073299832aa1ec6a746a6452c3ee7b46b3d3b11d", + "sha256:19d6c11bf35a6ad077eb23852827f91c804eeb71ecb85db4ee1386825b9dc4db", + "sha256:1f687a28640f763f23f8a9801fe9e1b37338bb1ca5d564ddd41619458f1f22d1", + "sha256:224803b74aab56aa7be313f92a8d9911dcade37e5f167db62a738d0c85fdac4b", + "sha256:23a412b7b1a7063f81a742463f38821097b6a37ce1e5b89dd8e871d14dbfd86b", + "sha256:25f87ae6b96374db20f180eab083aafe419b194e96e4f282c40191e71980c666", + "sha256:2630ca4e152c221072fd4a56d4622b5ada876f668ecd24d5ab62544ae6793ed6", + "sha256:28e1f28d07220c0f3da0e8fcd5a115bbb53f8b55cecf9bec0c946eb9a059a94c", + "sha256:2b51739ddfd013c6f657b55a508de8b9ea78b56d22b236052c3a85a675102dc6", + "sha256:2cc1b87bba1dd1a898e664a31012725e48af826bf3971e786c53e32e02adae6c", + "sha256:2fef0b38c34ae675fcbb1b5db760d40c3fc3612cfa186e9e50df5782cac02bcd", + "sha256:36f392dc7763fe7924575475736bddf9ab9f7a66b920932d0ea50c2ded2f5636", + "sha256:374f690e1dd0dbdcddea4a5c9bdd97632cf656c69113f7cd6a361f2a67221cb6", + "sha256:3986217ec830c2109875be740531feb8ddafe0dfa49767cdcd072ed7e8927962", + "sha256:39fb166d2196413bead229cd64a2ffd6ec78ebab83fff7d2701103cf9f4dfd26", + "sha256:4290035b169578ffbbfa50d904d26bec16a94526071ebec3dadbebf67a26b25e", + "sha256:43548ad74ea50456e1c68d3c67fff3de64c6edb85bcd511d1136f9b5376fc9d1", + "sha256:44a22ae1cfd82e4ffa2066eb3390777dc79468f866f0625261a93e44cdf6482b", + "sha256:457c2cd5a646dd4ed536c92b535d73548fb8e216ebee602aa9f48e068fc393f3", + "sha256:459226445c7d7454981c4c0ce0ad1a72e1e751c3e417f305722bbcee6697e06a", + "sha256:47af45b6153522733aa6e92543938e97a70ce0900649ba626cf5aad290b737b6", + "sha256:499334ad139557de97cbc4347ee921c0e2b5e9c0f009859e74f3f77918339257", + "sha256:57ba112e5530530fd175ed550373eb263db4ca98b5f00694d73b18b9a02e7185", + "sha256:5ce479ecc068bc2a74cb98dd8dba99e070d1b2f4a8371a7dfe631f85db70fe6e", + "sha256:5dbc1bcc7413eebe5f18196e22804a3be1bfdfc7e2afd415e12c068624d48247", + "sha256:6277d426e2f31bdbacb377d17a7475e32b2d7d1f02faaecc48d8e370c6a3ff31", + "sha256:66372c2a01782c5fe8e04bff4a2a0121a9897e19223d9eab30c54c50b2ebeb7f", + "sha256:670fa596984b08a4a769491cbdf22350431970d0112e03d7e4eeaecaafcd0fec", + "sha256:6f435946b7bf7a1b438b4e6b149b947c837cb23c704e780c19ba3e6855dbbdd3", + "sha256:7413167c507a768eafb5424413c5b2f515c606be5bb4ef8c5dee43925aa5718b", + "sha256:7c3d389e8d76a49923683123730c33e9553063d9041658f23897f0b396b2386f", + "sha256:7d77b6f63f806578c604dca209280e4c54f0fa9a8128bb8d2cc5fb6f99da4150", + "sha256:7e76b9cfbf5ced1aca15a0e5b6f229344d9b3123439ffce552b11faab0114a02", + "sha256:7f3502f03b4da52bbe8ba962621daa846f38489cae5c4a7b5d738f15f6443d17", + "sha256:7fe9739a686dc44733d52d6e4f7b9c77b285e49edf8570754b322bca6b85b4cc", + "sha256:83ab366777ea45d58f72593adf35d36ca911ea8bd838483c1823b883a121b0e4", + "sha256:84077821c85f222362b72fdc44f7a3a13587a013a45cf14534df1cbbdc9a6796", + "sha256:8bb381f777351bd534462f63e1c6afb10a7caa9fa2a421ae22c26e796fe31b1f", + "sha256:92da587eee39a52c91aebea8b850e4e4f095fe5928d415cb7ed656b3460ae79a", + "sha256:9301cc6db4d83d2c0719f7fcda37229691745168bf6ae849bea2e85fc769175d", + "sha256:965fd0cf4694d76f6564896b422724ec7b959ef927a7cb187fc6b3f4e4f59833", + "sha256:99d6a550425cc51c656331af0e2b1651e90eaaa23fb4acde577cf15068e2e20f", + "sha256:99ef6289b62042500d581170d06e17f5353b111a15aa6b25b05b91c6886df8fc", + "sha256:a1409c4eccb6981c7baabc8888d3550df518add6e06fe74fa1d9312c1838652d", + "sha256:a74fcf77d979364f9b69fcf8200849ca29a374973dc193a7317698aa37d8b01c", + "sha256:aaa179975a64790c1f2701ac562b5eeb733946eeb036b5bcca05c8d928a62f10", + "sha256:ac69b394764bb857429b031d29d9604842bc4cbfd964d764b1af1868eeebc4f0", + "sha256:b45d4503de8f4f3dc02f1d28a9b039e5504a02cc18906cfe744c11def942e9eb", + "sha256:b7d893c8cf0e2429b823ef1a1d360a25950ed11f0e2a9df2b5198821832e1947", + "sha256:b8eb28995771c087a73338f695a08c9abfdf723d185e57b97f6175c5051ff1ae", + "sha256:b91d529b47798c016d4b4c1d06cc826ac40d196da54f0de3c519f5a297c5076a", + "sha256:bc365ce25f6c7c5ed70e4bc674f9137f52b7dd6a125037f9132a7be52b8a252f", + "sha256:bf29304a8011feb58913c382902fde3395957a47645bf848eea695839aa101b7", + "sha256:c06bf3f38f0707592898428636cbb75d0a846651b053a1cf748763e3063a6925", + "sha256:c77d10ec3c1cf328b2f501ca32583625987ea0f23a0c2a49b37a39ee5c4c4630", + "sha256:cd196d056b40af073d95a2879678585f0b74ad35190fac04ca67954c582c6b61", + "sha256:d7a353ebfa7154c871a35caca7bfd8f9e18666829a1dc187115b80e35a29393e", + "sha256:d84308f097d7a513359757c69707ad339da799e53b7393819ec2ea36bc4beb58", + "sha256:dd7ef715ccb8040954d44cfeff17e6b8e9f79c8019daae2fd30a8806ef5435c0", + "sha256:e672cf9caaf669053121f1766d659a8813bd547edef6e009205378faf45c67b8", + "sha256:ecc6148228c9ae25ce403eade13a0961de1cb016bdb35c6eafd8e7b87ad028b1", + "sha256:f1c5742c31ba7d72f2dedf7968998730664b45e38827637e0f04a2ac7de2f5f1", + "sha256:f1d6e4b7b2ae3a6a9df53efbf199e4bfcff0959dbdb5fd9ced34d4407348e39a", + "sha256:f2fc053228a6bd3a17a9b0a3f15c3ab3cf95727b00557e92e1cfe094b88cc662", + "sha256:f57515750d07e14743db55d59759893fdb21d2668f39e549a7d6cad5d70f9fea", + "sha256:f85151ec5a232335f1be022b09fbbe459042ea1951d8a48fef251223fc67eee1", + "sha256:fb0315a2b26fde4005a7c401707c5352df274460f2f85b209cf6024271373013", + "sha256:fc0916c4295c64d6890a46e02d4482bb5ccf33bf1a824c0eaa9e83b148291f90", + "sha256:fd24fd140b69f0b0bcc9165c397e9b2e89ecbeda83303abf2a072609f60239e2", + "sha256:fdae0120cddc839eb8e3c15faa8ad541cc6d906d3eb24d82fb041cfe2807bc1e", + "sha256:fe00f4fe11c8a521b173e6324d862ee7ee3412bf7107570c9b564fe1119b56fb" + ], + "markers": "python_version >= '3.8'", + "version": "==2024.4.28" + }, + "requests": { + "hashes": [ + "sha256:f2c3881dddb70d056c5bd7600a4fae312b2a300e39be6a118d30b90bd27262b5", + "sha256:fa5490319474c82ef1d2c9bc459d3652e3ae4ef4c4ebdd18a21145a47ca4b6b8" + ], + "index": "pypi", + "markers": "python_version >= '3.8'", + "version": "==2.32.0" + }, + "six": { + "hashes": [ + "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", + "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==1.16.0" + }, + "smmap": { + "hashes": [ + "sha256:dceeb6c0028fdb6734471eb07c0cd2aae706ccaecab45965ee83f11c8d3b1f62", + "sha256:e6d8668fa5f93e706934a62d7b4db19c8d9eb8cf2adbb75ef1b675aa332b69da" + ], + "markers": "python_version >= '3.7'", + "version": "==5.0.1" + }, + "urllib3": { + "hashes": [ + "sha256:a448b2f64d686155468037e1ace9f2d2199776e17f0a46610480d311f73e3472", + "sha256:dd505485549a7a552833da5e6063639d0d177c04f23bc3864e41e5dc5f612168" + ], + "index": "pypi", + "markers": "python_version >= '3.8'", + "version": "==2.2.2" + }, + "watchdog": { + "hashes": [ + "sha256:11e12fafb13372e18ca1bbf12d50f593e7280646687463dd47730fd4f4d5d257", + "sha256:2895bf0518361a9728773083908801a376743bcc37dfa252b801af8fd281b1ca", + "sha256:39cb34b1f1afbf23e9562501673e7146777efe95da24fab5707b88f7fb11649b", + "sha256:45cc09cc4c3b43fb10b59ef4d07318d9a3ecdbff03abd2e36e77b6dd9f9a5c85", + "sha256:4986db5e8880b0e6b7cd52ba36255d4793bf5cdc95bd6264806c233173b1ec0b", + "sha256:5369136a6474678e02426bd984466343924d1df8e2fd94a9b443cb7e3aa20d19", + "sha256:557ba04c816d23ce98a06e70af6abaa0485f6d94994ec78a42b05d1c03dcbd50", + "sha256:6a4db54edea37d1058b08947c789a2354ee02972ed5d1e0dca9b0b820f4c7f92", + "sha256:6a80d5cae8c265842c7419c560b9961561556c4361b297b4c431903f8c33b269", + "sha256:6a9c71a0b02985b4b0b6d14b875a6c86ddea2fdbebd0c9a720a806a8bbffc69f", + "sha256:6c47bdd680009b11c9ac382163e05ca43baf4127954c5f6d0250e7d772d2b80c", + "sha256:6e949a8a94186bced05b6508faa61b7adacc911115664ccb1923b9ad1f1ccf7b", + "sha256:73c7a935e62033bd5e8f0da33a4dcb763da2361921a69a5a95aaf6c93aa03a87", + "sha256:76ad8484379695f3fe46228962017a7e1337e9acadafed67eb20aabb175df98b", + "sha256:8350d4055505412a426b6ad8c521bc7d367d1637a762c70fdd93a3a0d595990b", + "sha256:87e9df830022488e235dd601478c15ad73a0389628588ba0b028cb74eb72fed8", + "sha256:8f9a542c979df62098ae9c58b19e03ad3df1c9d8c6895d96c0d51da17b243b1c", + "sha256:8fec441f5adcf81dd240a5fe78e3d83767999771630b5ddfc5867827a34fa3d3", + "sha256:9a03e16e55465177d416699331b0f3564138f1807ecc5f2de9d55d8f188d08c7", + "sha256:ba30a896166f0fee83183cec913298151b73164160d965af2e93a20bbd2ab605", + "sha256:c17d98799f32e3f55f181f19dd2021d762eb38fdd381b4a748b9f5a36738e935", + "sha256:c522392acc5e962bcac3b22b9592493ffd06d1fc5d755954e6be9f4990de932b", + "sha256:d0f9bd1fd919134d459d8abf954f63886745f4660ef66480b9d753a7c9d40927", + "sha256:d18d7f18a47de6863cd480734613502904611730f8def45fc52a5d97503e5101", + "sha256:d31481ccf4694a8416b681544c23bd271f5a123162ab603c7d7d2dd7dd901a07", + "sha256:e3e7065cbdabe6183ab82199d7a4f6b3ba0a438c5a512a68559846ccb76a78ec", + "sha256:eed82cdf79cd7f0232e2fdc1ad05b06a5e102a43e331f7d041e5f0e0a34a51c4", + "sha256:f970663fa4f7e80401a7b0cbeec00fa801bf0287d93d48368fc3e6fa32716245", + "sha256:f9b2fdca47dc855516b2d66eef3c39f2672cbf7e7a42e7e67ad2cbfcd6ba107d" + ], + "markers": "python_version >= '3.8'", + "version": "==4.0.0" + } + }, + "develop": {} +} diff --git a/README.md b/README.md new file mode 100644 index 000000000..0d5242ac9 --- /dev/null +++ b/README.md @@ -0,0 +1 @@ +# Kotlin Fhir Data-Capture diff --git a/build-docs.bash b/build-docs.bash new file mode 100755 index 000000000..608b3c02c --- /dev/null +++ b/build-docs.bash @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: Apache-2.0 +# +# Copyright 2023-2024 The Enola Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +set -euox pipefail + +rm -rf site/ +mkdir -p site/ + +# We need to generate the Dokka API Doc into docs/use/api/ *before* running mkdocs, +# so that it can validate the links to it and make sure that they are not broken. +./gradlew dokkaHtml + +# "install --deploy" is better than "sync", because it checks that the Pipfile.lock +# is up-to-date with the Pipfile before installing. If it's not, it will fail the +# installation. This is useful for ensuring strict dependency control during CI. +pipenv install --deploy +pipenv run mkdocs build --strict diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 000000000..b91d75aa6 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,41 @@ +// Top-level build file where you can add configuration options common to all sub-projects/modules. +buildscript { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } + dependencies { + classpath(Plugins.androidGradlePlugin) + classpath(Plugins.benchmarkGradlePlugin) + classpath(Plugins.flankGradlePlugin) + classpath(Plugins.kotlinComposePlugin) + classpath(Plugins.kotlinGradlePlugin) + classpath(Plugins.kotlinSerializationPlugin) + classpath(Plugins.kspGradlePlugin) + classpath(Plugins.navSafeArgsGradlePlugin) + classpath(Plugins.rulerGradlePlugin) + } +} + +allprojects { + repositories { + google() + mavenCentral() + maven(url = "https://oss.sonatype.org/content/repositories/snapshots") + gradlePluginPortal() + } + configureSpotless() +} + +subprojects { + applyLicenseeConfig() + + tasks.withType(Test::class.java).configureEach { + maxParallelForks = 1 + if (project.providers.environmentVariable("GITHUB_ACTIONS").isPresent) { + // limit memory usage to avoid running out of memory in the docker container. + maxHeapSize = "512m" + } + } +} diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts new file mode 100644 index 000000000..ff2b065c1 --- /dev/null +++ b/buildSrc/build.gradle.kts @@ -0,0 +1,33 @@ +import org.gradle.api.artifacts.dsl.DependencyHandler +import org.gradle.plugin.use.PluginDependency +import org.gradle.api.provider.Provider + +plugins { + `kotlin-dsl` +} + +repositories { + google() + gradlePluginPortal() + mavenCentral() +} + +dependencies { + implementation(libs.spotless.plugin.gradle) + implementation(libs.gradle) + implementation(libs.licensee.gradle.plugin) + implementation(libs.fladle) + implementation(libs.ruler.gradle.plugin) + implementation(libs.hapi.fhir.structures.r4.v6100) + implementation(libs.kotlinpoet) + implementation(plugin(libs.plugins.android.kotlin.multiplatform.library)) + implementation(plugin(libs.plugins.compose.compiler)) + implementation(plugin(libs.plugins.compose.hotreload)) + implementation(plugin(libs.plugins.compose.multiplatform)) + implementation(plugin(libs.plugins.kotlin.multiplatform)) + implementation(plugin(libs.plugins.android.application.build.src)) + implementation(plugin(libs.plugins.kotlin.serialization.build.src)) +} + +fun DependencyHandler.plugin(plugin: Provider) = + plugin.map { "${it.pluginId}:${it.pluginId}.gradle.plugin:${it.version}" } diff --git a/buildSrc/settings.gradle.kts b/buildSrc/settings.gradle.kts new file mode 100644 index 000000000..215a5d58e --- /dev/null +++ b/buildSrc/settings.gradle.kts @@ -0,0 +1,7 @@ +dependencyResolutionManagement { + versionCatalogs { + create("libs") { + from(files("../gradle/libs.versions.toml")) + } + } +} diff --git a/buildSrc/src/main/kotlin/FirebaseTestLabConfig.kt b/buildSrc/src/main/kotlin/FirebaseTestLabConfig.kt new file mode 100644 index 000000000..d12b5dd46 --- /dev/null +++ b/buildSrc/src/main/kotlin/FirebaseTestLabConfig.kt @@ -0,0 +1,94 @@ +/* + * Copyright 2023-2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import com.android.build.api.dsl.LibraryExtension +import com.osacky.flank.gradle.FlankGradleExtension +import java.util.UUID +import org.gradle.api.Project +import org.gradle.kotlin.dsl.apply +import org.gradle.kotlin.dsl.configure + +fun Project.configureFirebaseTestLabForLibraries() { + apply(plugin = Plugins.BuildPlugins.fladle) + configure { + commonConfigurationForFirebaseTestLab(this@configureFirebaseTestLabForLibraries) + debugApk.set( + project.provider { + "${project.rootDir}/demo/build/outputs/apk/androidTest/debug/demo-debug-androidTest.apk" + }, + ) + instrumentationApk.set(project.provider { "$buildDir/outputs/apk/androidTest/debug/*.apk" }) + environmentVariables.set( + mapOf( + "coverage" to "true", + "coverageFilePath" to "/sdcard/Download/", + "clearPackageData" to "true", + ), + ) + devices.set( + listOf( + mapOf( + "model" to "Nexus6P", + "version" to + "${project.extensions.getByType(LibraryExtension::class.java).defaultConfig.minSdk}", + "locale" to "en_US", + ), + mapOf( + "model" to "MediumPhone.arm", + "version" to "33", + "locale" to "en_US", + ), + ), + ) + } +} + +private fun FlankGradleExtension.commonConfigurationForFirebaseTestLabBenchmark(project: Project) { + commonConfigurationForFirebaseTestLab(project) + environmentVariables.set( + mapOf( + "additionalTestOutputDir" to "/sdcard/Download", + "no-isolated-storage" to "true", + "clearPackageData" to "true", + ), + ) + devices.set( + listOf( + mapOf( + "model" to "panther", + "version" to "33", + "locale" to "en_US", + ), + ), + ) +} + +private fun FlankGradleExtension.commonConfigurationForFirebaseTestLab(project: Project) { + projectId.set("android-fhir-instrumeted-tests") + useOrchestrator.set(true) + flakyTestAttempts.set(1) + maxTestShards.set(10) + testTimeout.set("45m") + directoriesToPull.set(listOf("/sdcard/Download")) + resultsBucket.set("android-fhir-build-artifacts") + resultsDir.set( + if (project.providers.environmentVariable("KOKORO_BUILD_ARTIFACTS_SUBDIR").isPresent) { + "${System.getenv("KOKORO_BUILD_ARTIFACTS_SUBDIR")}/firebase/${project.name}" + } else { + "${project.name}-${UUID.randomUUID()}" + }, + ) +} diff --git a/buildSrc/src/main/kotlin/LicenseeConfig.kt b/buildSrc/src/main/kotlin/LicenseeConfig.kt new file mode 100644 index 000000000..5bfd0b56a --- /dev/null +++ b/buildSrc/src/main/kotlin/LicenseeConfig.kt @@ -0,0 +1,236 @@ +/* + * Copyright 2023-2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import org.gradle.api.Project +import org.gradle.kotlin.dsl.apply +import org.gradle.kotlin.dsl.configure + +@Suppress("unused") +fun Project.applyLicenseeConfig() { + // We have some empty folders like the :contrib root folder, which Gradle recognizes as projects. + // Don't configure plugins for those folders. + if (!project.buildFile.exists()) { + return + } + + configureLicensee() +} + +private fun Project.configureLicensee() { + apply(plugin = "app.cash.licensee") + configure { + allow("Apache-2.0") + allow("BSD-2-Clause") + allow("BSD-3-Clause") + allow("MIT") + + ignoreDependencies("com.ibm.icu", "icu4j") { + because( + "ICU uses an ICU license that was mispaced and cannot be loaded by this tool right now", + ) + } + + // Occasionally, dependencies may add their licenses via a direct URL instead of an SPDX id. + nonStandardLicenseUrls.forEach { allowUrl(it) } + + ignoreDependencies("junit", "junit") { + because("JUnit is used in tests only, so it is not distributed with our library") + } + ignoreDependencies("org.jacoco", "org.jacoco.agent") { + because("JaCoCo is used in tests only, so it is not distributed with our library") + } + allowDependency("org.javassist", "javassist", "3.29.0-GA") { + because("Multi-licensed under Apache. https://github.com/jboss-javassist/javassist") + } + + // xpp3 (HAPI FHIR transitive dep) + allowDependency("xpp3", "xpp3_xpath", "1.1.4c") { + because("Custom license, essentially BSD-5. https://fedoraproject.org/wiki/Licensing/xpp") + } + // json-patch and its transitive deps + allowDependency("com.github.java-json-tools", "btf", "1.3") { + because("Dual-licensed under Apache. https://github.com/java-json-tools/btf") + } + allowDependency("com.github.java-json-tools", "jackson-coreutils", "2.0") { + because("Dual-licensed under Apache. https://github.com/java-json-tools/jackson-coreutils") + } + allowDependency("com.github.java-json-tools", "json-patch", "1.13") { + because("Dual-licensed under Apache. https://github.com/java-json-tools/json-patch") + } + allowDependency("com.github.java-json-tools", "msg-simple", "1.2") { + because("Dual-licensed under Apache. https://github.com/java-json-tools/msg-simple") + } + + // SQLCipher + allowDependency("net.zetetic", "android-database-sqlcipher", "4.5.0") { + because("Custom license, essentially BSD-3. https://www.zetetic.net/sqlcipher/license/") + } + allowDependency("net.zetetic", "android-database-sqlcipher", "4.5.4") { + because("Custom license, essentially BSD-3. https://www.zetetic.net/sqlcipher/license/") + } + + // Jakarta XML Binding API + allowDependency("jakarta.xml.bind", "jakarta.xml.bind-api", "4.0.1") { + because("BSD 3-clause.") + } + + // Jakarta Activation API 2.1 Specification + allowDependency("jakarta.activation", "jakarta.activation-api", "2.1.2") { + because( + "Licensed under Eclipse Distribution License 1.0. http://www.eclipse.org/org/documents/edl-v10.php", + ) + } + + // Jakarta Annotation API 2.1 Specification + allowDependency("jakarta.annotation", "jakarta.annotation-api", "2.1.1") { + because( + "Licensed under EPL 2.0", + ) + } + + // Javax Annotation API + allowDependency("javax.annotation", "javax.annotation-api", "1.3.2") { + because("Dual-licensed under CDDL 1.1 and GPL v2 with classpath exception.") + } + + // Streaming API for XML (StAX) + allowDependency("javax.xml.stream", "stax-api", "1.0-2") { + because("Dual-licensed under CDDL 1.0 and GPL v3.") + } + + // xml-commons + allowDependency("xml-apis", "xml-apis", "1.4.01") { + because("Licensed under Mozilla Public License Version 2.0. http://www.mozilla.org/MPL/2.0/") + } + + // The XSLT and XQuery Processor + allowDependency("net.sf.saxon", "Saxon-HE", "9.8.0-15") { + because("BSD 3-clause. http://www.antlr.org/license.html") + } + + // ANTLR 4 + allowDependency("org.antlr", "antlr-runtime", "3.5.3") { + because("BSD 3-clause. http://www.antlr.org/license.html") + } + // ANTLR 4 + allowDependency("org.antlr", "antlr4-runtime", "4.13.1") { + because("BSD 3-clause. http://www.antlr.org/license.html") + } + + // Utilities + // https://developers.google.com/android/reference/com/google/android/gms/common/package-summary + allowDependency("com.google.android.gms", "play-services-base", "17.4.0") { because("") } + allowDependency("com.google.android.gms", "play-services-base", "18.0.1") { because("") } + allowDependency("com.google.android.gms", "play-services-base", "18.1.0") { because("") } + + allowDependency("com.google.android.odml", "image", "1.0.0-beta1") { because("") } + + // More utility classes + // https://developers.google.com/android/reference/com/google/android/gms/common/package-summary + allowDependency("com.google.android.gms", "play-services-basement", "17.4.0") { because("") } + allowDependency("com.google.android.gms", "play-services-basement", "18.0.0") { because("") } + allowDependency("com.google.android.gms", "play-services-basement", "18.1.0") { because("") } + + // https://developers.google.com/android/reference/com/google/android/gms/common/package-summary + allowDependency("com.google.android.gms", "play-services-clearcut", "17.0.0") { because("") } + + // ML Kit barcode scanning https://developers.google.com/ml-kit/vision/barcode-scanning/android + allowDependency("com.google.android.gms", "play-services-mlkit-barcode-scanning", "16.1.4") { + because("") + } + + // Play Services Phenotype + allowDependency("com.google.android.gms", "play-services-phenotype", "17.0.0") { because("") } + + // Tasks API Android https://developers.google.com/android/guides/tasks + allowDependency("com.google.android.gms", "play-services-tasks", "17.2.0") { because("") } + allowDependency("com.google.android.gms", "play-services-tasks", "18.0.1") { because("") } + allowDependency("com.google.android.gms", "play-services-tasks", "18.0.2") { because("") } + + // Barcode Scanning https://developers.google.com/ml-kit/vision/barcode-scanning + allowDependency("com.google.mlkit", "barcode-scanning", "16.1.1") { because("") } + + // MLKit Common https://developers.google.com/ml-kit/vision/barcode-scanning + allowDependency("com.google.mlkit", "common", "17.1.1") { because("") } + allowDependency("com.google.mlkit", "common", "18.0.0") { because("") } + + allowDependency("com.google.mlkit", "camera", "16.0.0-beta3") { because("") } + + // Object Detection https://developers.google.com/ml-kit/vision/object-detection + allowDependency("com.google.mlkit", "object-detection", "16.2.3") { because("") } + + // Object Detection https://developers.google.com/ml-kit/vision/object-detection + allowDependency("com.google.mlkit", "object-detection-common", "17.0.0") { because("") } + + // Object Detection https://developers.google.com/ml-kit/vision/object-detection + allowDependency("com.google.mlkit", "object-detection-custom", "16.3.1") { because("") } + + // Vision Common + // https://developers.google.com/android/reference/com/google/mlkit/vision/common/package-summary + allowDependency("com.google.mlkit", "vision-common", "16.3.0") { because("") } + allowDependency("com.google.mlkit", "vision-common", "17.0.0") { because("") } + + // Vision Common + // https://developers.google.com/android/reference/com/google/mlkit/vision/common/package-summary + allowDependency("com.google.mlkit", "vision-internal-vkp", "18.0.0") { because("") } + + allowDependency("com.google.mlkit", "vision-interfaces", "16.0.0") { because("") } + + // Glide + allowDependency("com.github.bumptech.glide", "glide", "4.14.2") { + because("BSD, part MIT and Apache 2.0. https://github.com/bumptech/glide#license") + } + + // Glide Annotations + allowDependency("com.github.bumptech.glide", "annotations", "4.14.2") { + because("BSD, part MIT and Apache 2.0. https://github.com/bumptech/glide#license") + } + + // Glide Disk LRU Cache + allowDependency("com.github.bumptech.glide", "disklrucache", "4.14.2") { + because("BSD, part MIT and Apache 2.0. https://github.com/bumptech/glide#license") + } + + // Glide GIF Decoder + allowDependency("com.github.bumptech.glide", "gifdecoder", "4.14.2") { + because("BSD, part MIT and Apache 2.0. https://github.com/bumptech/glide#license") + } + + // ICU4C License + allowDependency("com.ibm.icu", "icu4j", "72.1") { + because("BSD, part MIT and Apache 2.0. https://github.com/unicode-org/icu/blob/main/LICENSE") + } + + // Logback + allowDependency("ch.qos.logback", "logback-classic", "1.4.14") { because("LGPL") } + + // Logback + allowDependency("ch.qos.logback", "logback-core", "1.4.14") { because("LGPL") } + + // KMP FileKit + allowUrl("https://github.com/vinceglb/FileKit/blob/main/LICENSE") { because("") } + allowUrl("https://github.com/hypfvieh/dbus-java/blob/master/LICENSE") { because("") } + } +} + +private val nonStandardLicenseUrls = + listOf( + // BSD-3 + "http://opensource.org/licenses/BSD-3-Clause", + "http://www.opensource.org/licenses/bsd-license.php", + "https://asm.ow2.io/license.html", + "https://developer.android.com/studio/terms.html", + ) diff --git a/buildSrc/src/main/kotlin/LocalProperties.kt b/buildSrc/src/main/kotlin/LocalProperties.kt new file mode 100644 index 000000000..bd1918f89 --- /dev/null +++ b/buildSrc/src/main/kotlin/LocalProperties.kt @@ -0,0 +1,39 @@ +/* + * Copyright 2025-2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import java.io.File +import java.io.FileInputStream +import java.io.InputStreamReader +import java.util.Properties +import org.gradle.api.Project + +/** + * Retrieve the project local properties if they are available. If there is no local properties file + * then an empty set of properties is returned. + */ +@Suppress("unused") +fun Project.gradleLocalProperties(file: String = "local.properties"): Properties { + val properties = Properties() + val localProperties = File(rootDir, file) + if (localProperties.isFile) { + InputStreamReader(FileInputStream(localProperties), Charsets.UTF_8).use { reader -> + properties.load(reader) + } + } else { + println("Gradle local properties file not found at $localProperties") + } + return properties +} diff --git a/buildSrc/src/main/kotlin/Plugins.kt b/buildSrc/src/main/kotlin/Plugins.kt new file mode 100644 index 000000000..c2a2d1b54 --- /dev/null +++ b/buildSrc/src/main/kotlin/Plugins.kt @@ -0,0 +1,46 @@ +/* + * Copyright 2023-2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +object Plugins { + + object BuildPlugins { + const val fladle = "com.osacky.fladle" + const val ruler = "com.spotify.ruler" + const val spotless = "com.diffplug.spotless" + } + + // classpath plugins + const val androidGradlePlugin = "com.android.tools.build:gradle:${Versions.androidGradlePlugin}" + const val benchmarkGradlePlugin = + "androidx.benchmark:benchmark-gradle-plugin:${Versions.benchmarkPlugin}" + const val kotlinGradlePlugin = "org.jetbrains.kotlin:kotlin-gradle-plugin:${Versions.kotlin}" + const val kotlinComposePlugin = + "org.jetbrains.kotlin.plugin.compose:org.jetbrains.kotlin.plugin.compose.gradle.plugin:${Versions.kotlin}" + const val kotlinSerializationPlugin = + "org.jetbrains.kotlin.plugin.serialization:org.jetbrains.kotlin.plugin.serialization.gradle.plugin:${Versions.kotlin}" + const val navSafeArgsGradlePlugin = "androidx.navigation:navigation-safe-args-gradle-plugin:2.6.0" + const val rulerGradlePlugin = "com.spotify.ruler:ruler-gradle-plugin:1.2.1" + const val flankGradlePlugin = "com.osacky.flank.gradle:fladle:0.17.4" + const val kspGradlePlugin = + "com.google.devtools.ksp:com.google.devtools.ksp.gradle.plugin:${Versions.kspPlugin}" + + object Versions { + const val androidGradlePlugin = "8.9.2" + const val benchmarkPlugin = "1.4.0-rc01" + const val kspPlugin = "2.2.20-2.0.4" + const val kotlin = "2.2.20" + } +} diff --git a/buildSrc/src/main/kotlin/Releases.kt b/buildSrc/src/main/kotlin/Releases.kt new file mode 100644 index 000000000..6406b9a82 --- /dev/null +++ b/buildSrc/src/main/kotlin/Releases.kt @@ -0,0 +1,124 @@ +/* + * Copyright 2023-2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import org.gradle.api.Project +import org.gradle.api.publish.PublishingExtension +import org.gradle.api.publish.maven.MavenPublication +import org.gradle.kotlin.dsl.configure +import org.gradle.kotlin.dsl.get +import org.gradle.kotlin.dsl.getByType +import org.gradle.kotlin.dsl.register + +interface LibraryArtifact { + /** Maven coordinate artifact id. */ + val artifactId: String + + /** Maven coordinate version. */ + val version: String + + /** Descriptive name for library. */ + val name: String +} + +object Releases { + const val groupId = "dev.ohs.fhir" + + // Libraries + // After releasing a new version of a library, you will need to bump up the library version + // in Dependencies.kt (in a separate PR) + + object DataCapture : LibraryArtifact { + override val artifactId = "data-capture" + override val version = "1.3.1" + override val name = "Android FHIR Structured Data Capture Library" + } + + object Contrib { + object Barcode : LibraryArtifact { + override val artifactId = "contrib-barcode" + override val version = "0.1.0-beta3" + override val name = "Android FHIR Structured Data Capture - Barcode Extensions (contrib)" + } + + object LocationWidget : LibraryArtifact { + override val artifactId = "contrib-locationwidget" + override val version = "0.1.0-alpha01" + override val name = + "Android FHIR Structured Data Capture - Location Widget Extensions (contrib)" + } + } + + // Demo apps + object Catalog { + const val applicationId = "dev.ohs.fhir.catalog" + const val versionCode = 1 + const val versionName = "1.0" + } +} + +fun Project.publishArtifact(artifact: LibraryArtifact) { + val variantToPublish = "release" + project.extensions + .getByType() + .publishing + .singleVariant(variantToPublish) { withSourcesJar() } + afterEvaluate { + configure { + publications { + register(variantToPublish) { + groupId = Releases.groupId + artifactId = artifact.artifactId + version = artifact.version + from(components[variantToPublish]) + pom { + name.set(artifact.name) + licenses { + license { + name.set("The Apache License, Version 2.0") + url.set("https://www.apache.org/licenses/LICENSE-2.0.txt") + } + } + } + repositories { + maven { + name = "CI" + url = + if (System.getenv("REPOSITORY_URL") != null) { + // REPOSITORY_URL is defined in .github/workflows/build.yml + uri(System.getenv("REPOSITORY_URL")) + } else { + uri("file://${rootProject.buildDir}/ci-repo") + } + version = + if (project.providers.environmentVariable("GITHUB_ACTIONS").isPresent) { + // ARTIFACT_VERSION_SUFFIX is defined in .github/workflows/build.yml + "${artifact.version}-${System.getenv("ARTIFACT_VERSION_SUFFIX")}" + } else { + artifact.version + } + if (System.getenv("GITHUB_TOKEN") != null) { + credentials { + username = System.getenv("GITHUB_ACTOR") + password = System.getenv("GITHUB_TOKEN") + } + } + } + } + } + } + } + } +} diff --git a/buildSrc/src/main/kotlin/RulerConfig.kt b/buildSrc/src/main/kotlin/RulerConfig.kt new file mode 100644 index 000000000..1f4b70f3f --- /dev/null +++ b/buildSrc/src/main/kotlin/RulerConfig.kt @@ -0,0 +1,30 @@ +/* + * Copyright 2021-2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import org.gradle.api.Project +import org.gradle.kotlin.dsl.apply +import org.gradle.kotlin.dsl.configure + +@Suppress("unused") +fun Project.configureRuler() { + apply(plugin = Plugins.BuildPlugins.ruler) + configure { + abi.set("arm64-v8a") + locale.set("en") + screenDensity.set(480) + sdkVersion.set(27) + } +} diff --git a/buildSrc/src/main/kotlin/Sdk.kt b/buildSrc/src/main/kotlin/Sdk.kt new file mode 100644 index 000000000..69e3a9ade --- /dev/null +++ b/buildSrc/src/main/kotlin/Sdk.kt @@ -0,0 +1,24 @@ +/* + * Copyright 2023-2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +object Sdk { + const val COMPILE_SDK = 36 + const val TARGET_SDK = 33 + + // Engine and SDC must support API 24. + // Remove desugaring when upgrading it to 26. + const val MIN_SDK = 26 +} diff --git a/buildSrc/src/main/kotlin/SpotlessConfig.kt b/buildSrc/src/main/kotlin/SpotlessConfig.kt new file mode 100644 index 000000000..740540d8b --- /dev/null +++ b/buildSrc/src/main/kotlin/SpotlessConfig.kt @@ -0,0 +1,54 @@ +/* + * Copyright 2022-2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import org.gradle.api.Project +import org.gradle.kotlin.dsl.apply +import org.gradle.kotlin.dsl.configure + +@Suppress("unused") +fun Project.configureSpotless() { + val ktlintVersion = "0.50.0" + val ktlintOptions = mapOf("indent_size" to "2", "continuation_indent_size" to "2") + apply(plugin = Plugins.BuildPlugins.spotless) + configure { + ratchetFrom = "origin/main" + kotlin { + target("**/*.kt") + targetExclude("**/build/") + targetExclude("**/*_Generated.kt") + ktlint(ktlintVersion).userData(ktlintOptions) + ktfmt().googleStyle() + licenseHeaderFile( + "${project.rootProject.projectDir}/license-header.txt", + "package|import|class|object|sealed|open|interface|abstract ", + // It is necessary to tell spotless the top level of a file in order to apply config to it + // See: https://github.com/diffplug/spotless/issues/135 + ) + toggleOffOn() + } + kotlinGradle { + target("*.gradle.kts") + ktlint(ktlintVersion).userData(ktlintOptions) + ktfmt().googleStyle() + } + format("xml") { + target("**/*.xml") + targetExclude("**/build/", ".idea/") + prettier(mapOf("prettier" to "2.0.5", "@prettier/plugin-xml" to "0.13.0")) + .config(mapOf("parser" to "xml", "tabWidth" to 4)) + } + } +} diff --git a/datacapture/.gitignore b/datacapture/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/datacapture/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/datacapture/build.gradle.kts b/datacapture/build.gradle.kts new file mode 100644 index 000000000..1ef8fa72f --- /dev/null +++ b/datacapture/build.gradle.kts @@ -0,0 +1,144 @@ +@file:OptIn(ExperimentalWasmDsl::class) + +import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi +import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl + +plugins { + id("org.jetbrains.kotlin.multiplatform") + id("com.android.kotlin.multiplatform.library") + id("org.jetbrains.kotlin.plugin.compose") + id("org.jetbrains.compose.hot-reload") + id("org.jetbrains.compose") + alias(libs.plugins.ksp) +} + +group = "dev.ohs.fhir" + +kotlin { + jvmToolchain(21) + + androidLibrary { + namespace = "dev.ohs.fhir.datacapture" + compileSdk = Sdk.COMPILE_SDK + minSdk = Sdk.MIN_SDK + withJava() + withHostTestBuilder {} + withDeviceTestBuilder { sourceSetTreeName = "test" } + .configure { instrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } + + experimentalProperties["android.experimental.kmp.enableAndroidResources"] = true + + compilations.configureEach { + compilerOptions.configure { + jvmTarget.set( + org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_11, + ) + } + } + + packaging { + resources.excludes.addAll( + listOf("META-INF/ASL2.0", "META-INF/ASL-2.0.txt", "META-INF/LGPL-3.0.txt"), + ) + } + } + + val xcfName = "sharedKit" + + iosX64 { binaries.framework { baseName = xcfName } } + + iosArm64 { binaries.framework { baseName = xcfName } } + + iosSimulatorArm64 { binaries.framework { baseName = xcfName } } + + wasmJs { + browser() + binaries.library() + } + + jvm("desktop") + + js { + browser() + binaries.library() + } + + sourceSets { + all { + languageSettings { + @OptIn(ExperimentalKotlinGradlePluginApi::class) + compilerOptions { freeCompilerArgs.add("-Xexpect-actual-classes") } + } + } + + commonMain { + dependencies { + implementation(libs.material.icons.extended) + implementation(compose.runtime) + implementation(compose.foundation) + implementation(compose.material3) + implementation(compose.ui) + implementation(compose.components.resources) + implementation(compose.components.uiToolingPreview) + implementation(libs.fhir.path) + implementation(libs.navigation.compose) + implementation(libs.androidx.lifecycle.viewmodel.compose) + implementation(libs.androidx.lifecycle.runtime.compose) + implementation(libs.filekit.dialogs.compose) + implementation(libs.kermit) + implementation(libs.kotlinx.coroutines.core) + implementation(libs.kotlin.fhir) + implementation(libs.kotlinx.io.core) + implementation(libs.kotlinx.serialization.json) + } + } + + commonTest { + dependencies { + implementation(libs.androidx.lifecycle.runtime.testing) + implementation(libs.kotlin.test) + implementation(libs.kotest.assertions.core) + + @OptIn(org.jetbrains.compose.ExperimentalComposeLibrary::class) + implementation(compose.uiTest) + } + } + + androidMain { resources.srcDir("res") } + + getByName("androidDeviceTest") { + dependencies { + implementation(libs.androidx.compose.ui.test.junit4) + implementation(libs.androidx.compose.ui.test.manifest) + implementation(libs.androidx.test.core) + implementation(libs.androidx.test.ext.junit) + implementation(libs.androidx.test.ext.junit.ktx) + implementation(libs.androidx.test.runner) + implementation(libs.androidx.test.rules) + implementation(libs.kotlinx.coroutines.test) + implementation(libs.truth) + } + } + + getByName("androidHostTest") { + dependencies { + implementation(libs.androidx.fragment.testing) + implementation(libs.androidx.test.core) + implementation(libs.junit) + implementation(libs.kotlin.test.junit) + implementation(libs.kotlinx.coroutines.test) + implementation(libs.truth) + /* implementation(project(":knowledge")) { + exclude(group = "com.google.android.fhir", module = "engine") + }*/ + } + } + + val desktopMain by getting { + dependencies { + implementation(compose.desktop.currentOs) + implementation(libs.kotlinx.coroutines.swing) + } + } + } +} diff --git a/datacapture/src/androidDeviceTest/kotlin/dev/ohs/fhir/datacapture/ExampleInstrumentedTest.kt b/datacapture/src/androidDeviceTest/kotlin/dev/ohs/fhir/datacapture/ExampleInstrumentedTest.kt new file mode 100644 index 000000000..17764b046 --- /dev/null +++ b/datacapture/src/androidDeviceTest/kotlin/dev/ohs/fhir/datacapture/ExampleInstrumentedTest.kt @@ -0,0 +1,38 @@ +/* + * Copyright 2025-2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.ohs.fhir.datacapture + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Assert +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + Assert.assertEquals("dev.ohs.fhir.datacapture.test", appContext.packageName) + } +} diff --git a/datacapture/src/androidDeviceTest/kotlin/dev/ohs/fhir/datacapture/views/factories/DateTimeViewFactoryTest.kt b/datacapture/src/androidDeviceTest/kotlin/dev/ohs/fhir/datacapture/views/factories/DateTimeViewFactoryTest.kt new file mode 100644 index 000000000..55944ef9c --- /dev/null +++ b/datacapture/src/androidDeviceTest/kotlin/dev/ohs/fhir/datacapture/views/factories/DateTimeViewFactoryTest.kt @@ -0,0 +1,1214 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.ohs.fhir.datacapture.views.factories + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.test.assert +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.assertTextContains +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.filterToOne +import androidx.compose.ui.test.hasAnyChild +import androidx.compose.ui.test.hasContentDescription +import androidx.compose.ui.test.hasTextExactly +import androidx.compose.ui.test.isEditable +import androidx.compose.ui.test.onChildren +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextReplacement +import androidx.compose.ui.test.runComposeUiTest +import androidx.compose.ui.text.AnnotatedString +import com.google.fhir.model.r4.DateTime +import com.google.fhir.model.r4.Enumeration +import com.google.fhir.model.r4.Extension +import com.google.fhir.model.r4.FhirDateTime +import com.google.fhir.model.r4.Questionnaire +import com.google.fhir.model.r4.QuestionnaireResponse +import dev.ohs.fhir.datacapture.extensions.FhirR4Boolean +import dev.ohs.fhir.datacapture.extensions.FhirR4String +import dev.ohs.fhir.datacapture.theme.QuestionnaireTheme +import dev.ohs.fhir.datacapture.validation.Invalid +import dev.ohs.fhir.datacapture.validation.NotValidated +import dev.ohs.fhir.datacapture.views.QuestionTextConfiguration +import dev.ohs.fhir.datacapture.views.QuestionnaireViewItem +import dev.ohs.fhir.datacapture.views.components.DATE_TEXT_INPUT_FIELD +import dev.ohs.fhir.datacapture.views.components.ERROR_TEXT_AT_HEADER_TEST_TAG +import dev.ohs.fhir.datacapture.views.components.QUESTION_HEADER_TAG +import dev.ohs.fhir.datacapture.views.components.TIME_PICKER_INPUT_FIELD +import io.kotest.matchers.collections.shouldBeEmpty +import io.kotest.matchers.nulls.shouldBeNull +import io.kotest.matchers.nulls.shouldNotBeNull +import io.kotest.matchers.shouldBe +import java.util.Locale +import kotlin.test.Test +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.UtcOffset +import kotlinx.datetime.number + +@OptIn(ExperimentalTestApi::class) +class DateTimeViewFactoryTest { + @Composable + fun QuestionnaireDateTimeView(questionnaireViewItem: QuestionnaireViewItem) { + QuestionnaireTheme { DateTimeViewFactory.Content(questionnaireViewItem) } + } + + @Test + fun shouldSetQuestionHeader() = runComposeUiTest { + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "datetime-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.DateTime), + text = FhirR4String(value = "Question?"), + ), + QuestionnaireResponse.Item( + linkId = + FhirR4String( + value = "datetime-item", + ), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + + setContent { QuestionnaireDateTimeView(questionnaireViewItem) } + + onNodeWithTag(QUESTION_HEADER_TAG).assertTextEquals("Question?") + } + + @Test + fun shouldSetEmptyDateTimeInput() = runComposeUiTest { + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "datetime-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.DateTime), + text = FhirR4String(value = "Question?"), + ), + QuestionnaireResponse.Item( + linkId = + FhirR4String( + value = "datetime-item", + ), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + + setContent { QuestionnaireDateTimeView(questionnaireViewItem) } + + onNodeWithTag(DATE_TEXT_INPUT_FIELD, useUnmergedTree = true).assertTextEquals("") + onNodeWithTag(TIME_PICKER_INPUT_FIELD, useUnmergedTree = true).assertTextEquals("") + } + + @Test + fun showDateFormatLabelInLowerCase() = runComposeUiTest { + Locale.setDefault(Locale.US) + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "datetime-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.DateTime), + ), + QuestionnaireResponse.Item( + linkId = + FhirR4String( + value = "datetime-item", + ), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + + setContent { QuestionnaireDateTimeView(questionnaireViewItem) } + onNodeWithTag(DATE_TEXT_INPUT_FIELD).assertTextEquals("mm/dd/yyyy", includeEditableText = false) + } + + @Test + fun shouldSetDateTimeInput() = runComposeUiTest { + Locale.setDefault(Locale.US) + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "datetime-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.DateTime), + text = FhirR4String(value = "Question?"), + ), + QuestionnaireResponse.Item( + linkId = FhirR4String(value = "datetime-item"), + answer = + listOf( + QuestionnaireResponse.Item.Answer( + value = + QuestionnaireResponse.Item.Answer.Value.DateTime( + value = + DateTime( + value = + FhirDateTime.DateTime( + dateTime = LocalDateTime(2020, 1, 5, 1, 30), + utcOffset = UtcOffset.ZERO, + ), + ), + ), + ), + ), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + + setContent { QuestionnaireDateTimeView(questionnaireViewItem) } + + onNodeWithTag(DATE_TEXT_INPUT_FIELD, useUnmergedTree = true).assertTextEquals("01/05/2020") + onNodeWithTag(TIME_PICKER_INPUT_FIELD, useUnmergedTree = true) + .assert( + hasTextExactly("1:30 AM"), + ) + } + + @Test + fun parseDateTextInputInUSLocale() = runComposeUiTest { + Locale.setDefault(Locale.US) + var answer: QuestionnaireResponse.Item.Answer? = null + val itemViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "datetime-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.DateTime), + text = FhirR4String(value = "Question?"), + ), + QuestionnaireResponse.Item( + linkId = FhirR4String(value = "datetime-item"), + answer = + listOf( + QuestionnaireResponse.Item.Answer( + value = + QuestionnaireResponse.Item.Answer.Value.DateTime( + value = + DateTime( + value = + FhirDateTime.DateTime( + dateTime = LocalDateTime(2020, 1, 5, 1, 30), + utcOffset = UtcOffset.ZERO, + ), + ), + ), + ), + ), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, result, _ -> answer = result.singleOrNull() }, + ) + setContent { QuestionnaireDateTimeView(itemViewItem) } + + onNodeWithTag(DATE_TEXT_INPUT_FIELD).performTextReplacement("11/19/2020") + waitUntil { answer != null } + + val dateTime = (answer!!.value?.asDateTime()?.value?.value as? FhirDateTime.DateTime)?.dateTime + dateTime.shouldNotBeNull() + dateTime.day.shouldBe(19) + dateTime.month.number.shouldBe(11) + dateTime.year.shouldBe(2020) + } + + @Test + fun parseDateTextInputInJapanLocale() = runComposeUiTest { + Locale.setDefault(Locale.JAPAN) + var answer: QuestionnaireResponse.Item.Answer? = null + val itemViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "datetime-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.DateTime), + text = FhirR4String(value = "Question?"), + ), + QuestionnaireResponse.Item( + linkId = FhirR4String(value = "datetime-item"), + answer = + listOf( + QuestionnaireResponse.Item.Answer( + value = + QuestionnaireResponse.Item.Answer.Value.DateTime( + value = + DateTime( + value = + FhirDateTime.DateTime( + dateTime = LocalDateTime(2020, 1, 5, 1, 30), + utcOffset = UtcOffset.ZERO, + ), + ), + ), + ), + ), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, result, _ -> answer = result.singleOrNull() }, + ) + setContent { QuestionnaireDateTimeView(itemViewItem) } + + onNodeWithTag(DATE_TEXT_INPUT_FIELD).performTextReplacement("2020/11/19") + waitUntil { answer != null } + + val dateTime = (answer!!.value?.asDateTime()?.value?.value as? FhirDateTime.DateTime)?.dateTime + dateTime.shouldNotBeNull() + dateTime.day.shouldBe(19) + dateTime.month.number.shouldBe(11) + dateTime.year.shouldBe(2020) + } + + @Test + fun ifDateInputIsInvalidThenClearTheAnswer() = runComposeUiTest { + Locale.setDefault(Locale.JAPAN) + var answers: List? = null + var draftAnswer: Any? = null + val itemViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "datetime-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.DateTime), + text = FhirR4String(value = "Question?"), + ), + QuestionnaireResponse.Item( + linkId = FhirR4String(value = "datetime-item"), + answer = + listOf( + QuestionnaireResponse.Item.Answer( + value = + QuestionnaireResponse.Item.Answer.Value.DateTime( + value = + DateTime( + value = + FhirDateTime.DateTime( + dateTime = LocalDateTime(2020, 1, 5, 1, 30), + utcOffset = UtcOffset.ZERO, + ), + ), + ), + ), + ), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, result, draft -> + answers = result + draftAnswer = draft + }, + ) + setContent { QuestionnaireDateTimeView(itemViewItem) } + + onNodeWithTag(DATE_TEXT_INPUT_FIELD).performTextReplacement("2020/11") + waitUntil { answers != null } + + answers!!.shouldBeEmpty() + (draftAnswer as String).shouldBe("2020/11") + } + + @Test + fun doNotClearTheTextFieldInputOnInvalidDate() = runComposeUiTest { + Locale.setDefault(Locale.JAPAN) + val itemViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "datetime-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.DateTime), + text = FhirR4String(value = "Question?"), + ), + QuestionnaireResponse.Item( + linkId = FhirR4String(value = "datetime-item"), + answer = + listOf( + QuestionnaireResponse.Item.Answer( + value = + QuestionnaireResponse.Item.Answer.Value.DateTime( + value = + DateTime( + value = + FhirDateTime.DateTime( + dateTime = LocalDateTime(2020, 1, 5, 1, 30), + utcOffset = UtcOffset.ZERO, + ), + ), + ), + ), + ), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + setContent { QuestionnaireDateTimeView(itemViewItem) } + onNodeWithTag(DATE_TEXT_INPUT_FIELD).performTextReplacement("2020/11") + + onNodeWithTag(DATE_TEXT_INPUT_FIELD, useUnmergedTree = true).assertTextEquals("2020/11") + } + + @Test + fun clearQuestionnaireResponseAnswerOnDraftAnswerUpdate() = runComposeUiTest { + var answers: List? = + listOf(QuestionnaireResponse.Item.Answer()) + val questionnaireItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "datetime-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.DateTime), + text = FhirR4String(value = "Question?"), + ), + QuestionnaireResponse.Item( + linkId = FhirR4String(value = "datetime-item"), + answer = + listOf( + QuestionnaireResponse.Item.Answer( + value = + QuestionnaireResponse.Item.Answer.Value.DateTime( + value = + DateTime( + value = + FhirDateTime.DateTime( + dateTime = LocalDateTime(2020, 1, 5, 1, 30), + utcOffset = UtcOffset.ZERO, + ), + ), + ), + ), + ), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, answersForCallback, _ -> answers = answersForCallback }, + ) + + setContent { QuestionnaireDateTimeView(questionnaireItem) } + questionnaireItem.setDraftAnswer("02/07") + + answers!!.shouldBeEmpty() + } + + @Test + fun clearDraftAnswerOnAnValidAnswerUpdate() = runComposeUiTest { + val answer = + QuestionnaireResponse.Item.Answer( + value = + QuestionnaireResponse.Item.Answer.Value.DateTime( + value = + DateTime( + value = + FhirDateTime.DateTime( + dateTime = LocalDateTime(2020, 2, 6, 2, 30), + utcOffset = UtcOffset.ZERO, + ), + ), + ), + ) + var draft: String? = "02/07" + val questionnaireItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "datetime-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.DateTime), + ), + QuestionnaireResponse.Item( + linkId = FhirR4String(value = "datetime-item"), + answer = + listOf( + QuestionnaireResponse.Item.Answer( + value = + QuestionnaireResponse.Item.Answer.Value.DateTime( + value = + DateTime( + value = + FhirDateTime.DateTime( + dateTime = LocalDateTime(2020, 1, 5, 1, 30), + utcOffset = UtcOffset.ZERO, + ), + ), + ), + ), + ), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, draftAnswer -> draft = draftAnswer as? String }, + ) + + setContent { QuestionnaireDateTimeView(questionnaireItem) } + questionnaireItem.setAnswer(answer) + draft.shouldBeNull() + } + + @Test + fun displayDraftAnswerInTheTextFieldOfRecycledItems() = runComposeUiTest { + Locale.setDefault(Locale.US) + var questionnaireItem by + mutableStateOf( + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "datetime-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.DateTime), + ), + QuestionnaireResponse.Item( + linkId = FhirR4String(value = "datetime-item"), + answer = + listOf( + QuestionnaireResponse.Item.Answer( + value = + QuestionnaireResponse.Item.Answer.Value.DateTime( + value = + DateTime( + value = + FhirDateTime.DateTime( + dateTime = LocalDateTime(2020, 1, 5, 1, 30), + utcOffset = UtcOffset.ZERO, + ), + ), + ), + ), + ), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ), + ) + + setContent { QuestionnaireDateTimeView(questionnaireItem) } + onNodeWithTag(DATE_TEXT_INPUT_FIELD, useUnmergedTree = true).assertTextEquals("01/05/2020") + + questionnaireItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "datetime-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.DateTime), + ), + QuestionnaireResponse.Item( + linkId = + FhirR4String( + value = "datetime-item", + ), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + draftAnswer = "02/07", + ) + + onNodeWithTag(DATE_TEXT_INPUT_FIELD, useUnmergedTree = true).assertTextEquals("02/07") + } + + @Test + fun displayAnAnswerInTheTextFieldOfPartiallyAnsweredRecycledItem() = runComposeUiTest { + Locale.setDefault(Locale.US) + var questionnaireItem by + mutableStateOf( + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "datetime-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.DateTime), + ), + QuestionnaireResponse.Item( + linkId = + FhirR4String( + value = "datetime-item", + ), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + draftAnswer = "02/07", + ), + ) + + setContent { QuestionnaireDateTimeView(questionnaireItem) } + onNodeWithTag(DATE_TEXT_INPUT_FIELD, useUnmergedTree = true).assertTextEquals("02/07") + + questionnaireItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "datetime-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.DateTime), + ), + QuestionnaireResponse.Item( + linkId = FhirR4String(value = "datetime-item"), + answer = + listOf( + QuestionnaireResponse.Item.Answer( + value = + QuestionnaireResponse.Item.Answer.Value.DateTime( + value = + DateTime( + value = + FhirDateTime.DateTime( + dateTime = LocalDateTime(2020, 1, 5, 1, 30), + utcOffset = UtcOffset.ZERO, + ), + ), + ), + ), + ), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + + onNodeWithTag(DATE_TEXT_INPUT_FIELD, useUnmergedTree = true).assertTextEquals("01/05/2020") + } + + @Test + fun ifDraftAnswerInputIsInvalidThenDoNotEnableTimeTextInputLayout() = runComposeUiTest { + val itemViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "datetime-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.DateTime), + text = FhirR4String(value = "Question?"), + ), + QuestionnaireResponse.Item( + linkId = + FhirR4String( + value = "datetime-item", + ), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + draftAnswer = "11/19", + ) + + setContent { QuestionnaireDateTimeView(itemViewItem) } + onNodeWithTag(TIME_PICKER_INPUT_FIELD).assertIsNotEnabled() + } + + @Test + fun ifTheDraftAnswerInputIsEmptyDoNotEnableTheTimeTextInputLayout() = runComposeUiTest { + val itemViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "datetime-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.DateTime), + text = FhirR4String(value = "Question?"), + ), + QuestionnaireResponse.Item( + linkId = + FhirR4String( + value = "datetime-item", + ), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + draftAnswer = "", + ) + + setContent { QuestionnaireDateTimeView(itemViewItem) } + onNodeWithTag(TIME_PICKER_INPUT_FIELD).assertIsNotEnabled() + } + + @Test + fun ifThereIsNoAnswerOrDraftAnswerDoNotEnableTheTimeTextInputLayout() = runComposeUiTest { + val itemViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "datetime-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.DateTime), + text = FhirR4String(value = "Question?"), + ), + QuestionnaireResponse.Item( + linkId = + FhirR4String( + value = "datetime-item", + ), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + draftAnswer = null, + ) + + setContent { QuestionnaireDateTimeView(itemViewItem) } + onNodeWithTag(TIME_PICKER_INPUT_FIELD).assertIsNotEnabled() + } + + @Test + fun ifDateDraftAnswerIsValidThenEnableTimeTextInputLayout() = runComposeUiTest { + val itemViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "datetime-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.DateTime), + text = FhirR4String(value = "Question?"), + ), + QuestionnaireResponse.Item( + linkId = + FhirR4String( + value = "datetime-item", + ), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + draftAnswer = "11/19/2020", + ) + + setContent { QuestionnaireDateTimeView(itemViewItem) } + onNodeWithTag(TIME_PICKER_INPUT_FIELD).assertIsNotEnabled() + } + + @Test + fun displayValidationResult_error_shouldShowErrorMessage() = runComposeUiTest { + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "datetime-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.DateTime), + required = FhirR4Boolean(value = true), + ), + QuestionnaireResponse.Item( + linkId = + FhirR4String( + value = "datetime-item", + ), + ), + validationResult = Invalid(listOf("Missing answer for required field.")), + answersChangedCallback = { _, _, _, _ -> }, + ) + + setContent { QuestionnaireDateTimeView(questionnaireViewItem) } + + onNodeWithTag(DATE_TEXT_INPUT_FIELD) + .assert( + SemanticsMatcher.expectValue( + SemanticsProperties.Error, + "Missing answer for required field.", + ), + ) + } + + @Test + fun displayValidationResult_noError_shouldShowNoErrorMessage() = runComposeUiTest { + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "datetime-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.DateTime), + extension = + listOf( + Extension( + url = "http://hl7.org/fhir/StructureDefinition/minValue", + value = + Extension.Value.DateTime( + value = + DateTime( + value = + FhirDateTime.DateTime( + dateTime = LocalDateTime(2020, 1, 5, 1, 30), + utcOffset = UtcOffset.ZERO, + ), + ), + ), + ), + Extension( + url = "http://hl7.org/fhir/StructureDefinition/maxValue", + value = + Extension.Value.DateTime( + value = + DateTime( + value = + FhirDateTime.DateTime( + dateTime = LocalDateTime(2025, 1, 5, 1, 30), + utcOffset = UtcOffset.ZERO, + ), + ), + ), + ), + ), + ), + QuestionnaireResponse.Item( + linkId = FhirR4String(value = "datetime-item"), + answer = + listOf( + QuestionnaireResponse.Item.Answer( + value = + QuestionnaireResponse.Item.Answer.Value.DateTime( + value = + DateTime( + value = + FhirDateTime.DateTime( + dateTime = LocalDateTime(2023, 1, 5, 1, 30), + utcOffset = UtcOffset.ZERO, + ), + ), + ), + ), + ), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + + setContent { QuestionnaireDateTimeView(questionnaireViewItem) } + + onNodeWithTag(DATE_TEXT_INPUT_FIELD) + .assert(SemanticsMatcher.keyNotDefined(SemanticsProperties.Error)) + onNodeWithTag(TIME_PICKER_INPUT_FIELD) + .assert(SemanticsMatcher.keyNotDefined(SemanticsProperties.Error)) + } + + @Test + fun ifTheDraftAnswerIsInvalidDisplayTheErrorMessage() = runComposeUiTest { + Locale.setDefault(Locale.US) + + val itemViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "datetime-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.DateTime), + text = FhirR4String(value = "Question?"), + ), + QuestionnaireResponse.Item( + linkId = + FhirR4String( + value = "datetime-item", + ), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + draftAnswer = "11/19/202", + ) + + setContent { QuestionnaireDateTimeView(itemViewItem) } + + onNodeWithTag(DATE_TEXT_INPUT_FIELD) + .assert( + SemanticsMatcher.expectValue( + SemanticsProperties.Error, + "Date format needs to be mm/dd/yyyy (e.g. 01/31/2023)", + ), + ) + } + + @Test + fun showDateFormatInLowerCaseInTheErrorMessage() = runComposeUiTest { + Locale.setDefault(Locale.US) + val itemViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "datetime-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.DateTime), + ), + QuestionnaireResponse.Item( + linkId = + FhirR4String( + value = "datetime-item", + ), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + draftAnswer = "11/19/202", + ) + + setContent { QuestionnaireDateTimeView(itemViewItem) } + onNodeWithTag(DATE_TEXT_INPUT_FIELD) + .assert( + SemanticsMatcher.expectValue( + SemanticsProperties.Error, + "Date format needs to be mm/dd/yyyy (e.g. 01/31/2023)", + ), + ) + } + + @Test + fun hidesErrorTextviewInTheHeader() = runComposeUiTest { + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "datetime-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.DateTime), + ), + QuestionnaireResponse.Item( + linkId = + FhirR4String( + value = "datetime-item", + ), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + + setContent { QuestionnaireDateTimeView(questionnaireViewItem) } + + onNodeWithTag(ERROR_TEXT_AT_HEADER_TEST_TAG).assertDoesNotExist() + } + + @Test + fun bind_readOnly_shouldDisableView() = runComposeUiTest { + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "datetime-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.DateTime), + readOnly = FhirR4Boolean(value = true), + ), + QuestionnaireResponse.Item( + linkId = + FhirR4String( + value = "datetime-item", + ), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + + setContent { QuestionnaireDateTimeView(questionnaireViewItem) } + + onNodeWithTag(DATE_TEXT_INPUT_FIELD).assertIsNotEnabled() + onNodeWithTag(TIME_PICKER_INPUT_FIELD).assertIsNotEnabled() + } + + @Test + fun bindMultipleTimesWithSeparateQuestionnaireItemViewItemShouldShowProperDateAndTime() = + runComposeUiTest { + Locale.setDefault(Locale.US) + var questionnaireViewItem by + mutableStateOf( + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "datetime-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.DateTime), + text = FhirR4String(value = "Question?"), + ), + QuestionnaireResponse.Item( + linkId = FhirR4String(value = "datetime-item"), + answer = + listOf( + QuestionnaireResponse.Item.Answer( + value = + QuestionnaireResponse.Item.Answer.Value.DateTime( + value = + DateTime( + value = + FhirDateTime.DateTime( + dateTime = LocalDateTime(2020, 1, 5, 1, 30), + utcOffset = UtcOffset.ZERO, + ), + ), + ), + ), + ), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ), + ) + + setContent { QuestionnaireDateTimeView(questionnaireViewItem) } + + onNodeWithTag(DATE_TEXT_INPUT_FIELD, useUnmergedTree = true).assertTextEquals("01/05/2020") + onNodeWithTag(TIME_PICKER_INPUT_FIELD, useUnmergedTree = true) + .assert( + SemanticsMatcher.expectValue( + SemanticsProperties.EditableText, + AnnotatedString("1:30 AM"), + ), + ) + + questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "datetime-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.DateTime), + text = FhirR4String(value = "Question?"), + ), + QuestionnaireResponse.Item( + linkId = FhirR4String(value = "datetime-item"), + answer = + listOf( + QuestionnaireResponse.Item.Answer( + value = + QuestionnaireResponse.Item.Answer.Value.DateTime( + value = + DateTime( + value = + FhirDateTime.DateTime( + dateTime = LocalDateTime(2021, 1, 5, 2, 30), + utcOffset = UtcOffset.ZERO, + ), + ), + ), + ), + ), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + + onNodeWithTag(DATE_TEXT_INPUT_FIELD, useUnmergedTree = true).assertTextEquals("01/05/2021") + onNodeWithTag(TIME_PICKER_INPUT_FIELD, useUnmergedTree = true) + .assert( + SemanticsMatcher.expectValue( + SemanticsProperties.EditableText, + AnnotatedString("2:30 AM"), + ), + ) + + questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "datetime-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.DateTime), + text = FhirR4String(value = "Question?"), + ), + QuestionnaireResponse.Item( + linkId = + FhirR4String( + value = "datetime-item", + ), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + + onNodeWithTag(DATE_TEXT_INPUT_FIELD, useUnmergedTree = true).assertTextEquals("") + onNodeWithTag(TIME_PICKER_INPUT_FIELD, useUnmergedTree = true).assertTextEquals("") + } + + @Test + fun showsTimePickerInInputMode() = runComposeUiTest { + val questionnaireItemView = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "datetime-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.DateTime), + ), + QuestionnaireResponse.Item( + linkId = + FhirR4String( + value = "datetime-item", + ), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + + setContent { QuestionnaireDateTimeView(questionnaireItemView) } + onNodeWithTag(DATE_TEXT_INPUT_FIELD) + .onChildren() + .filterToOne( + SemanticsMatcher.expectValue(SemanticsProperties.Role, Role.Button), + ) + .performClick() + onNodeWithText("OK").performClick() + onNodeWithTag(TIME_PICKER_INPUT_FIELD).performClick() + + onNode( + hasContentDescription("Switch to clock input", substring = true) and + SemanticsMatcher.expectValue(SemanticsProperties.Role, Role.Button), + ) + .assertIsDisplayed() + onNode(hasContentDescription("for hour", substring = true) and isEditable()).assertIsDisplayed() + onNode(hasContentDescription("for minutes", substring = true) and isEditable()).assertExists() + } + + @Test + fun showsTimePickerInClockMode() = runComposeUiTest { + val questionnaireItemView = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "datetime-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.DateTime), + ), + QuestionnaireResponse.Item( + linkId = + FhirR4String( + value = "datetime-item", + ), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + + setContent { QuestionnaireDateTimeView(questionnaireItemView) } + onNodeWithTag(DATE_TEXT_INPUT_FIELD) + .onChildren() + .filterToOne( + SemanticsMatcher.expectValue(SemanticsProperties.Role, Role.Button), + ) + .performClick() + onNodeWithText("OK").performClick() + onNodeWithTag(TIME_PICKER_INPUT_FIELD) + .onChildren() + .filterToOne( + SemanticsMatcher.expectValue(SemanticsProperties.Role, Role.Button), + ) + .performClick() + + onNode( + hasContentDescription("Switch to text input", substring = true) and + SemanticsMatcher.expectValue(SemanticsProperties.Role, Role.Button), + ) + .assertIsDisplayed() + onNode( + hasAnyChild(SemanticsMatcher.keyIsDefined(SemanticsProperties.TraversalIndex)) and + SemanticsMatcher.keyIsDefined(SemanticsProperties.SelectableGroup), + ) + .assertIsDisplayed() + } + + @Test + fun showsAsterisk() = runComposeUiTest { + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "datetime-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.DateTime), + text = FhirR4String(value = "Question?"), + required = FhirR4Boolean(value = true), + ), + QuestionnaireResponse.Item( + linkId = + FhirR4String( + value = "datetime-item", + ), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + questionViewTextConfiguration = QuestionTextConfiguration(showAsterisk = true), + ) + + setContent { QuestionnaireDateTimeView(questionnaireViewItem) } + + onNodeWithTag(QUESTION_HEADER_TAG).assertTextEquals("Question? *") + } + + @Test + fun hideAsterisk() = runComposeUiTest { + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "datetime-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.DateTime), + text = FhirR4String(value = "Question?"), + required = FhirR4Boolean(value = true), + ), + QuestionnaireResponse.Item( + linkId = + FhirR4String( + value = "datetime-item", + ), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + questionViewTextConfiguration = QuestionTextConfiguration(showAsterisk = false), + ) + + setContent { QuestionnaireDateTimeView(questionnaireViewItem) } + + onNodeWithTag(QUESTION_HEADER_TAG).assertTextEquals("Question?") + } + + @Test + fun showsRequiredText() = runComposeUiTest { + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "datetime-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.DateTime), + required = FhirR4Boolean(value = true), + ), + QuestionnaireResponse.Item( + linkId = + FhirR4String( + value = "datetime-item", + ), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + questionViewTextConfiguration = QuestionTextConfiguration(showRequiredText = true), + ) + + setContent { QuestionnaireDateTimeView(questionnaireViewItem) } + onNodeWithTag(DATE_TEXT_INPUT_FIELD).assertTextContains("Required", substring = true) + } + + @Test + fun hideRequiredText() = runComposeUiTest { + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "datetime-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.DateTime), + required = FhirR4Boolean(value = true), + ), + QuestionnaireResponse.Item( + linkId = + FhirR4String( + value = "datetime-item", + ), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + questionViewTextConfiguration = QuestionTextConfiguration(showRequiredText = false), + ) + + setContent { QuestionnaireDateTimeView(questionnaireViewItem) } + + onNodeWithText("Required").assertDoesNotExist() + } + + @Test + fun showsOptionalText() = runComposeUiTest { + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "datetime-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.DateTime), + ), + QuestionnaireResponse.Item( + linkId = + FhirR4String( + value = "datetime-item", + ), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + questionViewTextConfiguration = QuestionTextConfiguration(showOptionalText = true), + ) + + setContent { QuestionnaireDateTimeView(questionnaireViewItem) } + + onNodeWithTag(DATE_TEXT_INPUT_FIELD).assertTextContains("Optional") + } + + @Test + fun hideOptionalText() = runComposeUiTest { + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "datetime-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.DateTime), + ), + QuestionnaireResponse.Item( + linkId = + FhirR4String( + value = "datetime-item", + ), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + questionViewTextConfiguration = QuestionTextConfiguration(showOptionalText = false), + ) + + setContent { QuestionnaireDateTimeView(questionnaireViewItem) } + onNodeWithText("Optional").assertDoesNotExist() + } +} diff --git a/datacapture/src/androidDeviceTest/kotlin/dev/ohs/fhir/datacapture/views/factories/TimeViewFactoryTest.kt b/datacapture/src/androidDeviceTest/kotlin/dev/ohs/fhir/datacapture/views/factories/TimeViewFactoryTest.kt new file mode 100644 index 000000000..3d02f55f3 --- /dev/null +++ b/datacapture/src/androidDeviceTest/kotlin/dev/ohs/fhir/datacapture/views/factories/TimeViewFactoryTest.kt @@ -0,0 +1,223 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.ohs.fhir.datacapture.views.factories + +import android.content.Context +import androidx.compose.runtime.Composable +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assert +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.hasTextExactly +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.runComposeUiTest +import androidx.test.platform.app.InstrumentationRegistry +import com.google.fhir.model.r4.Enumeration +import com.google.fhir.model.r4.Questionnaire +import com.google.fhir.model.r4.QuestionnaireResponse +import com.google.fhir.model.r4.Time +import dev.ohs.fhir.datacapture.extensions.FhirR4String +import dev.ohs.fhir.datacapture.theme.QuestionnaireTheme +import dev.ohs.fhir.datacapture.validation.NotValidated +import dev.ohs.fhir.datacapture.views.QuestionnaireViewItem +import dev.ohs.fhir.datacapture.views.components.QUESTION_HEADER_TAG +import dev.ohs.fhir.datacapture.views.components.TIME_PICKER_INPUT_FIELD +import java.util.Locale +import kotlin.test.Test +import kotlinx.datetime.LocalTime + +@OptIn(ExperimentalTestApi::class) +class TimeViewFactoryTest { + + @Composable + fun QuestionnaireTimeView(questionnaireViewItem: QuestionnaireViewItem) { + QuestionnaireTheme { TimeViewFactory.Content(questionnaireViewItem) } + } + + private val context = InstrumentationRegistry.getInstrumentation().targetContext + + @Test + fun shouldSetQuestionHeader() = runComposeUiTest { + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "time-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Time), + text = FhirR4String(value = "Question?"), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "time-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + + setContent { QuestionnaireTimeView(questionnaireViewItem) } + + onNodeWithTag(QUESTION_HEADER_TAG).assertTextEquals("Question?") + } + + @Test + fun shouldSetEmptyTimeInput() = runComposeUiTest { + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "time-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Time), + text = FhirR4String(value = "Question?"), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "time-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + + setContent { QuestionnaireTimeView(questionnaireViewItem) } + + onNodeWithTag(TIME_PICKER_INPUT_FIELD, useUnmergedTree = true).assertTextEquals("") + } + + @Test + fun shouldDisplayAMTimeInCorrectFormat_Locale_Japan() = runComposeUiTest { + context.setLocale(Locale.JAPAN) + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "time-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Time), + text = FhirR4String(value = "Question?"), + ), + QuestionnaireResponse.Item( + linkId = FhirR4String(value = "time-item"), + answer = + listOf( + QuestionnaireResponse.Item.Answer( + value = + QuestionnaireResponse.Item.Answer.Value.Time( + value = Time(value = LocalTime.parse("10:10:00")), + ), + ), + ), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + + setContent { QuestionnaireTimeView(questionnaireViewItem) } + + onNodeWithTag(TIME_PICKER_INPUT_FIELD, useUnmergedTree = true).assert(hasTextExactly("10:10")) + } + + @Test + fun shouldDisplayAMTimeInCorrectFormat_Locale_US() = runComposeUiTest { + context.setLocale(Locale.US) + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "time-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Time), + text = FhirR4String(value = "Question?"), + ), + QuestionnaireResponse.Item( + linkId = FhirR4String(value = "time-item"), + answer = + listOf( + QuestionnaireResponse.Item.Answer( + value = + QuestionnaireResponse.Item.Answer.Value.Time( + value = Time(value = LocalTime.parse("10:10:00")), + ), + ), + ), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + + setContent { QuestionnaireTimeView(questionnaireViewItem) } + + onNodeWithTag(TIME_PICKER_INPUT_FIELD, useUnmergedTree = true) + .assert( + hasTextExactly("10:10 AM"), + ) + } + + @Test + fun shouldDisplayPMTimeInCorrectFormat_Locale_US() = runComposeUiTest { + context.setLocale(Locale.US) + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "time-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Time), + text = FhirR4String(value = "Question?"), + ), + QuestionnaireResponse.Item( + linkId = FhirR4String(value = "time-item"), + answer = + listOf( + QuestionnaireResponse.Item.Answer( + value = + QuestionnaireResponse.Item.Answer.Value.Time( + value = Time(value = LocalTime.parse("22:10:00")), + ), + ), + ), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + + setContent { QuestionnaireTimeView(questionnaireViewItem) } + + onNodeWithTag(TIME_PICKER_INPUT_FIELD, useUnmergedTree = true) + .assert( + hasTextExactly("10:10 PM"), + ) + } + + @Test + fun shouldDisplayPMTimeInCorrectFormat_Locale_Japan() = runComposeUiTest { + context.setLocale(Locale.JAPAN) + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "time-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Time), + text = FhirR4String(value = "Question?"), + ), + QuestionnaireResponse.Item( + linkId = FhirR4String(value = "time-item"), + answer = + listOf( + QuestionnaireResponse.Item.Answer( + value = + QuestionnaireResponse.Item.Answer.Value.Time( + value = Time(value = LocalTime.parse("22:10:00")), + ), + ), + ), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + + setContent { QuestionnaireTimeView(questionnaireViewItem) } + + onNodeWithTag(TIME_PICKER_INPUT_FIELD, useUnmergedTree = true).assert(hasTextExactly("22:10")) + } + + private fun Context.setLocale(locale: Locale) { + resources.configuration.setLocale(locale) + } +} diff --git a/datacapture/src/androidHostTest/kotlin/dev/ohs/fhir/datacapture/ExampleUnitTest.kt b/datacapture/src/androidHostTest/kotlin/dev/ohs/fhir/datacapture/ExampleUnitTest.kt new file mode 100644 index 000000000..60f6f12fb --- /dev/null +++ b/datacapture/src/androidHostTest/kotlin/dev/ohs/fhir/datacapture/ExampleUnitTest.kt @@ -0,0 +1,32 @@ +/* + * Copyright 2025-2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.ohs.fhir.datacapture + +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * Example local unit test, which will execute on the development machine (host). + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +class ExampleUnitTest { + @Test + fun addition_isCorrect() { + assertEquals(4, 2 + 2) + } +} diff --git a/datacapture/src/androidMain/AndroidManifest.xml b/datacapture/src/androidMain/AndroidManifest.xml new file mode 100644 index 000000000..aec7d0502 --- /dev/null +++ b/datacapture/src/androidMain/AndroidManifest.xml @@ -0,0 +1,25 @@ + + + + + + + + + + + diff --git a/datacapture/src/androidMain/kotlin/dev/ohs/fhir/datacapture/AttachmentMediaHandler.android.kt b/datacapture/src/androidMain/kotlin/dev/ohs/fhir/datacapture/AttachmentMediaHandler.android.kt new file mode 100644 index 000000000..a256c3cfa --- /dev/null +++ b/datacapture/src/androidMain/kotlin/dev/ohs/fhir/datacapture/AttachmentMediaHandler.android.kt @@ -0,0 +1,148 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.ohs.fhir.datacapture + +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import androidx.activity.compose.LocalActivityResultRegistryOwner +import androidx.activity.result.ActivityResultRegistry +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext +import androidx.core.content.ContextCompat +import androidx.lifecycle.compose.LocalLifecycleOwner +import com.ionspin.kotlin.bignum.decimal.BigDecimal +import io.github.vinceglb.filekit.FileKit +import io.github.vinceglb.filekit.dialogs.FileKitType +import io.github.vinceglb.filekit.dialogs.init +import io.github.vinceglb.filekit.dialogs.openCameraPicker +import io.github.vinceglb.filekit.dialogs.openFilePicker +import io.github.vinceglb.filekit.mimeType +import io.github.vinceglb.filekit.name +import io.github.vinceglb.filekit.readBytes +import kotlin.coroutines.cancellation.CancellationException +import kotlinx.coroutines.suspendCancellableCoroutine + +internal class AndroidMediaHandler( + private val activityResultRegistry: ActivityResultRegistry, + private val context: Context, + override val maxSupportedFileSizeBytes: BigDecimal, + override val supportedMimeTypes: Array, +) : MediaHandler { + + init { + FileKit.init(activityResultRegistry) + } + + override suspend fun capturePhoto(): MediaCaptureResult { + val isCameraPermissionGranted = requestCameraPermission() + if (!isCameraPermissionGranted) { + return MediaCaptureResult.Error("Error: Camera permission not granted") + } + val pickedFile = FileKit.openCameraPicker() + return pickedFile?.let { + captureResult( + it.readBytes(), + titleName = it.name, + mimeType = it.mimeType()?.toString() ?: "application/octet-stream", + ) + } + ?: throw CancellationException() + } + + override suspend fun selectFile( + inputMimeTypes: Array, + ): MediaCaptureResult { + val imageOnly = inputMimeTypes.all { it.startsWith("image/") } + + val fileKitType = + if (imageOnly) { + FileKitType.Image + } else { + FileKitType.File( + inputMimeTypes.toSet().takeIf { it.isNotEmpty() }, + ) + } + val pickedFile = FileKit.openFilePicker(type = fileKitType) + + return pickedFile?.let { + captureResult( + it.readBytes(), + mimeType = it.mimeType()?.toString() ?: "application/octet-stream", + titleName = it.name, + ) + } + ?: throw CancellationException() + } + + override fun isCameraSupported(): Boolean = + context.packageManager.hasSystemFeature(PackageManager.FEATURE_CAMERA_ANY) + + private suspend fun requestCameraPermission(): Boolean = + suspendCancellableCoroutine { continuation -> + val requestPermissionLauncher = + activityResultRegistry.register( + CAMERA_REQUEST_PERMISSION_RESULT_CONTRACT_KEY, + ActivityResultContracts.RequestPermission(), + ) { + continuation.resumeWith(Result.success(it)) + } + + continuation.invokeOnCancellation { requestPermissionLauncher.unregister() } + + if ( + ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == + PackageManager.PERMISSION_GRANTED + ) { + continuation.resumeWith(Result.success(true)) + } else { + requestPermissionLauncher.launch(Manifest.permission.CAMERA) + } + } + + companion object { + private const val CAMERA_REQUEST_PERMISSION_RESULT_CONTRACT_KEY = + "dev.ohs.fhir.datacapture.AndroidMediaHandler.CameraPermission" + } +} + +@Composable +internal actual fun rememberMediaHandler( + maxSupportedFileSizeBytes: BigDecimal, + supportedMimeTypes: Array, +): MediaHandler { + val context = LocalContext.current + val activityResultRegistry = LocalActivityResultRegistryOwner.current!!.activityResultRegistry + val lifecycleOwner = LocalLifecycleOwner.current + + return remember( + context, + activityResultRegistry, + lifecycleOwner, + maxSupportedFileSizeBytes, + supportedMimeTypes, + ) { + AndroidMediaHandler( + activityResultRegistry, + context, + maxSupportedFileSizeBytes, + supportedMimeTypes, + ) + } +} diff --git a/datacapture/src/androidMain/kotlin/dev/ohs/fhir/datacapture/DataCapture.android.kt b/datacapture/src/androidMain/kotlin/dev/ohs/fhir/datacapture/DataCapture.android.kt new file mode 100644 index 000000000..ddc5d01ee --- /dev/null +++ b/datacapture/src/androidMain/kotlin/dev/ohs/fhir/datacapture/DataCapture.android.kt @@ -0,0 +1,46 @@ +/* + * Copyright 2025-2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.ohs.fhir.datacapture + +import android.content.Context + +actual object DataCapture { + + private lateinit var configuration: DataCaptureConfig + + fun initialize(context: Context) { + if (!::configuration.isInitialized) { + configuration = + if (context.applicationContext is DataCaptureConfig.Provider) { + (context.applicationContext as DataCaptureConfig.Provider).getDataCaptureConfig() + } else { + DataCaptureConfig() + } + } + return + } + + actual fun getConfiguration(): DataCaptureConfig { + if (this::configuration.isInitialized) { + return configuration + } else { + throw Exception( + "DataCapture not initialized. Initialize the library with DataCapture.initialize(context) ", + ) + } + } +} diff --git a/datacapture/src/androidMain/kotlin/dev/ohs/fhir/datacapture/LocalDateTimeFormatter.android.kt b/datacapture/src/androidMain/kotlin/dev/ohs/fhir/datacapture/LocalDateTimeFormatter.android.kt new file mode 100644 index 000000000..2a80936e6 --- /dev/null +++ b/datacapture/src/androidMain/kotlin/dev/ohs/fhir/datacapture/LocalDateTimeFormatter.android.kt @@ -0,0 +1,106 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.ohs.fhir.datacapture + +import android.content.Context +import android.icu.text.DateFormat +import android.text.format.DateFormat as AndroidDateFormat +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.intl.Locale +import dev.ohs.fhir.datacapture.extensions.length +import java.text.ParseException +import java.time.LocalDateTime +import java.time.ZoneId +import java.time.chrono.IsoChronology +import java.time.format.DateTimeFormatter +import java.time.format.DateTimeFormatterBuilder +import java.time.format.FormatStyle +import java.util.Date +import kotlinx.datetime.LocalDate +import kotlinx.datetime.LocalTime +import kotlinx.datetime.toJavaLocalDate +import kotlinx.datetime.toJavaLocalTime +import kotlinx.datetime.toKotlinLocalDate + +class AndroidLocalDateFormatter(private val context: Context) : LocalDateTimeFormatter { + + override fun parseStringToLocalDate( + str: String, + pattern: String, + ): LocalDate { + val localDate = + java.time.LocalDate.parse( + str, + DateTimeFormatter.ofPattern(pattern, Locale.current.platformLocale), + ) + + // Throw ParseException if year is less than 4 digits. + if (localDate.year.length() < 4) { + throw ParseException("Year has less than 4 digits.", str.indexOf('y')) + } + // date/localDate with year more than 4 digits + if (localDate.year.length() > 4) { + throw ParseException("Year has more than 4 digits.", str.indexOf('y')) + } + + return localDate.toKotlinLocalDate() + } + + override fun format(localDate: LocalDate, pattern: String?): String = + if (pattern.isNullOrEmpty()) { + val date = + Date.from(localDate.toJavaLocalDate().atStartOfDay(ZoneId.systemDefault()).toInstant()) + DateFormat.getDateInstance(DateFormat.SHORT, Locale.current.platformLocale).format(date) + } else { + DateTimeFormatter.ofPattern(pattern, Locale.current.platformLocale) + .format(localDate.toJavaLocalDate()) + } + + override val localDateShortFormatPattern: String + get() = + DateTimeFormatterBuilder.getLocalizedDateTimePattern( + FormatStyle.SHORT, + null, + IsoChronology.INSTANCE, + Locale.current.platformLocale, + ) + + // ICU on Android does not observe the user's 24h/12h time format setting (obtained from + // DateFormat.is24HourFormat()). In order to observe the setting, we are using DateFormat as + // suggested in the docs. See + // https://developer.android.com/guide/topics/resources/internationalization#24h-setting for + // details. + override fun localizedTimeString(time: LocalTime): String { + val date = + Date.from( + LocalDateTime.of(java.time.LocalDate.now(), time.toJavaLocalTime()) + .atZone(ZoneId.systemDefault()) + .toInstant(), + ) + + // Using applicationContext to allow for changing of locale in tests + return AndroidDateFormat.getTimeFormat(context.applicationContext).format(date) + } +} + +@Composable +actual fun getLocalDateTimeFormatter(): LocalDateTimeFormatter { + val context = LocalContext.current + return remember(context) { AndroidLocalDateFormatter(context) } +} diff --git a/datacapture/src/androidMain/kotlin/dev/ohs/fhir/datacapture/NumberFormatter.android.kt b/datacapture/src/androidMain/kotlin/dev/ohs/fhir/datacapture/NumberFormatter.android.kt new file mode 100644 index 000000000..9109efb7a --- /dev/null +++ b/datacapture/src/androidMain/kotlin/dev/ohs/fhir/datacapture/NumberFormatter.android.kt @@ -0,0 +1,32 @@ +/* + * Copyright 2025-2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.ohs.fhir.datacapture + +import android.icu.number.NumberFormatter +import android.icu.text.DecimalFormat +import android.os.Build +import java.util.Locale + +actual object NumberFormatter { + actual fun formatInteger(value: Int): String { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + NumberFormatter.withLocale(Locale.getDefault()).format(value).toString() + } else { + DecimalFormat.getInstance(Locale.getDefault()).format(value) + } + } +} diff --git a/datacapture/src/androidMain/res/xml/file_paths.xml b/datacapture/src/androidMain/res/xml/file_paths.xml new file mode 100644 index 000000000..70d171f6f --- /dev/null +++ b/datacapture/src/androidMain/res/xml/file_paths.xml @@ -0,0 +1,4 @@ + + + + diff --git a/datacapture/src/commonMain/composeResources/drawable/add_24px.xml b/datacapture/src/commonMain/composeResources/drawable/add_24px.xml new file mode 100644 index 000000000..b7ce041c2 --- /dev/null +++ b/datacapture/src/commonMain/composeResources/drawable/add_24px.xml @@ -0,0 +1,13 @@ + + + diff --git a/datacapture/src/commonMain/composeResources/drawable/delete_24px.xml b/datacapture/src/commonMain/composeResources/drawable/delete_24px.xml new file mode 100644 index 000000000..e99d9f311 --- /dev/null +++ b/datacapture/src/commonMain/composeResources/drawable/delete_24px.xml @@ -0,0 +1,13 @@ + + + diff --git a/datacapture/src/commonMain/composeResources/drawable/gm_calendar_today_24.xml b/datacapture/src/commonMain/composeResources/drawable/gm_calendar_today_24.xml new file mode 100644 index 000000000..8ddd5ec80 --- /dev/null +++ b/datacapture/src/commonMain/composeResources/drawable/gm_calendar_today_24.xml @@ -0,0 +1,28 @@ + + + + diff --git a/datacapture/src/commonMain/composeResources/drawable/gm_schedule_24.xml b/datacapture/src/commonMain/composeResources/drawable/gm_schedule_24.xml new file mode 100644 index 000000000..9a4aed5e5 --- /dev/null +++ b/datacapture/src/commonMain/composeResources/drawable/gm_schedule_24.xml @@ -0,0 +1,28 @@ + + + + diff --git a/datacapture/src/commonMain/composeResources/drawable/ic_access_time.xml b/datacapture/src/commonMain/composeResources/drawable/ic_access_time.xml new file mode 100644 index 000000000..2990b1942 --- /dev/null +++ b/datacapture/src/commonMain/composeResources/drawable/ic_access_time.xml @@ -0,0 +1,16 @@ + + + + diff --git a/datacapture/src/commonMain/composeResources/drawable/ic_audio_file.xml b/datacapture/src/commonMain/composeResources/drawable/ic_audio_file.xml new file mode 100644 index 000000000..9282621b6 --- /dev/null +++ b/datacapture/src/commonMain/composeResources/drawable/ic_audio_file.xml @@ -0,0 +1,13 @@ + + + diff --git a/datacapture/src/commonMain/composeResources/drawable/ic_camera.xml b/datacapture/src/commonMain/composeResources/drawable/ic_camera.xml new file mode 100644 index 000000000..9b2587ea3 --- /dev/null +++ b/datacapture/src/commonMain/composeResources/drawable/ic_camera.xml @@ -0,0 +1,13 @@ + + + diff --git a/datacapture/src/commonMain/composeResources/drawable/ic_clear.xml b/datacapture/src/commonMain/composeResources/drawable/ic_clear.xml new file mode 100644 index 000000000..0a28992f0 --- /dev/null +++ b/datacapture/src/commonMain/composeResources/drawable/ic_clear.xml @@ -0,0 +1,13 @@ + + + diff --git a/datacapture/src/commonMain/composeResources/drawable/ic_delete.xml b/datacapture/src/commonMain/composeResources/drawable/ic_delete.xml new file mode 100644 index 000000000..3fa054fbe --- /dev/null +++ b/datacapture/src/commonMain/composeResources/drawable/ic_delete.xml @@ -0,0 +1,13 @@ + + + diff --git a/datacapture/src/commonMain/composeResources/drawable/ic_document_file.xml b/datacapture/src/commonMain/composeResources/drawable/ic_document_file.xml new file mode 100644 index 000000000..76598e11c --- /dev/null +++ b/datacapture/src/commonMain/composeResources/drawable/ic_document_file.xml @@ -0,0 +1,13 @@ + + + diff --git a/datacapture/src/commonMain/composeResources/drawable/ic_file.xml b/datacapture/src/commonMain/composeResources/drawable/ic_file.xml new file mode 100644 index 000000000..dea558c04 --- /dev/null +++ b/datacapture/src/commonMain/composeResources/drawable/ic_file.xml @@ -0,0 +1,13 @@ + + + diff --git a/datacapture/src/commonMain/composeResources/drawable/ic_image_file.xml b/datacapture/src/commonMain/composeResources/drawable/ic_image_file.xml new file mode 100644 index 000000000..575bfd31a --- /dev/null +++ b/datacapture/src/commonMain/composeResources/drawable/ic_image_file.xml @@ -0,0 +1,13 @@ + + + diff --git a/datacapture/src/commonMain/composeResources/drawable/ic_keyboard.xml b/datacapture/src/commonMain/composeResources/drawable/ic_keyboard.xml new file mode 100644 index 000000000..2b4c017e9 --- /dev/null +++ b/datacapture/src/commonMain/composeResources/drawable/ic_keyboard.xml @@ -0,0 +1,12 @@ + + + diff --git a/datacapture/src/commonMain/composeResources/drawable/ic_video_file.xml b/datacapture/src/commonMain/composeResources/drawable/ic_video_file.xml new file mode 100644 index 000000000..08565fea5 --- /dev/null +++ b/datacapture/src/commonMain/composeResources/drawable/ic_video_file.xml @@ -0,0 +1,13 @@ + + + diff --git a/datacapture/src/commonMain/composeResources/values/strings.xml b/datacapture/src/commonMain/composeResources/values/strings.xml new file mode 100644 index 000000000..5e982f2e6 --- /dev/null +++ b/datacapture/src/commonMain/composeResources/values/strings.xml @@ -0,0 +1,160 @@ + + + + + + Next + Previous + Back + Edit + Review answers + Review + Submit + Cancel + Review answers before submitting + + + Errors found + Fix the following questions: + • %s + Submit anyway + Fix questions + + + Do you want to exit the questionnaire? + + + + + "Not Answered" + Help + Ok + + + No + Yes + + + - + + + Other + Enter custom option + Add another answer + Select all that apply + Save + Cancel + + + Date + Time + Select date + Select time + Switch to text input + Switch to clock input + + + Take Photo + Photo Preview + File Icon Preview + Upload Photo + Upload Audio + Upload Video + Upload Document + Upload File + Delete + Error: Image size is larger than %1$s MB + Error: File size is larger than %1$s MB + Upload failed + Error: Wrong media format + Error: Media not saved + Uploaded + Image uploaded + File uploaded + Video uploaded + Audio uploaded + Image deleted + File deleted + Video deleted + Audio deleted + Loading\u2026 + + + Add item + + + + + Missing answer for required field. + Minimum value allowed is:%1$s + Maximum value allowed is:%1$s + + minValue %1$s must be smaller than maxValue %2$s + + The minimum number of characters that are permitted in the answer is: %1$s + The maximum number of decimal places that are permitted in the answer is: %1$s + The answer doesn\'t match regular expression: %1$s + Only use (.) between two numbers. Other special characters are not supported. + Date format needs to be %1$s (e.g. %2$s) + Number must be between %1$s and %2$s + Invalid number + + Optional + Required + Required\n + \u0020\u002a + %1$s. %2$s + Add %1$s + diff --git a/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/AttachmentMediaHandler.kt b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/AttachmentMediaHandler.kt new file mode 100644 index 000000000..1588adb11 --- /dev/null +++ b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/AttachmentMediaHandler.kt @@ -0,0 +1,106 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.ohs.fhir.datacapture + +import androidx.compose.runtime.Composable +import com.google.fhir.model.r4.Attachment +import com.google.fhir.model.r4.Base64Binary +import com.google.fhir.model.r4.Code +import com.google.fhir.model.r4.DateTime +import com.google.fhir.model.r4.FhirDateTime +import com.ionspin.kotlin.bignum.decimal.BigDecimal +import com.ionspin.kotlin.bignum.decimal.toBigDecimal +import dev.ohs.fhir.datacapture.extensions.FhirR4String +import dev.ohs.fhir.datacapture.extensions.inMBs +import kotlin.io.encoding.Base64 +import kotlin.time.Clock +import kotlin.time.ExperimentalTime +import kotlinx.datetime.TimeZone +import kotlinx.datetime.offsetAt +import kotlinx.datetime.toLocalDateTime + +internal sealed interface MediaCaptureResult { + data class Success(val attachment: Attachment) : MediaCaptureResult + + data class Error(val error: String) : MediaCaptureResult +} + +internal interface MediaHandler { + val maxSupportedFileSizeBytes: BigDecimal + val supportedMimeTypes: Array + + /** + * Captures a photo using the device camera. + * + * @return MediaCaptureResult containing the Attachment or error message + */ + suspend fun capturePhoto(): MediaCaptureResult + + /** + * Opens file picker to select a file. + * + * @param inputMimeTypes Array of acceptable MIME types + * @param supportedMimeTypes Function to check if a MIME type is supported + * @return MediaCaptureResult containing the Attachment or error message + */ + suspend fun selectFile( + inputMimeTypes: Array, + ): MediaCaptureResult + + fun isCameraSupported(): Boolean +} + +internal fun MediaHandler.isMimeTypeSupported(mimeType: String) = + supportedMimeTypes.any { it.substringBefore("/") == mimeType } + +@OptIn(ExperimentalTime::class) +internal fun MediaHandler.captureResult( + byteArray: ByteArray, + mimeType: String, + titleName: String, +): MediaCaptureResult { + if (byteArray.size.toBigDecimal() > maxSupportedFileSizeBytes) { + return MediaCaptureResult.Error( + "Error: File size is larger than the allowed ${maxSupportedFileSizeBytes.inMBs} MB", + ) + } + + val byteArrayString = Base64.encode(byteArray) + val currentTimeZone = TimeZone.currentSystemDefault() + + val attachment = + Attachment( + contentType = Code(value = mimeType), + data = Base64Binary(value = byteArrayString), + title = FhirR4String(value = titleName), + creation = + DateTime( + value = + FhirDateTime.DateTime( + dateTime = Clock.System.now().toLocalDateTime(currentTimeZone), + utcOffset = currentTimeZone.offsetAt(Clock.System.now()), + ), + ), + ) + return MediaCaptureResult.Success(attachment) +} + +@Composable +internal expect fun rememberMediaHandler( + maxSupportedFileSizeBytes: BigDecimal, + supportedMimeTypes: Array, +): MediaHandler diff --git a/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/DataCapture.kt b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/DataCapture.kt new file mode 100644 index 000000000..6c8b8849e --- /dev/null +++ b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/DataCapture.kt @@ -0,0 +1,27 @@ +/* + * Copyright 2021-2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.ohs.fhir.datacapture + +/** Stores config and global state of the Structured Data Capture Library. */ +expect object DataCapture { + + /** + * If client has set a configuration by implementing [DataCaptureConfig.Provider], then it returns + * that. Otherwise, it returns a default [DataCaptureConfig]. + */ + fun getConfiguration(): DataCaptureConfig +} diff --git a/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/DataCaptureConfig.kt b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/DataCaptureConfig.kt new file mode 100644 index 000000000..7c57553c9 --- /dev/null +++ b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/DataCaptureConfig.kt @@ -0,0 +1,111 @@ +/* + * Copyright 2022-2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.ohs.fhir.datacapture + +import com.google.fhir.model.r4.Coding +import com.google.fhir.model.r4.Resource + +/** + * The App developers may provide the [DataCaptureConfig] for the DataCapture library by + * implementing [Provider] interface in the android.app.Application class. The library would load + * and cache the configuration by calling [Provider.getDataCaptureConfig]. + * + * NOTE: App developers should make sure that [Provider.getDataCaptureConfig] provides a constant + * [DataCaptureConfig] throughout the lifecycle of the application. + */ +data class DataCaptureConfig( + /** + * An [ExternalAnswerValueSetResolver] may be set to provide answer options dynamically for + * `choice` and `open-choice` type questions. + */ + var valueSetResolverExternal: ExternalAnswerValueSetResolver? = null, + + /** + * A [XFhirQueryResolver] may be set by the client to resolve x-fhir-query for the library. See + * https://build.fhir.org/ig/HL7/sdc/expressions.html#fhirquery for more details. + */ + var xFhirQueryResolver: XFhirQueryResolver? = null, + + /** Resolves a URL to the media binary content. */ + var urlResolver: UrlResolver? = null, + + /** + * A [QuestionnaireItemViewHolderFactoryMatchersProviderFactory] may be set by the client to + * provide [QuestionnaireItemViewHolderFactoryMatcher]s to add custom questionnaire components or + * override the behaviour of existing components in the sdc. + */ + var questionnaireItemViewHolderFactoryMatchersProviderFactory: + QuestionnaireItemViewHolderFactoryMatchersProviderFactory? = + null, +) { + + /** + * A class that can provide the [DataCaptureConfig] for the Structured Data Capture Library. To do + * this, implement the [DataCaptureConfig.Provider] interface on your android.app.Application + * class. You should provide the same configuration throughout the lifecycle of your application. + * The library may cache the configuration and different configurations will be ignored. + */ + interface Provider { + fun getDataCaptureConfig(): DataCaptureConfig + } +} + +/** + * Resolves external answer value sets not defined in the questionnaire's `contained` element. This + * allows the library to render answer options to `choice` and `open-choice` type questions more + * dynamically. + * + * NOTE: The result of the resolution may be cached to improve performance. In other words, the + * resolver may be called only once after which the same answer value set may be used multiple times + * in the UI to populate answer options. + */ +interface ExternalAnswerValueSetResolver { + suspend fun resolve(uri: String): List +} + +/** + * Resolves resources based on the provided xFhir query. This allows the library to resolve + * x-fhir-query answer expressions. + * + * NOTE: The result of the resolution may be cached to improve performance. In other words, the + * resolver may be called only once after which the Resources may be used multiple times in the UI. + */ +fun interface XFhirQueryResolver { + suspend fun resolve(xFhirQuery: String): List +} + +/** + * Resolves media content based on the provided URL, allowing the library to render media content in + * its UI. + */ +interface UrlResolver { + suspend fun resolveBitmapUrl(url: String): ByteArray? +} + +/** + * Factory to create [QuestionnaireItemViewHolderFactoryMatchersProvider]to provide [List] of + * [QuestionnaireItemViewHolderFactoryMatcher]. The developers may provide the factory to the + * library via [DataCaptureConfig] to add custom questionnaire components or override the behaviour + * of existing components in the sdc. + * + * See the + * [developer guide](https://github.com/google/android-fhir/wiki/SDCL:-Customize-how-a-Questionnaire-is-displayed#custom-questionnaire-components) + * for more information. + */ +fun interface QuestionnaireItemViewHolderFactoryMatchersProviderFactory { + fun get(provider: String): QuestionnaireItemViewHolderFactoryMatchersProvider +} diff --git a/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/LocalDateTimeFormatter.kt b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/LocalDateTimeFormatter.kt new file mode 100644 index 000000000..c3ee83dcf --- /dev/null +++ b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/LocalDateTimeFormatter.kt @@ -0,0 +1,84 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.ohs.fhir.datacapture + +import androidx.compose.runtime.Composable +import co.touchlab.kermit.Logger +import kotlin.time.Clock +import kotlin.time.ExperimentalTime +import kotlinx.datetime.LocalDate +import kotlinx.datetime.LocalTime +import kotlinx.datetime.TimeZone +import kotlinx.datetime.format +import kotlinx.datetime.format.FormatStringsInDatetimeFormats +import kotlinx.datetime.format.byUnicodePattern +import kotlinx.datetime.toLocalDateTime + +interface LocalDateTimeFormatter { + /** + * Parses a date string using the given date pattern, or the default date pattern for the device + * locale. If the parsing fails, an exception is thrown. + */ + fun parseStringToLocalDate(str: String, pattern: String): LocalDate + + /** + * Returns the local date string using the provided date pattern, or the default date pattern for + * the system locale if no date pattern is provided. + */ + fun format(localDate: LocalDate, pattern: String? = null): String + + /** + * Medium and long format styles use alphabetical month names which are difficult for the user to + * input. Use short format style which is always numerical. + */ + val localDateShortFormatPattern: String + + fun localizedTimeString(time: LocalTime): String +} + +internal fun LocalDateTimeFormatter.parseLocalDateOrNull( + dateToDisplay: String, + pattern: String, +): LocalDate? { + return try { + parseStringToLocalDate(dateToDisplay, pattern) + } catch (_: Exception) { + null + } +} + +@OptIn(ExperimentalTime::class, FormatStringsInDatetimeFormats::class) +internal fun LocalDateTimeFormatter.isValidDateEntryFormat(entryFormat: String?): Boolean { + return entryFormat?.let { + try { + val dateFormat = LocalDate.Format { byUnicodePattern(entryFormat) } + val text = + Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date.format(dateFormat) + parseStringToLocalDate( + text, + entryFormat, + ) + true + } catch (e: Exception) { + Logger.w(messageString = e.message ?: "Error parsing date", throwable = e) + false + } + } + ?: false +} + +@Composable expect fun getLocalDateTimeFormatter(): LocalDateTimeFormatter diff --git a/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/NumberFormatter.kt b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/NumberFormatter.kt new file mode 100644 index 000000000..ddbe53d2f --- /dev/null +++ b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/NumberFormatter.kt @@ -0,0 +1,21 @@ +/* + * Copyright 2025-2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.ohs.fhir.datacapture + +expect object NumberFormatter { + fun formatInteger(value: Int): String +} diff --git a/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/QuestionnaireAdapterItem.kt b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/QuestionnaireAdapterItem.kt new file mode 100644 index 000000000..a52a4b3ec --- /dev/null +++ b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/QuestionnaireAdapterItem.kt @@ -0,0 +1,51 @@ +/* + * Copyright 2022-2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.ohs.fhir.datacapture + +import com.google.fhir.model.r4.QuestionnaireResponse +import dev.ohs.fhir.datacapture.views.QuestionnaireViewItem + +/** Various types of rows that can be used in a Questionnaire RecyclerView. */ +internal sealed interface QuestionnaireAdapterItem { + /** A row for a question in a Questionnaire RecyclerView. */ + data class Question(val item: QuestionnaireViewItem) : + QuestionnaireAdapterItem, QuestionnaireReviewItem { + var id: String? = item.questionnaireItem.linkId.value + } + + /** A row for a repeated group response instance's header. */ + data class RepeatedGroupHeader( + val id: String, + /** The response index. This is 0-indexed, but should be 1-indexed when rendered in the UI. */ + val index: Int, + /** Callback that is invoked when the user clicks the delete button. */ + val onDeleteClicked: () -> Unit, + /** Responses nested under this header. */ + val responses: List, + val title: String, + ) : QuestionnaireAdapterItem + + data class RepeatedGroupAddButton( + var id: String?, + val item: QuestionnaireViewItem, + ) : QuestionnaireAdapterItem + + data class Navigation(val questionnaireNavigationUIState: QuestionnaireNavigationUIState) : + QuestionnaireAdapterItem, QuestionnaireReviewItem +} + +internal sealed interface QuestionnaireReviewItem diff --git a/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/QuestionnaireComposable.kt b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/QuestionnaireComposable.kt new file mode 100644 index 000000000..ab18920a2 --- /dev/null +++ b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/QuestionnaireComposable.kt @@ -0,0 +1,134 @@ +/* + * Copyright 2023-2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.ohs.fhir.datacapture + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.lifecycle.viewmodel.compose.viewModel +import com.google.fhir.model.r4.QuestionnaireResponse + +/** + * Public composable function for displaying a FHIR Questionnaire in KMP applications. + * + * This provides a builder-like API similar to QuestionnaireFragment.Builder but for Compose. + * + * @param questionnaireJson JSON string of the FHIR Questionnaire to display + * @param questionnaireResponseJson Optional JSON string of a pre-filled QuestionnaireResponse + * @param showSubmitButton Whether to show the submit button (default: true) + * @param showCancelButton Whether to show the cancel button (default: true) + * @param showReviewPage Whether to show a review page before submission (default: false) + * @param isReadOnly Whether the questionnaire is read-only (default: false) + * @param showAsterisk Whether to show asterisk for required fields (default: true) + * @param showRequiredText Whether to show "required" text (default: false) + * @param showOptionalText Whether to show "optional" text (default: false) + * @param submitButtonText Custom text for submit button (optional) + * @param matchersProvider Custom matchers provider for custom question types (optional) + * @param onSubmit Callback invoked when user submits the questionnaire with the response + * @param onCancel Callback invoked when user cancels the questionnaire + * + * Example usage: + * ``` + * Questionnaire( + * questionnaireJson = myQuestionnaireJson, + * showSubmitButton = true, + * showCancelButton = true, + * onSubmit = { response -> + * // Handle the questionnaire response + * println("Received response: $response") + * }, + * onCancel = { + * // Handle cancellation + * navController.popBackStack() + * } + * ) + * ``` + */ +@Composable +fun Questionnaire( + questionnaireJson: String, + questionnaireResponseJson: String? = null, + showSubmitButton: Boolean = true, + showCancelButton: Boolean = true, + showReviewPage: Boolean = false, + showReviewPageFirst: Boolean = false, + isReadOnly: Boolean = false, + showAsterisk: Boolean = false, + showRequiredText: Boolean = true, + showOptionalText: Boolean = false, + showNavigationLongScroll: Boolean = false, + submitButtonText: String? = null, + matchersProvider: QuestionnaireItemViewHolderFactoryMatchersProvider? = null, + onSubmit: (suspend () -> QuestionnaireResponse) -> Unit, + onCancel: () -> Unit, +) { + val stateMap = + remember( + questionnaireJson, + questionnaireResponseJson, + showSubmitButton, + showCancelButton, + showReviewPage, + showReviewPageFirst, + isReadOnly, + showAsterisk, + showRequiredText, + showOptionalText, + showNavigationLongScroll, + submitButtonText, + ) { + buildMap { + put(EXTRA_QUESTIONNAIRE_JSON_STRING, questionnaireJson) + questionnaireResponseJson?.let { put(EXTRA_QUESTIONNAIRE_RESPONSE_JSON_STRING, it) } + put(EXTRA_SHOW_SUBMIT_BUTTON, showSubmitButton) + put(EXTRA_SHOW_CANCEL_BUTTON, showCancelButton) + put(EXTRA_ENABLE_REVIEW_PAGE, showReviewPage) + put(EXTRA_READ_ONLY, isReadOnly) + put(EXTRA_SHOW_ASTERISK_TEXT, showAsterisk) + put(EXTRA_SHOW_REQUIRED_TEXT, showRequiredText) + put(EXTRA_SHOW_OPTIONAL_TEXT, showOptionalText) + put(EXTRA_SHOW_REVIEW_PAGE_FIRST, showReviewPageFirst) + put(EXTRA_SHOW_NAVIGATION_IN_DEFAULT_LONG_SCROLL, showNavigationLongScroll) + submitButtonText?.let { put(EXTRA_SUBMIT_BUTTON_TEXT, it) } + } + } + + val viewModel: QuestionnaireViewModel = viewModel { QuestionnaireViewModel(stateMap) } + + LaunchedEffect(viewModel, onSubmit, onCancel) { + viewModel.setOnSubmitButtonClickListener { onSubmit { viewModel.getQuestionnaireResponse() } } + + viewModel.setOnCancelButtonClickListener { onCancel() } + } + + val effectiveMatchersProvider = + matchersProvider ?: EmptyQuestionnaireItemViewHolderFactoryMatchersProvider + + QuestionnaireScreen( + viewModel = viewModel, + matchersProvider = effectiveMatchersProvider, + ) +} + +/** + * Default empty implementation of QuestionnaireItemViewHolderFactoryMatchersProvider that provides + * no custom matchers. + */ +private object EmptyQuestionnaireItemViewHolderFactoryMatchersProvider : + QuestionnaireItemViewHolderFactoryMatchersProvider() { + override fun get() = emptyList() +} diff --git a/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/QuestionnaireItemViewHolderFactoryMatcher.kt b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/QuestionnaireItemViewHolderFactoryMatcher.kt new file mode 100644 index 000000000..8e154f81a --- /dev/null +++ b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/QuestionnaireItemViewHolderFactoryMatcher.kt @@ -0,0 +1,38 @@ +/* + * Copyright 2025-2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.ohs.fhir.datacapture + +import com.google.fhir.model.r4.Questionnaire +import dev.ohs.fhir.datacapture.views.factories.QuestionnaireItemViewFactory + +/** + * Data class that holds a matcher function ([matches]) which evaluates whether a given [factory] + * should be used to display a given [Questionnaire.Item]. + * + * See the + * [developer guide](https://github.com/google/android-fhir/wiki/SDCL:-Customize-how-a-Questionnaire-is-displayed#custom-questionnaire-components) + * for more information. + */ +data class QuestionnaireItemViewHolderFactoryMatcher( + /** The custom [QuestionnaireItemViewFactory] to use. */ + val factory: QuestionnaireItemViewFactory, + /** + * A predicate function which, given a [Questionnaire.Item], returns true if the factory should + * apply to that item. + */ + val matches: (Questionnaire.Item) -> Boolean, +) diff --git a/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/QuestionnaireItemViewHolderFactoryMatchersProvider.kt b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/QuestionnaireItemViewHolderFactoryMatchersProvider.kt new file mode 100644 index 000000000..a8d578c9d --- /dev/null +++ b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/QuestionnaireItemViewHolderFactoryMatchersProvider.kt @@ -0,0 +1,44 @@ +/* + * Copyright 2025-2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.ohs.fhir.datacapture + +/** + * Provides the [QuestionnaireItemViewHolderFactoryMatcher]s which are used to evaluate whether a + * custom [dev.ohs.fhir.datacapture.views.factories.QuestionnaireItemViewHolderFactory] should be + * used to render a given questionnaire item. + * + * **NOTE**: + * + * User-provided custom views take precedence over canonical views provided by the library. If + * multiple [QuestionnaireItemViewHolderFactoryMatcher] are applicable for the same item, the + * behavior is undefined (any of them may be selected). + * + * See the + * [developer guide](https://github.com/google/android-fhir/wiki/SDCL:-Customize-how-a-Questionnaire-is-displayed#custom-questionnaire-components) + * for more information. + */ +abstract class QuestionnaireItemViewHolderFactoryMatchersProvider { + /** + * Implementation should specify when custom questionnaire components should be used. + * + * @return A [List] of [QuestionnaireItemViewHolderFactoryMatcher]s which are used to evaluate + * whether a custom + * [dev.ohs.fhir.datacapture.views.factories.QuestionnaireItemViewHolderFactory] should be used + * to render a given questionnaire item. + */ + abstract fun get(): List +} diff --git a/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/QuestionnaireLists.kt b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/QuestionnaireLists.kt new file mode 100644 index 000000000..cb38f8b09 --- /dev/null +++ b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/QuestionnaireLists.kt @@ -0,0 +1,431 @@ +/* + * Copyright 2024-2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.ohs.fhir.datacapture + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.google.fhir.model.r4.Questionnaire +import dev.ohs.fhir.datacapture.extensions.displayString +import dev.ohs.fhir.datacapture.extensions.elementValue +import dev.ohs.fhir.datacapture.extensions.itemControl +import dev.ohs.fhir.datacapture.extensions.shouldUseDialog +import dev.ohs.fhir.datacapture.generated.resources.Res +import dev.ohs.fhir.datacapture.generated.resources.not_answered +import dev.ohs.fhir.datacapture.theme.QuestionnaireTheme +import dev.ohs.fhir.datacapture.validation.Invalid +import dev.ohs.fhir.datacapture.views.QuestionnaireViewItem +import dev.ohs.fhir.datacapture.views.components.QuestionnaireBottomNavigation +import dev.ohs.fhir.datacapture.views.components.RepeatedGroupAddButtonItem +import dev.ohs.fhir.datacapture.views.components.RepeatedGroupHeaderItem +import dev.ohs.fhir.datacapture.views.factories.AttachmentViewFactory +import dev.ohs.fhir.datacapture.views.factories.AutoCompleteViewFactory +import dev.ohs.fhir.datacapture.views.factories.BooleanChoiceViewFactory +import dev.ohs.fhir.datacapture.views.factories.CheckBoxGroupViewFactory +import dev.ohs.fhir.datacapture.views.factories.DateTimeViewFactory +import dev.ohs.fhir.datacapture.views.factories.DateViewFactory +import dev.ohs.fhir.datacapture.views.factories.DialogSelectViewFactory +import dev.ohs.fhir.datacapture.views.factories.DisplayViewFactory +import dev.ohs.fhir.datacapture.views.factories.DropDownViewFactory +import dev.ohs.fhir.datacapture.views.factories.EditTextDecimalViewFactory +import dev.ohs.fhir.datacapture.views.factories.EditTextIntegerViewFactory +import dev.ohs.fhir.datacapture.views.factories.EditTextMultiLineViewFactory +import dev.ohs.fhir.datacapture.views.factories.EditTextPhoneNumberViewFactory +import dev.ohs.fhir.datacapture.views.factories.EditTextSingleLineViewFactory +import dev.ohs.fhir.datacapture.views.factories.GroupViewFactory +import dev.ohs.fhir.datacapture.views.factories.QuantityViewFactory +import dev.ohs.fhir.datacapture.views.factories.QuestionnaireItemViewFactory +import dev.ohs.fhir.datacapture.views.factories.RadioGroupViewFactory +import dev.ohs.fhir.datacapture.views.factories.SliderViewFactory +import dev.ohs.fhir.datacapture.views.factories.TimeViewFactory +import kotlin.uuid.ExperimentalUuidApi +import org.jetbrains.compose.resources.stringResource + +// Choice questions are rendered as dialogs if they have at least this many options +const val MINIMUM_NUMBER_OF_ANSWER_OPTIONS_FOR_DIALOG = 10 + +// Choice questions are rendered as radio group if number of options less than this constant +const val MINIMUM_NUMBER_OF_ANSWER_OPTIONS_FOR_DROP_DOWN = 4 + +// Test tag for QuestionnaireEditList +const val QUESTIONNAIRE_EDIT_LIST = "questionnaire_edit_list" + +@OptIn(ExperimentalUuidApi::class) +@Composable +internal fun QuestionnaireEditList( + items: List, + displayMode: DisplayMode, + questionnaireItemViewHolderMatchers: List, + onUpdateProgressIndicator: (Int, Int) -> Unit, +) { + val listState = rememberLazyListState() + LaunchedEffect(listState) { + if (displayMode is DisplayMode.EditMode && !displayMode.pagination.isPaginated) { + snapshotFlow { + val layoutInfo = listState.layoutInfo + val visibleItems = layoutInfo.visibleItemsInfo + val lastVisible = layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0 + val total = layoutInfo.totalItemsCount + + // If all items are visible, we're at 100% + if (visibleItems.size >= total && total > 0) { + total to total + } else { + lastVisible + 1 to total + } + } + .collect { (visibleCount, total) -> onUpdateProgressIndicator(visibleCount, total) } + } + } + LazyColumn(state = listState, modifier = Modifier.testTag(QUESTIONNAIRE_EDIT_LIST)) { + items( + items = items, + key = { item -> + when (item) { + is QuestionnaireAdapterItem.Question -> item.id + ?: throw IllegalStateException("Missing id for the Question: $item") + is QuestionnaireAdapterItem.RepeatedGroupHeader -> item.id + is QuestionnaireAdapterItem.Navigation -> "navigation" + is QuestionnaireAdapterItem.RepeatedGroupAddButton -> item.id + ?: throw IllegalStateException("Missing id for the RepeatedGroupAddButton: $item") + } + }, + contentType = { it::class.simpleName }, + ) { adapterItem: QuestionnaireAdapterItem -> + when (adapterItem) { + is QuestionnaireAdapterItem.Question -> { + val questionnaireViewHolderType = getItemViewTypeForQuestion(adapterItem.item) + val questionnaireItemViewHolderDelegate = + getQuestionnaireItemViewFactory(questionnaireViewHolderType) + questionnaireItemViewHolderDelegate.Content(adapterItem.item) + } + is QuestionnaireAdapterItem.Navigation -> { + QuestionnaireBottomNavigation(adapterItem.questionnaireNavigationUIState) + } + is QuestionnaireAdapterItem.RepeatedGroupHeader -> { + RepeatedGroupHeaderItem(adapterItem) + } + is QuestionnaireAdapterItem.RepeatedGroupAddButton -> { + RepeatedGroupAddButtonItem(adapterItem.item) + } + } + } + } +} + +@Composable +internal fun QuestionnaireReviewList(items: List) { + LazyColumn { + items( + items = items, + key = { item -> + when (item) { + is QuestionnaireAdapterItem.Question -> item.id + ?: throw IllegalStateException("Missing id for the Question: $item") + is QuestionnaireAdapterItem.Navigation -> "navigation" + } + }, + contentType = { it::class.simpleName }, + ) { item: QuestionnaireReviewItem -> + when (item) { + is QuestionnaireAdapterItem.Question -> { + QuestionnaireReviewItem( + questionnaireViewItem = item.item, + modifier = Modifier.fillMaxWidth(), + ) + } + is QuestionnaireAdapterItem.Navigation -> { + QuestionnaireBottomNavigation( + item.questionnaireNavigationUIState, + modifier = Modifier.fillMaxWidth(), + ) + } + } + } + } +} + +@Composable +private fun QuestionnaireReviewItem( + questionnaireViewItem: QuestionnaireViewItem, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.padding(horizontal = 16.dp, vertical = 16.dp), + ) { + // Header section with prefix, question, and hint + val hasPrefix = questionnaireViewItem.questionnaireItem.prefix?.value?.isNotEmpty() == true + val hasQuestion = questionnaireViewItem.questionText?.isNotEmpty() == true + val hasHint = + questionnaireViewItem.enabledDisplayItems.any { it.text?.value?.isNotEmpty() == true } + + if (hasPrefix || hasQuestion || hasHint) { + Column { + // Question with optional prefix + if (hasPrefix || hasQuestion) { + val questionText = buildString { + if (hasPrefix) { + append(questionnaireViewItem.questionnaireItem.prefix ?: "") + if (hasQuestion) append(" ") + } + if (hasQuestion) { + append(questionnaireViewItem.questionText?.toString() ?: "") + } + } + + Text( + text = questionText, + style = QuestionnaireTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = QuestionnaireTheme.colorScheme.onSurface, + modifier = Modifier.padding(bottom = 4.dp), + ) + } + + // Hint/instructions. Should we show the hint instructions? + /*if (hasHint) { + questionnaireViewItem.enabledDisplayItems.forEach { displayItem -> + displayItem.text?.let { hintText -> + if (hintText.isNotEmpty()) { + Text( + text = hintText, + style = QuestionnaireTheme.typography.bodyMedium, + color = QuestionnaireTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = 8.dp), + ) + } + } + } + }*/ + } + } + + // Flyover text + questionnaireViewItem.enabledDisplayItems.forEach { displayItem -> + displayItem.extension.forEach { ext -> + if (ext.url == "http://hl7.org/fhir/StructureDefinition/questionnaire-displayCategory") { + ext.value?.asString()?.value?.value?.let { flyoverText -> + if (flyoverText.isNotEmpty()) { + Text( + text = flyoverText, + style = QuestionnaireTheme.typography.bodyMedium, + color = QuestionnaireTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 8.dp), + ) + } + } + } + } + } + + // Answer section (only for non-group, non-display items) + when (questionnaireViewItem.questionnaireItem.type.value) { + Questionnaire.QuestionnaireItemType.Group, + Questionnaire.QuestionnaireItemType.Display, -> { + // No answer display for groups and display items + } + else -> { + val notAnsweredTextString = stringResource(Res.string.not_answered) + val answerText = + questionnaireViewItem.answers + .map { it.elementValue?.displayString ?: "" } + .joinToString() + .ifBlank { notAnsweredTextString } + + if (answerText.isNotEmpty()) { + Text( + text = answerText, + style = QuestionnaireTheme.typography.bodyLarge, + color = QuestionnaireTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 4.dp), + ) + } + + // Error display + if (questionnaireViewItem.validationResult is Invalid) { + Row( + modifier = Modifier.padding(top = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Default.Warning, + contentDescription = "Error", + tint = QuestionnaireTheme.colorScheme.error, + ) + Text( + text = questionnaireViewItem.validationResult.singleStringValidationMessage, + style = QuestionnaireTheme.typography.bodyMedium, + color = QuestionnaireTheme.colorScheme.error, + ) + } + } + } + } + + // Divider + HorizontalDivider( + modifier = Modifier.padding(top = 16.dp), + color = QuestionnaireTheme.colorScheme.outlineVariant, + thickness = 0.5.dp, + ) + } +} + +// TODO provide option to override factory +/*private fun getQuestionnaireItemViewHolder( + parent: ViewGroup, + questionnaireViewItem: QuestionnaireViewItem, + questionnaireItemViewHolderMatchers: + List, +): QuestionnaireItemViewHolder { + // Find a matching custom widget + val questionnaireViewHolderFactory = + questionnaireItemViewHolderMatchers + .find { it.matches(questionnaireViewItem.questionnaireItem) } + ?.factory + ?: getQuestionnaireItemViewHolderFactory(getItemViewTypeForQuestion(questionnaireViewItem)) + return questionnaireViewHolderFactory.create(parent) +}*/ + +fun getQuestionnaireItemViewFactory( + questionnaireViewHolderType: QuestionnaireViewHolderType, +): QuestionnaireItemViewFactory { + return when (questionnaireViewHolderType) { + QuestionnaireViewHolderType.EDIT_TEXT_SINGLE_LINE -> EditTextSingleLineViewFactory + QuestionnaireViewHolderType.EDIT_TEXT_MULTI_LINE -> EditTextMultiLineViewFactory + QuestionnaireViewHolderType.EDIT_TEXT_INTEGER -> EditTextIntegerViewFactory + QuestionnaireViewHolderType.EDIT_TEXT_DECIMAL -> EditTextDecimalViewFactory + QuestionnaireViewHolderType.QUANTITY -> QuantityViewFactory + QuestionnaireViewHolderType.DISPLAY -> DisplayViewFactory + QuestionnaireViewHolderType.SLIDER -> SliderViewFactory + QuestionnaireViewHolderType.PHONE_NUMBER -> EditTextPhoneNumberViewFactory + QuestionnaireViewHolderType.BOOLEAN_TYPE_PICKER -> BooleanChoiceViewFactory + QuestionnaireViewHolderType.RADIO_GROUP -> RadioGroupViewFactory + QuestionnaireViewHolderType.CHECK_BOX_GROUP -> CheckBoxGroupViewFactory + QuestionnaireViewHolderType.DIALOG_SELECT -> DialogSelectViewFactory + QuestionnaireViewHolderType.DROP_DOWN -> DropDownViewFactory + QuestionnaireViewHolderType.AUTO_COMPLETE -> AutoCompleteViewFactory + QuestionnaireViewHolderType.DATE_PICKER -> DateViewFactory + QuestionnaireViewHolderType.TIME_PICKER -> TimeViewFactory + QuestionnaireViewHolderType.DATE_TIME_PICKER -> DateTimeViewFactory + QuestionnaireViewHolderType.GROUP -> GroupViewFactory + QuestionnaireViewHolderType.ATTACHMENT -> AttachmentViewFactory + } +} + +/** + * Returns the [QuestionnaireViewHolderType] that will be used to render the + * [QuestionnaireViewItem]. This is determined by a combination of the data type of the question and + * any additional Questionnaire Item UI Control Codes + * (http://hl7.org/fhir/R4/valueset-questionnaire-item-control.html) used in the itemControl + * extension (http://hl7.org/fhir/R4/extension-questionnaire-itemcontrol.html). + */ +private fun getItemViewTypeForQuestion( + questionnaireViewItem: QuestionnaireViewItem, +): QuestionnaireViewHolderType { + val questionnaireItem = questionnaireViewItem.questionnaireItem + + if (questionnaireViewItem.enabledAnswerOptions.isNotEmpty()) { + return getChoiceViewHolderType(questionnaireViewItem) + } + + return when (val type = questionnaireItem.type.value) { + Questionnaire.QuestionnaireItemType.Group -> QuestionnaireViewHolderType.GROUP + Questionnaire.QuestionnaireItemType.Boolean -> QuestionnaireViewHolderType.BOOLEAN_TYPE_PICKER + Questionnaire.QuestionnaireItemType.Date -> QuestionnaireViewHolderType.DATE_PICKER + Questionnaire.QuestionnaireItemType.Time -> QuestionnaireViewHolderType.TIME_PICKER + Questionnaire.QuestionnaireItemType.DateTime -> QuestionnaireViewHolderType.DATE_TIME_PICKER + Questionnaire.QuestionnaireItemType.String -> getStringViewHolderType(questionnaireViewItem) + Questionnaire.QuestionnaireItemType.Text -> QuestionnaireViewHolderType.EDIT_TEXT_MULTI_LINE + Questionnaire.QuestionnaireItemType.Integer -> getIntegerViewHolderType(questionnaireViewItem) + Questionnaire.QuestionnaireItemType.Decimal -> QuestionnaireViewHolderType.EDIT_TEXT_DECIMAL + Questionnaire.QuestionnaireItemType.Choice, + Questionnaire.QuestionnaireItemType.Reference, -> getChoiceViewHolderType(questionnaireViewItem) + Questionnaire.QuestionnaireItemType.Display -> QuestionnaireViewHolderType.DISPLAY + Questionnaire.QuestionnaireItemType.Quantity -> QuestionnaireViewHolderType.QUANTITY + Questionnaire.QuestionnaireItemType.Attachment -> QuestionnaireViewHolderType.ATTACHMENT + else -> throw NotImplementedError("Question type $type not supported.") + } +} + +private fun getChoiceViewHolderType( + questionnaireViewItem: QuestionnaireViewItem, +): QuestionnaireViewHolderType { + val questionnaireItem = questionnaireViewItem.questionnaireItem + + // Use the view type that the client wants if they specified an itemControl or dialog extension + return when { + questionnaireItem.shouldUseDialog -> QuestionnaireViewHolderType.DIALOG_SELECT + else -> questionnaireItem.itemControl?.viewHolderType + } + // Otherwise, choose a sensible UI element automatically + ?: run { + val numOptions = questionnaireViewItem.enabledAnswerOptions.size + when { + // Always use a dialog for questions with a large number of options + numOptions >= MINIMUM_NUMBER_OF_ANSWER_OPTIONS_FOR_DIALOG -> + QuestionnaireViewHolderType.DIALOG_SELECT + + // Use a check box group if repeated answers are permitted + questionnaireItem.repeats?.value == true -> QuestionnaireViewHolderType.CHECK_BOX_GROUP + + // Use a dropdown if there are a medium number of options + numOptions >= MINIMUM_NUMBER_OF_ANSWER_OPTIONS_FOR_DROP_DOWN -> + QuestionnaireViewHolderType.DROP_DOWN + + // Use a radio group only if there are a small number of options + else -> QuestionnaireViewHolderType.RADIO_GROUP + } + } +} + +private fun getIntegerViewHolderType( + questionnaireViewItem: QuestionnaireViewItem, +): QuestionnaireViewHolderType { + val questionnaireItem = questionnaireViewItem.questionnaireItem + // Use the view type that the client wants if they specified an itemControl + return questionnaireItem.itemControl?.viewHolderType + ?: QuestionnaireViewHolderType.EDIT_TEXT_INTEGER +} + +private fun getStringViewHolderType( + questionnaireViewItem: QuestionnaireViewItem, +): QuestionnaireViewHolderType { + val questionnaireItem = questionnaireViewItem.questionnaireItem + // Use the view type that the client wants if they specified an itemControl + return questionnaireItem.itemControl?.viewHolderType + ?: QuestionnaireViewHolderType.EDIT_TEXT_SINGLE_LINE +} diff --git a/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/QuestionnaireNavigationViewUIState.kt b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/QuestionnaireNavigationViewUIState.kt new file mode 100644 index 000000000..0599b755d --- /dev/null +++ b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/QuestionnaireNavigationViewUIState.kt @@ -0,0 +1,32 @@ +/* + * Copyright 2023-2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.ohs.fhir.datacapture + +sealed interface QuestionnaireNavigationViewUIState { + data object Hidden : QuestionnaireNavigationViewUIState + + data class Enabled(val labelText: String? = null, val onClickAction: () -> Unit) : + QuestionnaireNavigationViewUIState +} + +data class QuestionnaireNavigationUIState( + val navPrevious: QuestionnaireNavigationViewUIState = QuestionnaireNavigationViewUIState.Hidden, + val navNext: QuestionnaireNavigationViewUIState = QuestionnaireNavigationViewUIState.Hidden, + val navSubmit: QuestionnaireNavigationViewUIState = QuestionnaireNavigationViewUIState.Hidden, + val navCancel: QuestionnaireNavigationViewUIState = QuestionnaireNavigationViewUIState.Hidden, + val navReview: QuestionnaireNavigationViewUIState = QuestionnaireNavigationViewUIState.Hidden, +) diff --git a/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/QuestionnaireScreen.kt b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/QuestionnaireScreen.kt new file mode 100644 index 000000000..0cb63d304 --- /dev/null +++ b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/QuestionnaireScreen.kt @@ -0,0 +1,278 @@ +/* + * Copyright 2023-2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.ohs.fhir.datacapture + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Edit +import androidx.compose.material3.Icon +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import dev.ohs.fhir.datacapture.generated.resources.Res +import dev.ohs.fhir.datacapture.generated.resources.edit_button_text +import dev.ohs.fhir.datacapture.generated.resources.questionnaire_review_mode_title +import dev.ohs.fhir.datacapture.theme.QuestionnaireTheme +import dev.ohs.fhir.datacapture.views.components.QuestionnaireBottomNavigation +import org.jetbrains.compose.resources.stringResource + +/** + * A JSON encoded string extra for a questionnaire. This should only be used for questionnaires with + * size at most 512KB. For large questionnaires, use [EXTRA_QUESTIONNAIRE_JSON_URI]. + * + * This is required unless [EXTRA_QUESTIONNAIRE_JSON_URI] is provided. + * + * If this and [EXTRA_QUESTIONNAIRE_JSON_URI] are provided, [EXTRA_QUESTIONNAIRE_JSON_URI] takes + * precedence. + */ +internal const val EXTRA_QUESTIONNAIRE_JSON_STRING = "questionnaire" + +/** + * A [URI][android.net.Uri] extra for streaming a JSON encoded questionnaire. + * + * This is required unless [EXTRA_QUESTIONNAIRE_JSON_STRING] is provided. + * + * If this and [EXTRA_QUESTIONNAIRE_JSON_STRING] are provided, this extra takes precedence. + */ +internal const val EXTRA_QUESTIONNAIRE_JSON_URI = "questionnaire-uri" + +/** + * A JSON encoded string extra for a prefilled questionnaire response. This should only be used for + * questionnaire response with size at most 512KB. For large questionnaire response, use + * [EXTRA_QUESTIONNAIRE_RESPONSE_JSON_URI]. + * + * If this and [EXTRA_QUESTIONNAIRE_RESPONSE_JSON_URI] are provided, + * [EXTRA_QUESTIONNAIRE_RESPONSE_JSON_URI] takes precedence. + */ +internal const val EXTRA_QUESTIONNAIRE_RESPONSE_JSON_STRING = "questionnaire-response" + +/** A map of launchContext name and JSON encoded strings extra for each questionnaire context. */ +internal const val EXTRA_QUESTIONNAIRE_LAUNCH_CONTEXT_MAP = "questionnaire-launch-contexts" + +/** + * A [URI][android.net.Uri] extra for streaming a JSON encoded questionnaire response. + * + * If this and [EXTRA_QUESTIONNAIRE_RESPONSE_JSON_STRING] are provided, this extra takes precedence. + */ +internal const val EXTRA_QUESTIONNAIRE_RESPONSE_JSON_URI = "questionnaire-response-uri" + +/** + * A [Boolean] extra to control if a review page is shown. By default it will be shown at the end of + * the questionnaire. + */ +internal const val EXTRA_ENABLE_REVIEW_PAGE = "enable-review-page" + +/** + * A [Boolean] extra to control if the review page is to be opened first. This has no effect if + * review page is not enabled. + */ +internal const val EXTRA_SHOW_REVIEW_PAGE_FIRST = "show-review-page-first" + +/** + * An [Boolean] extra to control if the questionnaire is read-only. If review page and read-only are + * both enabled, read-only will take precedence. + */ +internal const val EXTRA_READ_ONLY = "read-only" + +internal const val EXTRA_MATCHERS_FACTORY = "matcher_factory_class" + +const val SUBMIT_REQUEST_KEY = "submit-request-key" + +const val CANCEL_REQUEST_KEY = "cancel-request-key" + +/** A [Boolean] extra to show or hide the Submit button in the questionnaire. Default is true. */ +internal const val EXTRA_SHOW_SUBMIT_BUTTON = "show-submit-button" + +/** A [Boolean] extra to show or hide the Cancel button in the questionnaire. Default is false. */ +internal const val EXTRA_SHOW_CANCEL_BUTTON = "show-cancel-button" + +internal const val EXTRA_SHOW_OPTIONAL_TEXT = "show-optional-text" + +internal const val EXTRA_SHOW_ASTERISK_TEXT = "show-asterisk-text" + +internal const val EXTRA_SHOW_REQUIRED_TEXT = "show-required-text" + +internal const val EXTRA_SUBMIT_BUTTON_TEXT = "submit-button-text" + +internal const val EXTRA_SHOW_NAVIGATION_IN_DEFAULT_LONG_SCROLL = + "show-navigation-in-default-long-scroll" + +/** + * A [Boolean] extra to show or hide the Submit anyway button in the questionnaire. Default is true. + */ +internal const val EXTRA_SHOW_SUBMIT_ANYWAY_BUTTON = "show-submit-anyway-button" + +internal const val QUESTIONNAIRE_PROGRESS_INDICATOR_TEST_TAG = + "questionnaire_progress_indicator_test_tag" + +@Composable +internal fun QuestionnaireScreen( + viewModel: QuestionnaireViewModel, + matchersProvider: QuestionnaireItemViewHolderFactoryMatchersProvider, +) { + val questionnaireState by viewModel.questionnaireStateFlow.collectAsStateWithLifecycle() + + Box(modifier = Modifier.fillMaxSize()) { + when (val displayMode = questionnaireState.displayMode) { + is DisplayMode.InitMode -> { + // Empty state - nothing to show + } + is DisplayMode.EditMode -> { + EditModeContent( + state = questionnaireState, + displayMode = displayMode, + matchersProvider = matchersProvider, + bottomNavItem = questionnaireState.bottomNavItem, + ) + } + is DisplayMode.ReviewMode -> { + ReviewModeContent( + state = questionnaireState, + displayMode = displayMode, + onEditClick = { viewModel.setReviewMode(false) }, + bottomNavItem = questionnaireState.bottomNavItem, + ) + } + } + } +} + +@Composable +private fun EditModeContent( + state: QuestionnaireState, + displayMode: DisplayMode.EditMode, + matchersProvider: QuestionnaireItemViewHolderFactoryMatchersProvider, + bottomNavItem: QuestionnaireAdapterItem.Navigation?, +) { + var progress by remember { mutableIntStateOf(0) } + + LaunchedEffect(displayMode) { + if (displayMode.pagination.isPaginated) { + progress = + calculateProgressPercentage( + count = displayMode.pagination.currentPageIndex + 1, + totalCount = displayMode.pagination.pages.size, + ) + } + } + Scaffold( + topBar = { + LinearProgressIndicator( + progress = { progress / 100f }, + modifier = + Modifier.testTag(QUESTIONNAIRE_PROGRESS_INDICATOR_TEST_TAG).height(4.dp).fillMaxWidth(), + ) + }, + bottomBar = { + if (bottomNavItem != null) { + QuestionnaireBottomNavigation(bottomNavItem.questionnaireNavigationUIState) + } + }, + ) { innerPadding -> + Box(modifier = Modifier.padding(innerPadding).fillMaxSize()) { + QuestionnaireEditList( + items = state.items, + displayMode = displayMode, + questionnaireItemViewHolderMatchers = matchersProvider.get(), + onUpdateProgressIndicator = { currentPage, totalCount -> + progress = calculateProgressPercentage(count = (currentPage + 1), totalCount = totalCount) + }, + ) + } + } +} + +@Composable +private fun ReviewModeContent( + state: QuestionnaireState, + displayMode: DisplayMode.ReviewMode, + onEditClick: () -> Unit, + bottomNavItem: QuestionnaireAdapterItem.Navigation?, +) { + Scaffold( + topBar = { + QuestionnaireTitleBar( + showEditButton = displayMode.showEditButton, + onEditClick = onEditClick, + modifier = Modifier.fillMaxWidth(), + ) + }, + bottomBar = { + if (bottomNavItem != null) { + QuestionnaireBottomNavigation( + bottomNavItem.questionnaireNavigationUIState, + ) + } + }, + ) { innerPadding -> + Box(modifier = Modifier.padding(innerPadding).fillMaxSize()) { + QuestionnaireReviewList(items = state.items.filterIsInstance()) + } + } +} + +@Composable +fun QuestionnaireTitleBar( + showEditButton: Boolean, + onEditClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier.padding(horizontal = 16.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(Res.string.questionnaire_review_mode_title), + style = QuestionnaireTheme.typography.titleLarge, + modifier = Modifier.weight(1f), + ) + + if (showEditButton) { + OutlinedButton(onClick = onEditClick) { + Icon( + imageVector = Icons.Outlined.Edit, + contentDescription = "Edit", + ) + Text(text = stringResource(Res.string.edit_button_text)) + } + } + } +} + +/** Calculates the progress percentage from given [count] and [totalCount] values. */ +private fun calculateProgressPercentage(count: Int, totalCount: Int): Int { + return if (totalCount == 0) 0 else (count * 100 / totalCount) +} diff --git a/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/QuestionnaireViewHolderType.kt b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/QuestionnaireViewHolderType.kt new file mode 100644 index 000000000..343d00987 --- /dev/null +++ b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/QuestionnaireViewHolderType.kt @@ -0,0 +1,56 @@ +/* + * Copyright 2023-2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.ohs.fhir.datacapture + +/** + * Questionnaire item view holder types supported by default by the data capture library. + * + * This is used by the [dev.ohs.fhir.datacapture.QuestionnaireFragment] lists to determine how each + * [com.google.fhir.model.r4.Questionnaire.Item] is rendered. + * + * This list should provide sufficient coverage for values in + * https://www.hl7.org/fhir/valueset-item-type.html and + * http://hl7.org/fhir/R4/valueset-questionnaire-item-control.html. + */ +enum class QuestionnaireViewHolderType(val value: Int) { + GROUP(0), + BOOLEAN_TYPE_PICKER(1), + DATE_PICKER(2), + DATE_TIME_PICKER(3), + EDIT_TEXT_SINGLE_LINE(4), + EDIT_TEXT_MULTI_LINE(5), + EDIT_TEXT_INTEGER(6), + EDIT_TEXT_DECIMAL(7), + RADIO_GROUP(8), + DROP_DOWN(9), + DISPLAY(10), + QUANTITY(11), + CHECK_BOX_GROUP(12), + AUTO_COMPLETE(13), + DIALOG_SELECT(14), + SLIDER(15), + PHONE_NUMBER(16), + ATTACHMENT(17), + TIME_PICKER(18), + ; + + companion object { + private val VALUES = values() + + fun fromInt(value: Int) = VALUES[value] + } +} diff --git a/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/QuestionnaireViewModel.kt b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/QuestionnaireViewModel.kt new file mode 100644 index 000000000..2cd798ae7 --- /dev/null +++ b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/QuestionnaireViewModel.kt @@ -0,0 +1,1357 @@ +/* + * Copyright 2023-2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.ohs.fhir.datacapture + +import androidx.annotation.VisibleForTesting +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import co.touchlab.kermit.Logger +import com.google.fhir.model.r4.Attachment +import com.google.fhir.model.r4.Canonical +import com.google.fhir.model.r4.Coding +import com.google.fhir.model.r4.Date +import com.google.fhir.model.r4.DateTime +import com.google.fhir.model.r4.Decimal +import com.google.fhir.model.r4.Enumeration +import com.google.fhir.model.r4.Extension +import com.google.fhir.model.r4.FhirDateTime +import com.google.fhir.model.r4.FhirR4Json +import com.google.fhir.model.r4.Integer +import com.google.fhir.model.r4.Quantity +import com.google.fhir.model.r4.Questionnaire +import com.google.fhir.model.r4.QuestionnaireResponse +import com.google.fhir.model.r4.Reference +import com.google.fhir.model.r4.Resource +import com.google.fhir.model.r4.Time +import com.google.fhir.model.r4.Uri +import dev.ohs.fhir.datacapture.enablement.EnablementEvaluator +import dev.ohs.fhir.datacapture.expressions.EnabledAnswerOptionsEvaluator +import dev.ohs.fhir.datacapture.extensions.EXTENSION_LAST_LAUNCHED_TIMESTAMP +import dev.ohs.fhir.datacapture.extensions.EntryMode +import dev.ohs.fhir.datacapture.extensions.FhirR4Boolean +import dev.ohs.fhir.datacapture.extensions.FhirR4String +import dev.ohs.fhir.datacapture.extensions.allItems +import dev.ohs.fhir.datacapture.extensions.calculatedExpression +import dev.ohs.fhir.datacapture.extensions.copyNestedItemsToChildlessAnswers +import dev.ohs.fhir.datacapture.extensions.cqfExpression +import dev.ohs.fhir.datacapture.extensions.createQuestionnaireResponseItem +import dev.ohs.fhir.datacapture.extensions.entryMode +import dev.ohs.fhir.datacapture.extensions.filterByCodeInNameExtension +import dev.ohs.fhir.datacapture.extensions.forEachItemPair +import dev.ohs.fhir.datacapture.extensions.hasDifferentAnswerSet +import dev.ohs.fhir.datacapture.extensions.isDisplayItem +import dev.ohs.fhir.datacapture.extensions.isHelpCode +import dev.ohs.fhir.datacapture.extensions.isHidden +import dev.ohs.fhir.datacapture.extensions.isPaginated +import dev.ohs.fhir.datacapture.extensions.isRepeatedGroup +import dev.ohs.fhir.datacapture.extensions.localizedTextAnnotatedString +import dev.ohs.fhir.datacapture.extensions.maxValueCqfCalculatedValueExpression +import dev.ohs.fhir.datacapture.extensions.minValueCqfCalculatedValueExpression +import dev.ohs.fhir.datacapture.extensions.packRepeatedGroups +import dev.ohs.fhir.datacapture.extensions.questionnaireLaunchContexts +import dev.ohs.fhir.datacapture.extensions.shouldHaveNestedItemsUnderAnswers +import dev.ohs.fhir.datacapture.extensions.unpackRepeatedGroups +import dev.ohs.fhir.datacapture.extensions.validateLaunchContextExtensions +import dev.ohs.fhir.datacapture.extensions.zipByLinkId +import dev.ohs.fhir.datacapture.fhirpath.ExpressionEvaluator +import dev.ohs.fhir.datacapture.fhirpath.convertToString +import dev.ohs.fhir.datacapture.generated.resources.Res +import dev.ohs.fhir.datacapture.generated.resources.submit_questionnaire +import dev.ohs.fhir.datacapture.validation.Invalid +import dev.ohs.fhir.datacapture.validation.NotValidated +import dev.ohs.fhir.datacapture.validation.QuestionnaireResponseItemValidator +import dev.ohs.fhir.datacapture.validation.QuestionnaireResponseValidator +import dev.ohs.fhir.datacapture.validation.QuestionnaireResponseValidator.checkQuestionnaireResponse +import dev.ohs.fhir.datacapture.validation.Valid +import dev.ohs.fhir.datacapture.validation.ValidationResult +import dev.ohs.fhir.datacapture.views.QuestionTextConfiguration +import dev.ohs.fhir.datacapture.views.QuestionnaireViewItem +import kotlin.time.Clock +import kotlin.time.ExperimentalTime +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.flow.withIndex +import kotlinx.coroutines.launch +import kotlinx.datetime.TimeZone +import kotlinx.datetime.UtcOffset +import kotlinx.datetime.toLocalDateTime +import kotlinx.io.buffered +import kotlinx.io.files.Path +import kotlinx.io.files.SystemFileSystem +import kotlinx.io.readString +import org.jetbrains.compose.resources.getString + +@OptIn(ExperimentalTime::class) +internal class QuestionnaireViewModel(state: Map) : ViewModel() { + private val jsonR4 = FhirR4Json() + private val xFhirQueryResolver: XFhirQueryResolver? by lazy { + DataCapture.getConfiguration().xFhirQueryResolver + } + private val externalValueSetResolver: ExternalAnswerValueSetResolver? by lazy { + DataCapture.getConfiguration().valueSetResolverExternal + } + + /** The current questionnaire as questions are being answered. */ + internal val questionnaire: Questionnaire + + init { + questionnaire = + when { + state.contains(EXTRA_QUESTIONNAIRE_JSON_URI) -> { + if (state.contains(EXTRA_QUESTIONNAIRE_JSON_STRING)) { + Logger.w( + "Both EXTRA_QUESTIONNAIRE_JSON_URI & EXTRA_QUESTIONNAIRE_JSON_STRING are provided. " + + "EXTRA_QUESTIONNAIRE_JSON_URI takes precedence.", + ) + } + val uriPath: String = state[EXTRA_QUESTIONNAIRE_JSON_URI] as String + jsonR4.decodeFromString(readFileContent(uriPath)) as Questionnaire + } + state.contains(EXTRA_QUESTIONNAIRE_JSON_STRING) -> { + val questionnaireJson: String = state[EXTRA_QUESTIONNAIRE_JSON_STRING] as String + jsonR4.decodeFromString(questionnaireJson) as Questionnaire + } + else -> + error( + "Neither EXTRA_QUESTIONNAIRE_JSON_URI nor EXTRA_QUESTIONNAIRE_JSON_STRING is supplied.", + ) + } + } + + /** The current questionnaire response as questions are being answered. */ + private val questionnaireResponse: MutableStateFlow = + MutableStateFlow( + QuestionnaireResponse.Builder( + Enumeration(), + ) + .build(), + ) + + init { + val questionnaireResponseDraft: QuestionnaireResponse + + when { + state.contains(EXTRA_QUESTIONNAIRE_RESPONSE_JSON_URI) -> { + if (state.contains(EXTRA_QUESTIONNAIRE_RESPONSE_JSON_STRING)) { + Logger.w( + "Both EXTRA_QUESTIONNAIRE_RESPONSE_JSON_URI & EXTRA_QUESTIONNAIRE_RESPONSE_JSON_STRING are provided. " + + "EXTRA_QUESTIONNAIRE_RESPONSE_JSON_URI takes precedence.", + ) + } + + questionnaireResponseDraft = + jsonR4.decodeFromString( + readFileContent(state[EXTRA_QUESTIONNAIRE_RESPONSE_JSON_URI]!! as String), + ) as QuestionnaireResponse + + addMissingResponseItems( + questionnaire.item, + questionnaireResponseDraft.item.toMutableList(), + ) + checkQuestionnaireResponse(questionnaire, questionnaireResponseDraft) + } + state.contains(EXTRA_QUESTIONNAIRE_RESPONSE_JSON_STRING) -> { + val questionnaireResponseJson: String = + state[EXTRA_QUESTIONNAIRE_RESPONSE_JSON_STRING] as String + questionnaireResponseDraft = + jsonR4.decodeFromString(questionnaireResponseJson) as QuestionnaireResponse + addMissingResponseItems( + questionnaire.item, + questionnaireResponseDraft.item.toMutableList(), + ) + checkQuestionnaireResponse(questionnaire, questionnaireResponseDraft) + } + else -> { + questionnaireResponseDraft = + QuestionnaireResponse( + status = + Enumeration(value = QuestionnaireResponse.QuestionnaireResponseStatus.In_Progress), + questionnaire = Canonical(value = this@QuestionnaireViewModel.questionnaire.url?.value), + // Retain the hierarchy and order of items within the questionnaire as specified in + // the standard. See https://www.hl7.org/fhir/questionnaireresponse.html#notes. + item = + this@QuestionnaireViewModel.questionnaire.item + .filterNot { it.isRepeatedGroup } + .map { it.createQuestionnaireResponseItem().build() }, + ) + } + } + + questionnaireResponse.value = + questionnaireResponseDraft + .toBuilder() + .apply { + val dateTime = + DateTime( + value = + FhirDateTime.DateTime( + dateTime = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()), + utcOffset = UtcOffset.ZERO, + ), + ) + // Add extension for questionnaire launch time stamp + val timeStampExtension = + extension.firstOrNull { it.url == EXTENSION_LAST_LAUNCHED_TIMESTAMP } + timeStampExtension?.apply { value = Extension.Value.DateTime(dateTime) } + ?: extension.add( + Extension.Builder(EXTENSION_LAST_LAUNCHED_TIMESTAMP).apply { + value = Extension.Value.DateTime(dateTime) + }, + ) + + packRepeatedGroups(this@QuestionnaireViewModel.questionnaire) + } + .build() + } + + /** + * The launch context allows information to be passed into questionnaire based on the context in + * which the questionnaire is being evaluated. For example, what patient, what encounter, what + * user, etc. is "in context" at the time the questionnaire response is being completed: + * https://build.fhir.org/ig/HL7/sdc/StructureDefinition-sdc-questionnaire-launchContext.html + */ + @Suppress("UNCHECKED_CAST") + private val questionnaireLaunchContextMap: Map? = + if (state.contains(EXTRA_QUESTIONNAIRE_LAUNCH_CONTEXT_MAP)) { + + val launchContextMapString: Map = + state[EXTRA_QUESTIONNAIRE_LAUNCH_CONTEXT_MAP] as Map + + val launchContextMapResource = + launchContextMapString.mapValues { jsonR4.decodeFromString(it.value) } + questionnaire.questionnaireLaunchContexts?.let { launchContextExtensions -> + validateLaunchContextExtensions(launchContextExtensions) + filterByCodeInNameExtension(launchContextMapResource, launchContextExtensions) + } + } else { + null + } + + /** The map from each item in the [Questionnaire] to its parent. */ + private var questionnaireItemParentMap: Map + + init { + /** Adds each child-parent pair in the [Questionnaire] to the parent map. */ + fun buildParentList( + item: Questionnaire.Item, + questionnaireItemToParentMap: ItemToParentMap, + ) { + for (child in item.item) { + questionnaireItemToParentMap[child] = item + buildParentList(child, questionnaireItemToParentMap) + } + } + + questionnaireItemParentMap = buildMap { + for (item in questionnaire.item) { + buildParentList(item, this) + } + } + } + + @VisibleForTesting + val entryMode: EntryMode by lazy { questionnaire.entryMode ?: EntryMode.RANDOM } + + /** Flag to determine if the questionnaire should be read-only. */ + private val isReadOnly = state[EXTRA_READ_ONLY] as Boolean? ?: false + + /** Flag to support fragment for review-feature */ + private val shouldEnableReviewPage = state[EXTRA_ENABLE_REVIEW_PAGE] as Boolean? ?: false + + /** Flag to open fragment first in data-collection or review-mode */ + private val shouldShowReviewPageFirst = + shouldEnableReviewPage && state[EXTRA_SHOW_REVIEW_PAGE_FIRST] as Boolean? ?: false + + /** Flag to show/hide submit button. Default is true. */ + private var shouldShowSubmitButton = state[EXTRA_SHOW_SUBMIT_BUTTON] as Boolean? ?: true + + /** Flag to show questionnaire page as default/long scroll. Default is false. */ + private var shouldSetNavigationInLongScroll = + state[EXTRA_SHOW_NAVIGATION_IN_DEFAULT_LONG_SCROLL] as Boolean? ?: false + + private var submitButtonText = state[EXTRA_SUBMIT_BUTTON_TEXT] as String? ?: "" + + private var onSubmitButtonClickListener: () -> Unit = {} + + private var onCancelButtonClickListener: () -> Unit = {} + + /** Flag to show/hide cancel button. Default is false */ + private var shouldShowCancelButton = state[EXTRA_SHOW_CANCEL_BUTTON] as Boolean? ?: false + + /** Flag to control whether asterisk text is shown for required questions. */ + private val showAsterisk = state[EXTRA_SHOW_ASTERISK_TEXT] as Boolean? ?: false + + /** Flag to control whether asterisk text is shown for required questions. */ + private val showRequiredText = state[EXTRA_SHOW_REQUIRED_TEXT] as Boolean? ?: true + + /** Flag to control whether optional text is shown. */ + private val showOptionalText = state[EXTRA_SHOW_OPTIONAL_TEXT] as Boolean? ?: false + + /** The pages of the questionnaire, or null if the questionnaire is not paginated. */ + @VisibleForTesting var pages: List? = null + + /** + * The flow representing the index of the current page. This value is meaningless if the + * questionnaire is not paginated or in review mode. + */ + @VisibleForTesting val currentPageIndexFlow: MutableStateFlow = MutableStateFlow(null) + + /** Tracks modifications in order to update the UI. */ + private val modificationCount = MutableStateFlow(0) + + /** Toggles review mode. */ + private val isInReviewModeFlow = MutableStateFlow(shouldShowReviewPageFirst) + + /** Tracks which help card has been opened. */ + private val openedHelpCardSet: MutableSet = mutableSetOf() + + /** Callback to save the help card state. */ + private val helpCardStateChangedCallback: (Boolean, QuestionnaireResponse.Item) -> Unit = + { shouldBeVisible, questionnaireResponseItem -> + if (shouldBeVisible) { + openedHelpCardSet.add(questionnaireResponseItem) + } else { + openedHelpCardSet.remove(questionnaireResponseItem) + } + } + + /** + * Contains [QuestionnaireResponse.Item]s that have been modified by the user. + * [QuestionnaireResponse.Item]s that have not been modified by the user will not be validated. + * This is to avoid spamming the user with a sea of validation errors when the questionnaire is + * loaded initially. + */ + private val modifiedQuestionnaireResponseItemSet = mutableSetOf() + + private lateinit var currentPageItems: List + + /** + * Map of [QuestionnaireResponse.Item.Answer] for [Questionnaire.Item]s that are disabled now. The + * answers will be used to pre-populate the [QuestionnaireResponse.Item] once the item is enabled + * again. + */ + private val responseItemToAnswersMapForDisabledQuestionnaireItem = + mutableMapOf< + QuestionnaireResponse.Item, + List, + >() + + /** + * Map from [Questionnaire.Item] to draft answers, e.g "02/02" for date with missing year part. + * + * This is used to maintain draft answers on the screen especially when the widgets are being + * recycled as a result of scrolling. Draft answers cannot be saved in [QuestionnaireResponse] + * because they might be incomplete and unparsable. Without this map, incomplete and unparsable + * answers would be lost. + * + * When the draft answer becomes valid, its entry in the map is removed, e.g, "02/02/2023" is + * valid answer and should not be in this map. + */ + private val draftAnswerMap = mutableMapOf() + + /** + * Callback function to update the view model after the answer(s) to a question have been changed. + * This is passed to the [QuestionnaireViewItem] in its constructor so that it can invoke this + * callback function after the UI widget has updated the answer(s). + * + * This function updates the [QuestionnaireResponse] held in memory using the answer(s) provided + * by the UI. Subsequently it should also trigger the recalculation of any relevant expressions, + * enablement states, and validation results throughout the questionnaire. + * + * This callback function has 4 params: + * - the reference to the [Questionnaire.Item] in the [Questionnaire] + * - the reference to the [QuestionnaireResponse.Item] in the [QuestionnaireResponse] + * - a [List] of [QuestionnaireResponse.Item.Answer] which are the new answers to the question. + * - partial answer, the entered input is not a valid answer + */ + private val answersChangedCallback: + suspend ( + Questionnaire.Item, + QuestionnaireResponse.Item, + List, + Any?, + ) -> Unit = + { questionnaireItem, questionnaireResponseItem, answers, draftAnswer -> + if (draftAnswer != null) { + draftAnswerMap[questionnaireItem] = draftAnswer + } else { + draftAnswerMap.remove(key = questionnaireItem) + } + + (questionnaireResponseItem.answer as? MutableList)?.let { answerList -> + answerList.clear() + answerList.addAll(answers) + } + + if (questionnaireItem.shouldHaveNestedItemsUnderAnswers) { + val questionnaireResponseItemCopy = + questionnaireResponseItem + .toBuilder() + .apply { copyNestedItemsToChildlessAnswers(questionnaireItem) } + .build() + + // Update current questionnaireResponseItem answers with updated nested answers list from + // copy + (questionnaireResponseItem.answer as? MutableList)?.let { + it.clear() + it.addAll(questionnaireResponseItemCopy.answer) + } + + // If nested items are added to the answer, the enablement evaluator needs to be + // reinitialized in order for it to rebuild the pre-order map and parent map of + // questionnaire response items to reflect the new structure of the questionnaire response + // to correctly calculate enable when statements. + enablementEvaluator = + EnablementEvaluator( + questionnaire, + questionnaireResponse.value, + questionnaireItemParentMap, + questionnaireLaunchContextMap, + xFhirQueryResolver, + ) + } + modifiedQuestionnaireResponseItemSet.add(questionnaireResponseItem) + + updateAnswerWithAffectedCalculatedExpression(questionnaireItem) + + modificationCount.update { it + 1 } + } + + private val expressionEvaluator: ExpressionEvaluator = + ExpressionEvaluator( + questionnaire, + questionnaireResponse.value, + questionnaireItemParentMap, + questionnaireLaunchContextMap, + xFhirQueryResolver, + ) + + private var enablementEvaluator: EnablementEvaluator = + EnablementEvaluator( + questionnaire, + questionnaireResponse.value, + questionnaireItemParentMap, + questionnaireLaunchContextMap, + xFhirQueryResolver, + ) + + private val answerOptionsEvaluator: EnabledAnswerOptionsEvaluator = + EnabledAnswerOptionsEvaluator( + questionnaire, + questionnaireResponse.value, + questionnaireItemParentMap, + questionnaireLaunchContextMap, + xFhirQueryResolver, + externalValueSetResolver, + ) + + private val questionnaireResponseItemValidator: QuestionnaireResponseItemValidator = + QuestionnaireResponseItemValidator(expressionEvaluator) + + /** + * Adds empty [QuestionnaireResponse.Item]s to `responseItems` so that each [Questionnaire.Item] + * in `questionnaireItems` has at least one corresponding [QuestionnaireResponse.Item]. This is + * because user-provided [QuestionnaireResponse] might not contain answers to unanswered or + * disabled questions. This function should only be used for unpacked questionnaire. + */ + @VisibleForTesting + internal fun addMissingResponseItems( + questionnaireItems: List, + responseItems: MutableList, + ) { + // To associate the linkId to QuestionnaireResponse.Item, do not use associateBy(). + // Instead, use groupBy(). + // This is because a questionnaire response may have multiple + // QuestionnaireResponseItemComponents with the same linkId. + val responseItemMap = responseItems.groupBy { it.linkId } + + // Clear the response item list, and then add the missing and existing response items back to + // the list + responseItems.clear() + + questionnaireItems.forEach { + if (responseItemMap[it.linkId].isNullOrEmpty()) { + responseItems.add(it.createQuestionnaireResponseItem().build()) + } else { + if ( + it.type.value == Questionnaire.QuestionnaireItemType.Group && it.repeats?.value != true + ) { + addMissingResponseItems( + questionnaireItems = it.item, + responseItems = responseItemMap[it.linkId]!!.single().item.toMutableList(), + ) + } + if ( + it.type.value == Questionnaire.QuestionnaireItemType.Group && it.repeats?.value == true + ) { + responseItemMap[it.linkId]!!.forEach { rItem -> + addMissingResponseItems( + questionnaireItems = it.item, + responseItems = rItem.item.toMutableList(), + ) + } + } + responseItems.addAll(responseItemMap[it.linkId]!!) + } + } + } + + /** + * Returns current [QuestionnaireResponse] captured by the UI which includes answers of enabled + * questions. + */ + suspend fun getQuestionnaireResponse(): QuestionnaireResponse { + return questionnaireResponse.value + .toBuilder() + .apply { + // Use the view model's questionnaire and questionnaire response for calculating enabled + // items + // because the calculation relies on references to the questionnaire response items. + item = + getEnabledResponseItems( + this@QuestionnaireViewModel.questionnaire.item, + questionnaireResponse.value.item, + ) + .toMutableList() + + unpackRepeatedGroups(this@QuestionnaireViewModel.questionnaire) + // Use authored as a submission time stamp + authored = + DateTime.Builder().apply { + value = + FhirDateTime.DateTime( + dateTime = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()), + utcOffset = UtcOffset.ZERO, + ) + } + } + .build() + } + + /** Clears all the answers from the questionnaire response by iterating through each item. */ + fun clearAllAnswers() { + questionnaireResponse.value = + questionnaireResponse.value + .toBuilder() + .apply { this.allItems.map { it.toBuilder().answer.apply { this.clear() } } } + .build() + draftAnswerMap.clear() + modifiedQuestionnaireResponseItemSet.clear() + responseItemToAnswersMapForDisabledQuestionnaireItem.clear() + modificationCount.update { it + 1 } + } + + /** + * Validates entire questionnaire and return the validation results. As a side effect, it triggers + * the UI update to show errors in case there are any validation errors. + */ + internal suspend fun validateQuestionnaireAndUpdateUI(): Map> = + QuestionnaireResponseValidator.validateQuestionnaireResponse( + questionnaire, + questionnaireResponse.value, + questionnaireItemParentMap, + questionnaireLaunchContextMap, + xFhirQueryResolver, + ) + .also { result -> + if (result.values.flatten().filterIsInstance().isNotEmpty()) { + // Update UI of current page if necessary + validateCurrentPageItems {} + } + } + + internal fun goToPreviousPage() { + when (entryMode) { + EntryMode.PRIOR_EDIT, + EntryMode.RANDOM, -> { + val previousPageIndex = + pages!!.indexOfLast { + it.index < currentPageIndexFlow.value!! && it.enabled && !it.hidden + } + check(previousPageIndex != -1) { + "Can't call goToPreviousPage() if no preceding page is enabled" + } + currentPageIndexFlow.value = previousPageIndex + } + else -> { + Logger.w("Previous questions and submitted answers cannot be viewed or edited.") + } + } + } + + internal fun goToNextPage() { + when (entryMode) { + EntryMode.PRIOR_EDIT, + EntryMode.SEQUENTIAL, -> { + validateCurrentPageItems { + val nextPageIndex = + pages!!.indexOfFirst { + it.index > currentPageIndexFlow.value!! && it.enabled && !it.hidden + } + check(nextPageIndex != -1) { "Can't call goToNextPage() if no following page is enabled" } + currentPageIndexFlow.value = nextPageIndex + } + } + EntryMode.RANDOM -> { + val nextPageIndex = + pages!!.indexOfFirst { + it.index > currentPageIndexFlow.value!! && it.enabled && !it.hidden + } + check(nextPageIndex != -1) { "Can't call goToNextPage() if no following page is enabled" } + currentPageIndexFlow.value = nextPageIndex + } + } + } + + internal fun setReviewMode(reviewModeFlag: Boolean) { + if (reviewModeFlag) { + when (entryMode) { + EntryMode.PRIOR_EDIT, + EntryMode.SEQUENTIAL, -> { + validateCurrentPageItems { isInReviewModeFlow.value = true } + } + EntryMode.RANDOM -> { + isInReviewModeFlow.value = true + } + } + } else { + isInReviewModeFlow.value = false + } + } + + internal fun setOnSubmitButtonClickListener(onClickAction: () -> Unit) { + onSubmitButtonClickListener = onClickAction + } + + internal fun setOnCancelButtonClickListener(onClickAction: () -> Unit) { + onCancelButtonClickListener = onClickAction + } + + internal fun setShowSubmitButtonFlag(showSubmitButton: Boolean) { + this.shouldShowSubmitButton = showSubmitButton + } + + internal fun setShowCancelButtonFlag(showCancelButton: Boolean) { + this.shouldShowCancelButton = showCancelButton + } + + /** [QuestionnaireState] to be displayed in the UI. */ + internal val questionnaireStateFlow: StateFlow = + combine(modificationCount, currentPageIndexFlow, isInReviewModeFlow) { _, _, _ -> + getQuestionnaireState() + } + .withIndex() + .onEach { + if (it.index == 0) { + initializeCalculatedExpressions() + modificationCount.update { count -> count + 1 } + } + } + .map { it.value } + .stateIn( + viewModelScope, + SharingStarted.Lazily, + initialValue = + QuestionnaireState( + items = emptyList(), + displayMode = DisplayMode.InitMode, + bottomNavItem = null, + ), + ) + + /** Travers all [calculatedExpression] within a [Questionnaire] and evaluate them. */ + private suspend fun initializeCalculatedExpressions() { + expressionEvaluator.detectExpressionCyclicDependency(questionnaire.item) + questionnaire.forEachItemPair(questionnaireResponse.value) { + questionnaireItem, + questionnaireResponseItem, + -> + if (questionnaireItem.calculatedExpression != null) { + updateAnswerWithCalculatedExpression(questionnaireItem, questionnaireResponseItem) + } + } + } + + /** + * Updates all items that has [calculatedExpression] that reference the given [questionnaireItem] + * within their calculations. + * + * If item X has a [calculatedExpression], but that item does not reference the given + * [questionnaireItem], then item X should not be calculated. + * + * Only items that have not been modified by the user will be updated to prevent any event loops. + * + * @param questionnaireItem The questionnaire item referenced by other items through + * [calculatedExpression]. + */ + private suspend fun updateAnswerWithAffectedCalculatedExpression( + questionnaireItem: Questionnaire.Item, + ) { + expressionEvaluator + .evaluateAllAffectedCalculatedExpressions( + questionnaireItem, + ) + .forEach { (questionnaireItem, calculatedAnswers) -> + // update all response item with updated values + questionnaireResponse.value + .toBuilder() + .allItems + // Item answer should not be modified and touched by user; + // https://build.fhir.org/ig/HL7/sdc/StructureDefinition-sdc-questionnaire-calculatedExpression.html + .filter { + it.linkId == questionnaireItem.linkId && + !modifiedQuestionnaireResponseItemSet.contains(it) + } + .forEach { questionnaireResponseItem -> + // update and notify only if new answer has changed to prevent any event loop + if (questionnaireResponseItem.answer.hasDifferentAnswerSet(calculatedAnswers)) { + questionnaireResponseItem.toBuilder().apply { + answer = + calculatedAnswers.mapNotNullTo(mutableListOf()) { + when (it) { + is FhirR4Boolean -> QuestionnaireResponse.Item.Answer.Value.Boolean(it) + is Decimal -> QuestionnaireResponse.Item.Answer.Value.Decimal(it) + is Integer -> QuestionnaireResponse.Item.Answer.Value.Integer(it) + is FhirR4String -> QuestionnaireResponse.Item.Answer.Value.String(it) + is Coding -> QuestionnaireResponse.Item.Answer.Value.Coding(it) + is Reference -> QuestionnaireResponse.Item.Answer.Value.Reference(it) + is Date -> QuestionnaireResponse.Item.Answer.Value.Date(it) + is DateTime -> QuestionnaireResponse.Item.Answer.Value.DateTime(it) + is Time -> QuestionnaireResponse.Item.Answer.Value.Time(it) + is Uri -> QuestionnaireResponse.Item.Answer.Value.Uri(it) + is Attachment -> QuestionnaireResponse.Item.Answer.Value.Attachment(it) + is Quantity -> QuestionnaireResponse.Item.Answer.Value.Quantity(it) + else -> null + }?.let { item -> + QuestionnaireResponse.Item.Answer.Builder().apply { value = item } + } + } + } + } + } + } + } + + /** + * Updates the answer(s) in the questionnaire response item with the evaluation result of the + * calculated expression if + * - there is a calculated expression in the questionnaire item, and + * - there is no user provided answer to the questionnaire response item (user input should always + * take precedence over calculated answers). + * + * Do nothing, otherwise. + */ + private fun updateAnswerWithCalculatedExpression( + questionnaireItem: Questionnaire.Item, + questionnaireResponseItem: QuestionnaireResponse.Item, + ) { + if (questionnaireItem.calculatedExpression == null) return + if (modifiedQuestionnaireResponseItemSet.contains(questionnaireResponseItem)) return + val answers = expressionEvaluator.evaluateCalculatedExpression(questionnaireItem) + if (answers.isEmpty()) return + if (questionnaireResponseItem.answer.hasDifferentAnswerSet(answers)) { + questionnaireResponseItem.toBuilder().apply { + answer = + answers.mapNotNullTo(mutableListOf()) { + when (it) { + is FhirR4Boolean -> QuestionnaireResponse.Item.Answer.Value.Boolean(it) + is Decimal -> QuestionnaireResponse.Item.Answer.Value.Decimal(it) + is Integer -> QuestionnaireResponse.Item.Answer.Value.Integer(it) + is FhirR4String -> QuestionnaireResponse.Item.Answer.Value.String(it) + is Coding -> QuestionnaireResponse.Item.Answer.Value.Coding(it) + is Reference -> QuestionnaireResponse.Item.Answer.Value.Reference(it) + is Date -> QuestionnaireResponse.Item.Answer.Value.Date(it) + is DateTime -> QuestionnaireResponse.Item.Answer.Value.DateTime(it) + is Time -> QuestionnaireResponse.Item.Answer.Value.Time(it) + is Uri -> QuestionnaireResponse.Item.Answer.Value.Uri(it) + is Attachment -> QuestionnaireResponse.Item.Answer.Value.Attachment(it) + is Quantity -> QuestionnaireResponse.Item.Answer.Value.Quantity(it) + else -> null + }?.let { item -> QuestionnaireResponse.Item.Answer.Builder().apply { value = item } } + } + } + } + } + + private fun removeDisabledAnswers( + questionnaireItem: Questionnaire.Item, + questionnaireResponseItem: QuestionnaireResponse.Item, + disabledAnswers: List, + ) { + val validAnswers = + questionnaireResponseItem.answer.filterNot { ans -> + disabledAnswers.any { ans.value === it.value } + } + viewModelScope.launch { + answersChangedCallback(questionnaireItem, questionnaireResponseItem, validAnswers, null) + } + } + + /** + * Traverses through the list of questionnaire items, the list of questionnaire response items and + * the list of items in the questionnaire response answer list and populates + * [questionnaireStateFlow] with matching pairs of questionnaire item and questionnaire response + * item. + * + * The traverse is carried out in the two lists in tandem. + */ + private suspend fun getQuestionnaireState(): QuestionnaireState { + val questionnaireItemList = questionnaire.item + val questionnaireResponseItemList = questionnaireResponse.value.item + + // Only display items on the current page while editing a paginated questionnaire, otherwise, + // display all items. + val questionnaireItemViewItems = + if (!isReadOnly && !isInReviewModeFlow.value && questionnaire.isPaginated) { + pages = getQuestionnairePages() + if (currentPageIndexFlow.value == null) { + currentPageIndexFlow.value = pages!!.first { it.enabled && !it.hidden }.index + } + getQuestionnaireAdapterItems( + questionnaireItemList[currentPageIndexFlow.value!!], + questionnaireResponseItemList[currentPageIndexFlow.value!!], + ) + } else { + getQuestionnaireAdapterItems(questionnaireItemList, questionnaireResponseItemList) + } + + // Reviewing the questionnaire or the questionnaire is read-only + if (isReadOnly || isInReviewModeFlow.value) { + val showSubmitButton = !isReadOnly && shouldShowSubmitButton + val bottomNavigationViewState = + QuestionnaireNavigationUIState( + navSubmit = + if (showSubmitButton) { + QuestionnaireNavigationViewUIState.Enabled( + submitButtonText.ifEmpty { getString(Res.string.submit_questionnaire) }, + onSubmitButtonClickListener, + ) + } else { + QuestionnaireNavigationViewUIState.Hidden + }, + navCancel = + if (!isReadOnly && shouldShowCancelButton) { + QuestionnaireNavigationViewUIState.Enabled( + onClickAction = onCancelButtonClickListener, + ) + } else { + QuestionnaireNavigationViewUIState.Hidden + }, + ) + val bottomNavigation = QuestionnaireAdapterItem.Navigation(bottomNavigationViewState) + + return QuestionnaireState( + items = + if (shouldSetNavigationInLongScroll) { + questionnaireItemViewItems + bottomNavigation + } else { + questionnaireItemViewItems + }, + displayMode = + DisplayMode.ReviewMode( + showEditButton = !isReadOnly, + showNavAsScroll = shouldSetNavigationInLongScroll, + ), + bottomNavItem = if (!shouldSetNavigationInLongScroll) bottomNavigation else null, + ) + } + + val showReviewButton: Boolean + val showSubmitButton: Boolean + val showCancelButton: Boolean + // Editing the questionnaire + val questionnairePagination = + if (!questionnaire.isPaginated) { + showReviewButton = shouldEnableReviewPage && !isInReviewModeFlow.value + showSubmitButton = shouldShowSubmitButton && !showReviewButton + showCancelButton = shouldShowCancelButton && !showReviewButton + QuestionnairePagination( + false, + emptyList(), + -1, + ) + } else { + val hasNextPage = + QuestionnairePagination(pages = pages!!, currentPageIndex = currentPageIndexFlow.value!!) + .hasNextPage + showReviewButton = shouldEnableReviewPage && !hasNextPage + showSubmitButton = shouldShowSubmitButton && !showReviewButton && !hasNextPage + showCancelButton = shouldShowCancelButton + QuestionnairePagination( + true, + pages!!, + currentPageIndexFlow.value!!, + ) + } + + val bottomNavigationUiViewState = + QuestionnaireNavigationUIState( + navPrevious = + when { + questionnairePagination.isPaginated && questionnairePagination.hasPreviousPage -> { + QuestionnaireNavigationViewUIState.Enabled { goToPreviousPage() } + } + else -> { + QuestionnaireNavigationViewUIState.Hidden + } + }, + navNext = + when { + questionnairePagination.isPaginated && questionnairePagination.hasNextPage -> { + QuestionnaireNavigationViewUIState.Enabled { goToNextPage() } + } + else -> { + QuestionnaireNavigationViewUIState.Hidden + } + }, + navSubmit = + if (showSubmitButton) { + QuestionnaireNavigationViewUIState.Enabled( + submitButtonText.ifEmpty { getString(Res.string.submit_questionnaire) }, + onSubmitButtonClickListener, + ) + } else { + QuestionnaireNavigationViewUIState.Hidden + }, + navReview = + if (showReviewButton) { + QuestionnaireNavigationViewUIState.Enabled { setReviewMode(true) } + } else { + QuestionnaireNavigationViewUIState.Hidden + }, + navCancel = + if (showCancelButton) { + QuestionnaireNavigationViewUIState.Enabled(onClickAction = onCancelButtonClickListener) + } else { + QuestionnaireNavigationViewUIState.Hidden + }, + ) + val bottomNavigation = QuestionnaireAdapterItem.Navigation(bottomNavigationUiViewState) + + return QuestionnaireState( + items = + if (shouldSetNavigationInLongScroll) { + questionnaireItemViewItems + bottomNavigation + } else { + questionnaireItemViewItems + }, + displayMode = DisplayMode.EditMode(questionnairePagination, shouldSetNavigationInLongScroll), + bottomNavItem = if (!shouldSetNavigationInLongScroll) bottomNavigation else null, + ) + } + + /** + * Returns the list of [QuestionnaireViewItem]s generated for the questionnaire items and + * questionnaire response items. + */ + private suspend fun getQuestionnaireAdapterItems( + questionnaireItemList: List, + questionnaireResponseItemList: List, + parentIdPrefix: String = "", + ): List { + return questionnaireItemList + .zipByLinkId(questionnaireResponseItemList) { questionnaireItem, questionnaireResponseItem -> + getQuestionnaireAdapterItems(questionnaireItem, questionnaireResponseItem, parentIdPrefix) + } + .flatten() + } + + /** + * Returns the list of [QuestionnaireViewItem]s generated for the questionnaire item and + * questionnaire response item. + */ + private suspend fun getQuestionnaireAdapterItems( + questionnaireItem: Questionnaire.Item, + questionnaireResponseItem: QuestionnaireResponse.Item, + parentIdPrefix: String = "", + ): List { + // Hidden questions should not get QuestionnaireItemViewItem instances + if (questionnaireItem.isHidden) return emptyList() + val enabled = + enablementEvaluator.evaluate( + questionnaireItem, + questionnaireResponseItem, + ) + // // Disabled questions should not get QuestionnaireItemViewItem instances + if (!enabled) { + cacheDisabledQuestionnaireItemAnswers(questionnaireResponseItem) + return emptyList() + } + + restoreFromDisabledQuestionnaireItemAnswersCache(questionnaireResponseItem) + + // Determine the validation result, which will be displayed on the item itself + val validationResult = + if ( + modifiedQuestionnaireResponseItemSet.contains(questionnaireResponseItem) || + isInReviewModeFlow.value + ) { + questionnaireResponseItemValidator.validate( + questionnaireItem, + questionnaireResponseItem, + ) + } else { + NotValidated + } + + // Set question text dynamically from CQL expression + val cqfDynamicQuestionnaireText = + questionnaireItem.text + ?.cqfExpression + ?.let { expressionEvaluator.evaluateExpressionValue(it) } + ?.takeIf { it.isNotEmpty() } + ?.let { convertToString(it) } + val evaluatedQuestionnaireResponseItem = + cqfDynamicQuestionnaireText?.let { + questionnaireResponseItem.copy(text = FhirR4String(value = it)) + } + ?: questionnaireResponseItem + + val (enabledQuestionnaireAnswerOptions, disabledQuestionnaireResponseAnswers) = + answerOptionsEvaluator.evaluate( + questionnaireItem, + evaluatedQuestionnaireResponseItem, + ) + if (disabledQuestionnaireResponseAnswers.isNotEmpty()) { + removeDisabledAnswers( + questionnaireItem, + evaluatedQuestionnaireResponseItem, + disabledQuestionnaireResponseAnswers, + ) + } + + val items = buildList { + val itemHelpCard = questionnaireItem.item.firstOrNull { it.isHelpCode } + val isHelpCard = itemHelpCard != null + val isHelpCardOpen = openedHelpCardSet.contains(evaluatedQuestionnaireResponseItem) + // Add an item for the question itself + + val question = + QuestionnaireAdapterItem.Question( + QuestionnaireViewItem( + questionnaireItem, + evaluatedQuestionnaireResponseItem, + validationResult = validationResult, + answersChangedCallback = answersChangedCallback, + enabledAnswerOptions = enabledQuestionnaireAnswerOptions, + minAnswerValue = + questionnaireItem.minValueCqfCalculatedValueExpression?.let { + expressionEvaluator.evaluateExpressionValue(it)?.singleOrNull() + as Extension.Value? + }, + maxAnswerValue = + questionnaireItem.maxValueCqfCalculatedValueExpression?.let { + expressionEvaluator.evaluateExpressionValue(it)?.singleOrNull() + as Extension.Value? + }, + draftAnswer = draftAnswerMap[questionnaireItem], + enabledDisplayItems = + questionnaireItem.item.filter { + it.isDisplayItem && + enablementEvaluator.evaluate( + it, + evaluatedQuestionnaireResponseItem, + ) + }, + questionViewTextConfiguration = + QuestionTextConfiguration( + showAsterisk = showAsterisk, + showRequiredText = showRequiredText, + showOptionalText = showOptionalText, + ), + isHelpCardOpen = isHelpCard && isHelpCardOpen, + helpCardStateChangedCallback = helpCardStateChangedCallback, + ), + ) + .apply { + if (parentIdPrefix.isNotEmpty()) { + id = "${parentIdPrefix}${questionnaireItem.linkId}" + } + } + add(question) + + // Add nested questions after the parent item. We need to get the questionnaire items and + // (possibly multiple sets of) matching questionnaire response items and generate the adapter + // items. There are three different cases: + // 1. Questions nested under a non-repeated group: Simply take the nested question items and + // the nested question response items and "zip" them. + // 2. Questions nested under a question: In this case, the nested questions are repeated for + // each answer to the parent question. Therefore, we need to take the questions and lists of + // questionnaire response items nested under each answer and generate multiple sets of adapter + // items. + // 3. Questions nested under a repeated group: In the in-memory questionnaire response in the + // view model, we create dummy answers for each repeated group. As a result the processing of + // this case is similar to the case of questions nested under a question. + // For background, see https://build.fhir.org/questionnaireresponse.html#link. + + // Case 1: Non-repeated group - process nested items directly with current prefix + if ( + !questionnaireItem.isRepeatedGroup && evaluatedQuestionnaireResponseItem.item.isNotEmpty() + ) { + addAll( + getQuestionnaireAdapterItems( + questionnaireItemList = questionnaireItem.item.filterNot { it.isDisplayItem }, + questionnaireResponseItemList = evaluatedQuestionnaireResponseItem.item, + parentIdPrefix = parentIdPrefix, + ), + ) + } + + // Case 2 and 3: Questions nested under answers (for questions with nested items or repeated + // groups) + evaluatedQuestionnaireResponseItem.answer + .map { it.item } + .forEachIndexed { index, nestedResponseItemList -> + val currentIdPrefix = + if (!questionnaireItem.isRepeatedGroup) { + // Case 2: Questions nested under a question (not a repeated group) + if (parentIdPrefix.isEmpty()) { + "${index}_${question.item.questionnaireItem.linkId}_" + } else { + "${parentIdPrefix}${index}_${question.item.questionnaireItem.linkId}_" + } + } else { + // Case 3: Build hierarchical ID prefix for nested repeated groups + "${parentIdPrefix}${index}_${question.item.questionnaireItem.linkId}_" + } + + if (questionnaireItem.isRepeatedGroup) { + // Case 3 + add( + QuestionnaireAdapterItem.RepeatedGroupHeader( + id = "${parentIdPrefix}${index}_${question.item.questionnaireItem.linkId}", + index = index, + onDeleteClicked = { viewModelScope.launch { question.item.removeAnswerAt(index) } }, + responses = nestedResponseItemList, + title = question.item.questionText?.toString() ?: "", + ), + ) + } + addAll( + getQuestionnaireAdapterItems( + // If nested display item is identified as instructions or flyover, then do not + // create questionnaire state for it. + questionnaireItemList = questionnaireItem.item.filterNot { it.isDisplayItem }, + questionnaireResponseItemList = nestedResponseItemList, + parentIdPrefix = currentIdPrefix, + ), + ) + } + + if (questionnaireItem.isRepeatedGroup) { + add( + QuestionnaireAdapterItem.RepeatedGroupAddButton( + id = "${parentIdPrefix}${question.item.questionnaireItem.linkId}_add_btn", + item = question.item, + ), + ) + } + } + currentPageItems = items + return items + } + + /** + * If the item is not enabled, clear the answers that it may have from the previous enabled state. + * This will also prevent any questionnaire item that depends on the answer of this questionnaire + * item to be wrongly evaluated as well. + */ + private fun cacheDisabledQuestionnaireItemAnswers( + questionnaireResponseItem: QuestionnaireResponse.Item, + ) { + if (questionnaireResponseItem.answer.isNotEmpty()) { + responseItemToAnswersMapForDisabledQuestionnaireItem[questionnaireResponseItem] = + questionnaireResponseItem.answer + questionnaireResponseItem.toBuilder().apply { answer = mutableListOf() } + } + } + + /** + * If the questionnaire item was previously disabled, check the cache to restore previous answers. + */ + private fun restoreFromDisabledQuestionnaireItemAnswersCache( + questionnaireResponseItem: QuestionnaireResponse.Item, + ) { + if (responseItemToAnswersMapForDisabledQuestionnaireItem.contains(questionnaireResponseItem)) { + questionnaireResponseItem.toBuilder().apply { + answer = + responseItemToAnswersMapForDisabledQuestionnaireItem + .remove(questionnaireResponseItem) + ?.map { it.toBuilder() } + ?.toMutableList() + ?: mutableListOf() + } + } + } + + private suspend fun getEnabledResponseItems( + questionnaireItemList: List, + questionnaireResponseItemList: List, + ): List { + val responseItemKeys = questionnaireResponseItemList.map { it.linkId } + val result = mutableListOf() + + for ((questionnaireItem, questionnaireResponseItem) in + questionnaireItemList.zip(questionnaireResponseItemList)) { + if ( + responseItemKeys.contains(questionnaireItem.linkId) && + enablementEvaluator.evaluate(questionnaireItem, questionnaireResponseItem) + ) { + questionnaireResponseItem.toBuilder().apply { + if (text?.value.isNullOrBlank()) { + text = + text.apply { + this?.value = questionnaireItem.localizedTextAnnotatedString?.toString() + } + } + // Nested group items + item = + getEnabledResponseItems( + questionnaireItem.item, + questionnaireResponseItem.item, + ) + .toMutableList() + + // Nested question items + answer.forEach { + it.item = + getEnabledResponseItems( + questionnaireItem.item, + it.item.map { itemBuilder -> itemBuilder.build() }, + ) + .toMutableList() + } + } + result.add(questionnaireResponseItem.toBuilder()) + } + } + return result + } + + /** + * Gets a list of [QuestionnairePage]s for a paginated questionnaire, or `null` if the + * questionnaire is not paginated. + */ + private suspend fun getQuestionnairePages(): List? = + if (questionnaire.isPaginated) { + questionnaire.item.zip(questionnaireResponse.value.item).mapIndexed { + index, + (questionnaireItem, questionnaireResponseItem), + -> + QuestionnairePage( + index, + enablementEvaluator.evaluate( + questionnaireItem, + questionnaireResponseItem, + ), + questionnaireItem.isHidden, + ) + } + } else { + null + } + + /** + * Validates the current page items if any are [NotValidated], and then, invokes [block] if they + * are all [Valid]. + */ + private fun validateCurrentPageItems(block: () -> Unit) { + if ( + currentPageItems.filterIsInstance().any { + it.item.validationResult is NotValidated + } + ) { + // Add all items on the current page to modifiedQuestionnaireResponseItemSet. + // This will ensure that all fields are validated even when they're not filled by the user + currentPageItems.filterIsInstance().forEach { + modifiedQuestionnaireResponseItemSet.add(it.item.getQuestionnaireResponseItem()) + } + // Results in a new questionnaire state being generated synchronously, i.e., the current + // thread will be suspended until the new state is generated. + modificationCount.update { it + 1 } + } + + if ( + currentPageItems.filterIsInstance().all { + it.item.validationResult is Valid + } + ) { + block() + } + } + + fun readFileContent(filePath: String): String { + val path = Path(filePath) // Create a Path object from the file path + return if (SystemFileSystem.exists(path)) { + SystemFileSystem.source(path).use { source -> source.buffered().readString() } + } else { + Logger.e("File not found at: $filePath") + throw Error("File not found at $filePath") + } + } +} + +typealias ItemToParentMap = MutableMap + +/** Questionnaire state for the Fragment to consume. */ +internal data class QuestionnaireState( + val items: List, + val displayMode: DisplayMode, + val bottomNavItem: QuestionnaireAdapterItem.Navigation?, +) + +internal sealed class DisplayMode { + class EditMode(val pagination: QuestionnairePagination, val showNavAsScroll: Boolean) : + DisplayMode() + + data class ReviewMode( + val showEditButton: Boolean, + val showNavAsScroll: Boolean, + ) : DisplayMode() + + // Sentinel displayMode that's used in setting the initial default QuestionnaireState + object InitMode : DisplayMode() +} + +/** + * Pagination information of the questionnaire. This is used for the UI to render pagination + * controls. Includes information for each page and the current page index. + */ +internal data class QuestionnairePagination( + val isPaginated: Boolean = false, + val pages: List, + val currentPageIndex: Int, +) + +/** A single page in the questionnaire. This is used for the UI to render pagination controls. */ +internal data class QuestionnairePage( + val index: Int, + val enabled: Boolean, + val hidden: Boolean, +) + +internal val QuestionnairePagination.hasPreviousPage: Boolean + get() = pages.any { it.index < currentPageIndex && it.enabled && !it.hidden } + +internal val QuestionnairePagination.hasNextPage: Boolean + get() = pages.any { it.index > currentPageIndex && it.enabled && !it.hidden } diff --git a/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/enablement/EnablementEvaluator.kt b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/enablement/EnablementEvaluator.kt new file mode 100644 index 000000000..51a226076 --- /dev/null +++ b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/enablement/EnablementEvaluator.kt @@ -0,0 +1,298 @@ +/* + * Copyright 2022-2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.ohs.fhir.datacapture.enablement + +import com.google.fhir.model.r4.Questionnaire +import com.google.fhir.model.r4.QuestionnaireResponse +import com.google.fhir.model.r4.Resource +import dev.ohs.fhir.datacapture.XFhirQueryResolver +import dev.ohs.fhir.datacapture.extensions.enableWhenExpression +import dev.ohs.fhir.datacapture.fhirpath.ExpressionEvaluator +import dev.ohs.fhir.datacapture.fhirpath.convertToBoolean + +/** + * Evaluator for the enablement status of a [Questionnaire.Item]. + * + * This is done by locating the relevant [QuestionnaireResponse.Item]s specified by the linkIds in + * the `enableWhen` constraints, and checking if the answers (or lack thereof) satisfy the criteria + * in the `enableWhen` constraints. The `enableBehavior` value is then used to combine the + * evaluation results of different `enableWhen` constraints. + * + * For example, the following `enableWhen` constraint in a [Questionnaire.Item] + * + * ``` + * "enableWhen": [ + * { + * "question": "vitaminKgiven", + * "operator": "exists", + * "answerBoolean": true + * } + * ], + * ``` + * + * specifies that the [Questionnaire.Item] should be enabled only if the question with linkId + * `vitaminKgiven` has been answered. + * + * The enablement status typically determines whether the [Questionnaire.Item] is shown or hidden. + * However, it is also possible that only user interaction is enabled or disabled (e.g. grayed out) + * with the [Questionnaire.Item] always shown. + * + * The evaluator works in the context of a Questionnaire and the corresponding + * QuestionnaireResponse. It is the caller's responsibility to make sure to call the evaluator with + * QuestionnaireItems and QuestionnaireResponseItems that belong to the Questionnaire and the + * QuestionnaireResponse. + * + * For more information see + * [Questionnaire.item.enableWhen](https://www.hl7.org/fhir/questionnaire-definitions.html#Questionnaire.item.enableWhen) + * and + * [Questionnaire.item.enableBehavior](https://www.hl7.org/fhir/questionnaire-definitions.html#Questionnaire.item.enableBehavior) + * . + * + * @param questionnaire the [Questionnaire] where the expression belong to + * @param questionnaireResponse the [QuestionnaireResponse] related to the [Questionnaire] + * @param questionnaireItemParentMap the [Map] of items parent + * @param questionnaireLaunchContextMap the [Map] of launchContext names to their resource values + * @param xFhirQueryResolver the [XFhirQueryResolver] used to resolve FHIR queries + */ +internal class EnablementEvaluator( + private val questionnaire: Questionnaire, + private val questionnaireResponse: QuestionnaireResponse, + private val questionnaireItemParentMap: Map = emptyMap(), + private val questionnaireLaunchContextMap: Map? = emptyMap(), + private val xFhirQueryResolver: XFhirQueryResolver? = null, +) { + + private val expressionEvaluator = + ExpressionEvaluator( + questionnaire, + questionnaireResponse, + questionnaireItemParentMap, + questionnaireLaunchContextMap, + xFhirQueryResolver, + ) + + /** + * The pre-order traversal trace of the items in the [QuestionnaireResponse]. This essentially + * represents the order in which all items are displayed in the UI. + */ + private val questionnaireResponseItemPreOrderList = mutableListOf() + + /** The map from each item in the [QuestionnaireResponse] to its parent. */ + private val questionnaireResponseItemParentMap = + mutableMapOf< + QuestionnaireResponse.Item, + QuestionnaireResponse.Item, + >() + + init { + /** Adds each child-parent pair in the [QuestionnaireResponse] to the parent map. */ + fun buildParentList(item: QuestionnaireResponse.Item) { + questionnaireResponseItemPreOrderList.add(item) + for (child in item.item) { + questionnaireResponseItemParentMap[child] = item + buildParentList(child) + } + for (answer in item.answer) { + for (nestedItem in answer.item) { + questionnaireResponseItemParentMap[nestedItem] = item + buildParentList(nestedItem) + } + } + } + + for (item in questionnaireResponse.item) { + buildParentList(item) + } + } + + /** + * Returns whether [questionnaireItem] should be enabled. + * + * @param questionnaireItem the corresponding questionnaire item. + * @param questionnaireResponseItem the corresponding questionnaire response item. + */ + fun evaluate( + questionnaireItem: Questionnaire.Item, + questionnaireResponseItem: QuestionnaireResponse.Item, + ): Boolean { + val enableWhenList = questionnaireItem.enableWhen + val enableWhenExpression = questionnaireItem.enableWhenExpression + + // The questionnaire item is enabled by default if there is no `enableWhen` constraint and no + // `enableWhenExpression`. + if (enableWhenList.isEmpty() && enableWhenExpression == null) return true + + // Evaluate `enableWhenExpression`. + if (enableWhenExpression != null) { + val variables = + mutableMapOf().apply { + put("resource", questionnaireResponse) + put("context", questionnaireResponseItem) + put("questionnaire", questionnaire) + put("qItem", questionnaireItem) + questionnaireLaunchContextMap?.let { putAll(it) } + } + return convertToBoolean( + expressionEvaluator.evaluateExpression( + questionnaireItem.enableWhenExpression!!, + variables, + ), + ) + } + + // Evaluate single `enableWhen` constraint. + if (enableWhenList.size == 1) { + return evaluateEnableWhen( + enableWhenList.single(), + questionnaireItem, + questionnaireResponseItem, + ) + } + + // Evaluate multiple `enableWhen` constraints and aggregate the results according to + // `enableBehavior` which specifies one of the two behaviors: 1) the questionnaire item is + // enabled if ALL `enableWhen` constraints are satisfied, or 2) the questionnaire item is + // enabled if ANY `enableWhen` constraint is satisfied. + return when (val value = questionnaireItem.enableBehavior?.value) { + Questionnaire.EnableWhenBehavior.All -> + enableWhenList.all { evaluateEnableWhen(it, questionnaireItem, questionnaireResponseItem) } + Questionnaire.EnableWhenBehavior.Any -> + enableWhenList.any { evaluateEnableWhen(it, questionnaireItem, questionnaireResponseItem) } + else -> throw IllegalStateException("Unrecognized enable when behavior $value") + } + } + + /** + * Returns whether the `enableWhen` constraint is satisfied for the `questionnaireResponseItem`. + */ + private fun evaluateEnableWhen( + enableWhen: Questionnaire.Item.EnableWhen, + questionnaireItem: Questionnaire.Item, + questionnaireResponseItem: QuestionnaireResponse.Item, + ): Boolean { + val targetQuestionnaireResponseItem: QuestionnaireResponse.Item? = + if ( + questionnaireItem.type.value == Questionnaire.QuestionnaireItemType.Display && + questionnaireResponseItem.linkId == enableWhen.question + ) { + questionnaireResponseItem + } else { + enableWhen.question.value?.let { + findEnableWhenQuestionnaireResponseItem(questionnaireResponseItem, it) + } + } + + if (targetQuestionnaireResponseItem == null) { + return false + } + + return if (Questionnaire.QuestionnaireItemOperator.Exists == enableWhen.operator.value) { + // True iff the answer value of the enable when is equal to whether an answer exists in the + // target questionnaire response item + enableWhen.answer.asBoolean()?.value?.value == + !targetQuestionnaireResponseItem.answer.isEmpty() + } else { + // The `enableWhen` constraint evaluates to true if at least one answer has a value that + // satisfies the `enableWhen` operator and answer, with the exception of the `Exists` + // operator. + // See https://www.hl7.org/fhir/valueset-questionnaire-enable-operator.html. + targetQuestionnaireResponseItem.answer.any { enableWhen.predicate(it) } + } + } + + /** + * Find a questionnaire response item in [QuestionnaireResponse] with the given `linkId` starting + * from the `origin`. + * + * This is used by the enableWhen logic to evaluate if a question should be enabled/displayed. + * + * If multiple questionnaire response items are present for the same question (same linkId), + * either as a result of repeated group or nested question under repeated answers, this returns + * the nearest question occurrence reachable by tracing first the "ancestor" axis and then the + * "preceding" axis and then the "following" axis. + * + * See + * https://www.hl7.org/fhir/questionnaire-definitions.html#Questionnaire.item.enableWhen.question. + */ + private fun findEnableWhenQuestionnaireResponseItem( + origin: QuestionnaireResponse.Item, + linkId: String, + ): QuestionnaireResponse.Item? { + // Find the nearest ancestor with the linkId + var parent = questionnaireResponseItemParentMap[origin] + while (parent != null) { + if (parent.linkId.value == linkId) { + return parent + } + parent = questionnaireResponseItemParentMap[parent] + } + + // Find the nearest item preceding the origin + val itemIndex = questionnaireResponseItemPreOrderList.indexOf(origin) + for (index in itemIndex - 1 downTo 0) { + if (questionnaireResponseItemPreOrderList[index].linkId.value == linkId) { + return questionnaireResponseItemPreOrderList[index] + } + } + + // Find the nearest item succeeding the origin + for (index in itemIndex + 1 until questionnaireResponseItemPreOrderList.size) { + if (questionnaireResponseItemPreOrderList[index].linkId.value == linkId) { + return questionnaireResponseItemPreOrderList[index] + } + } + + return null + } +} + +/** + * The predicate to evaluate the status of the enableWhen on the `EnableWhen` `operator` and + * `Answer` value. + */ +private val Questionnaire.Item.EnableWhen.predicate: (QuestionnaireResponse.Item.Answer) -> Boolean + get() = { + when (operator.value) { + Questionnaire.QuestionnaireItemOperator.EqualTo -> + it.value equalsFhirValue answer.getActualValue() + Questionnaire.QuestionnaireItemOperator.NotEqualTo -> + !(it.value equalsFhirValue answer.getActualValue()) + Questionnaire.QuestionnaireItemOperator.GreaterThan -> + (it.value compareFhirValue answer.getActualValue()) > 0 + Questionnaire.QuestionnaireItemOperator.GreaterThanOrEqualTo -> + it.value compareFhirValue answer.getActualValue() >= 0 + Questionnaire.QuestionnaireItemOperator.LessThan -> + (it.value compareFhirValue answer.getActualValue()) < 0 + Questionnaire.QuestionnaireItemOperator.LessThanOrEqualTo -> + (it.value compareFhirValue answer.getActualValue()) <= 0 + else -> throw NotImplementedError("Enable when operator $operator is not implemented.") + } + } + +private fun Questionnaire.Item.EnableWhen.Answer.getActualValue(): Any = + when (this) { + is Questionnaire.Item.EnableWhen.Answer.Boolean -> this.value + is Questionnaire.Item.EnableWhen.Answer.Coding -> this.value + is Questionnaire.Item.EnableWhen.Answer.Date -> this.value + is Questionnaire.Item.EnableWhen.Answer.DateTime -> this.value + is Questionnaire.Item.EnableWhen.Answer.Decimal -> this.value + is Questionnaire.Item.EnableWhen.Answer.Integer -> this.value + is Questionnaire.Item.EnableWhen.Answer.Quantity -> this.value + is Questionnaire.Item.EnableWhen.Answer.Reference -> this.value + is Questionnaire.Item.EnableWhen.Answer.String -> this.value + is Questionnaire.Item.EnableWhen.Answer.Time -> this.value + } diff --git a/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/enablement/FhirValueComparators.kt b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/enablement/FhirValueComparators.kt new file mode 100644 index 000000000..e6c8394e9 --- /dev/null +++ b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/enablement/FhirValueComparators.kt @@ -0,0 +1,185 @@ +/* + * Copyright 2025-2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.ohs.fhir.datacapture.enablement + +import com.google.fhir.model.r4.Attachment +import com.google.fhir.model.r4.Coding +import com.google.fhir.model.r4.Date +import com.google.fhir.model.r4.DateTime +import com.google.fhir.model.r4.Decimal +import com.google.fhir.model.r4.Extension +import com.google.fhir.model.r4.Integer +import com.google.fhir.model.r4.Quantity +import com.google.fhir.model.r4.QuestionnaireResponse +import com.google.fhir.model.r4.Reference +import com.google.fhir.model.r4.Time +import com.google.fhir.model.r4.Uri +import dev.ohs.fhir.datacapture.extensions.FhirR4Boolean +import dev.ohs.fhir.datacapture.extensions.FhirR4Decimal +import dev.ohs.fhir.datacapture.extensions.FhirR4String + +/** + * Compares a [QuestionnaireResponse.Item.Answer.Value] with [Any] value for equality check. Returns + * false if both values are null. + */ +infix fun QuestionnaireResponse.Item.Answer.Value?.equalsFhirValue( + other: Any?, +): Boolean { + if (this == other) return true + if (this == null || other == null) return false + + val otherValue = + if (other is Extension.Value) { + when (other) { + is Extension.Value.Boolean -> other.asBoolean()?.value + is Extension.Value.Decimal -> other.asDecimal()?.value + is Extension.Value.Integer -> other.asInteger()?.value + is Extension.Value.Date -> other.asDate()?.value + is Extension.Value.DateTime -> other.asDateTime()?.value + is Extension.Value.Time -> other.asTime()?.value + is Extension.Value.String -> other.asString()?.value + is Extension.Value.Uri -> other.asUri()?.value + is Extension.Value.Coding -> other.asCoding()?.value + is Extension.Value.Quantity -> other.asQuantity()?.value + is Extension.Value.Reference -> other.asReference()?.value + else -> null + } + } else { + other + } + + if (otherValue == null) return false + + return when (this) { + is QuestionnaireResponse.Item.Answer.Value.Boolean -> + otherValue is FhirR4Boolean && this.value.value == otherValue.value + is QuestionnaireResponse.Item.Answer.Value.Decimal -> + otherValue is FhirR4Decimal && this.value.value == otherValue.value + is QuestionnaireResponse.Item.Answer.Value.Integer -> + otherValue is Integer && this.value.value == otherValue.value + is QuestionnaireResponse.Item.Answer.Value.Date -> + otherValue is Date && this.value.value == otherValue.value + is QuestionnaireResponse.Item.Answer.Value.DateTime -> + otherValue is DateTime && this.value.value == otherValue.value + is QuestionnaireResponse.Item.Answer.Value.Time -> + otherValue is Time && this.value.value == otherValue.value + is QuestionnaireResponse.Item.Answer.Value.String -> + otherValue is FhirR4String && this.value.value == otherValue.value + is QuestionnaireResponse.Item.Answer.Value.Coding -> + otherValue is Coding && + this.value.system?.value == otherValue.system?.value && + this.value.code?.value == otherValue.code?.value + is QuestionnaireResponse.Item.Answer.Value.Quantity -> + otherValue is Quantity && this.value.value?.value == otherValue.value?.value + is QuestionnaireResponse.Item.Answer.Value.Uri -> + otherValue is Uri && this.value.value == otherValue.value + is QuestionnaireResponse.Item.Answer.Value.Attachment -> + otherValue is Attachment && this.value == otherValue + is QuestionnaireResponse.Item.Answer.Value.Reference -> + otherValue is Reference && this.value.reference?.value == otherValue.reference?.value + } +} + +/** + * Compares a [QuestionnaireResponse.Item.Answer.Value] with [Any] value for ordering. Returns + * negative if this < other, 0 if equal, positive if this > other. + */ +infix fun QuestionnaireResponse.Item.Answer.Value?.compareFhirValue( + other: Any?, +): Int { + if (this == other) return 0 + + val otherValue = + if (other is Extension.Value) { + when (other) { + is Extension.Value.Decimal -> other.asDecimal()?.value + is Extension.Value.Integer -> other.asInteger()?.value + is Extension.Value.Date -> other.asDate()?.value + is Extension.Value.DateTime -> other.asDateTime()?.value + is Extension.Value.Time -> other.asTime()?.value + is Extension.Value.String -> other.asString()?.value + is Extension.Value.Quantity -> other.asQuantity()?.value + else -> null + } + } else { + other + } + + if (otherValue == null) return 0 + + return when (this) { + is QuestionnaireResponse.Item.Answer.Value.Decimal -> { + if (otherValue !is Decimal) { + throw IllegalStateException("The other value is not of type decimal") + } + val thisVal = this.value.value!! + val otherVal = otherValue.value!! + thisVal.compareTo(otherVal) + } + is QuestionnaireResponse.Item.Answer.Value.Integer -> { + if (otherValue !is Integer) { + throw IllegalStateException( + "The other value is not of type integer. Actual type: ${otherValue::class}", + ) + } + val thisVal = this.value.value!! + val otherVal = otherValue.value!! + thisVal.compareTo(otherVal) + } + is QuestionnaireResponse.Item.Answer.Value.Date -> { + if (otherValue !is Date) { + throw IllegalStateException("The other value is not of type date") + } + val thisVal = this.value.value!!.toString() + val otherVal = otherValue.value!!.toString() + thisVal.compareTo(otherVal) + } + is QuestionnaireResponse.Item.Answer.Value.DateTime -> { + if (otherValue !is DateTime) { + throw IllegalStateException("The other value is not of type datetime") + } + val thisVal = this.value.value!!.toString() + val otherVal = otherValue.value!!.toString() + thisVal.compareTo(otherVal) + } + is QuestionnaireResponse.Item.Answer.Value.Time -> { + if (otherValue !is Time) { + throw IllegalStateException("The other value is not of type time") + } + val thisVal = this.value.value!! + val otherVal = otherValue.value!! + thisVal.compareTo(otherVal) + } + is QuestionnaireResponse.Item.Answer.Value.String -> { + if (otherValue !is FhirR4String) { + throw IllegalStateException("The other value is not of type string") + } + val thisVal = this.value.value!! + val otherVal = otherValue.value!! + thisVal.compareTo(otherVal) + } + is QuestionnaireResponse.Item.Answer.Value.Quantity -> { + if (otherValue !is Quantity) { + throw IllegalStateException("The other value is not of type quantity") + } + val thisVal = this.value.value!!.value!! + val otherVal = otherValue.value!!.value!! + thisVal.compareTo(otherVal) + } + else -> throw IllegalStateException("Comparison not supported for type :$this") + } +} diff --git a/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/expressions/EnabledAnswerOptionsEvaluator.kt b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/expressions/EnabledAnswerOptionsEvaluator.kt new file mode 100644 index 000000000..0ead6abde --- /dev/null +++ b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/expressions/EnabledAnswerOptionsEvaluator.kt @@ -0,0 +1,260 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.ohs.fhir.datacapture.expressions + +import com.google.fhir.model.r4.Coding +import com.google.fhir.model.r4.Questionnaire +import com.google.fhir.model.r4.QuestionnaireResponse +import com.google.fhir.model.r4.Resource +import com.google.fhir.model.r4.ValueSet +import dev.ohs.fhir.datacapture.ExternalAnswerValueSetResolver +import dev.ohs.fhir.datacapture.XFhirQueryResolver +import dev.ohs.fhir.datacapture.extensions.answerExpression +import dev.ohs.fhir.datacapture.extensions.answerOptionsToggleExpressions +import dev.ohs.fhir.datacapture.extensions.extractAnswerOptions +import dev.ohs.fhir.datacapture.extensions.isFhirPath +import dev.ohs.fhir.datacapture.extensions.isXFhirQuery +import dev.ohs.fhir.datacapture.fhirpath.ExpressionEvaluator +import dev.ohs.fhir.datacapture.fhirpath.convertToBoolean + +/** + * Evaluates and manages answer options within a [Questionnaire] and its corresponding + * [QuestionnaireResponse]. It handles enablement, disablement, and presentation of options based on + * expressions and criteria. + * + * The evaluator works in the context of a [Questionnaire] and the corresponding + * [QuestionnaireResponse]. It is the caller's responsibility to make sure to call the evaluator + * with [Questionnaire.Item] and [QuestionnaireResponse.Item] that belong to the [Questionnaire] and + * the [QuestionnaireResponse]. + * + * @param questionnaire the [Questionnaire] where the expression belong to + * @param questionnaireResponse the [QuestionnaireResponse] related to the [Questionnaire] + * @param xFhirQueryResolver the [XFhirQueryResolver] to resolve resources based on the X-FHIR-Query + * @param externalValueSetResolver the [ExternalAnswerValueSetResolver] to resolve value sets + * externally/outside of the [Questionnaire] + * @param questionnaireItemParentMap the [Map] of items parent + * @param questionnaireLaunchContextMap the [Map] of launchContext names to their resource values + */ +internal class EnabledAnswerOptionsEvaluator( + private val questionnaire: Questionnaire, + private val questionnaireResponse: QuestionnaireResponse, + private val questionnaireItemParentMap: Map = emptyMap(), + private val questionnaireLaunchContextMap: Map? = emptyMap(), + private val xFhirQueryResolver: XFhirQueryResolver? = null, + private val externalValueSetResolver: ExternalAnswerValueSetResolver? = null, +) { + + private val expressionEvaluator = + ExpressionEvaluator( + questionnaire, + questionnaireResponse, + questionnaireItemParentMap, + questionnaireLaunchContextMap, + xFhirQueryResolver, + ) + + private val answerValueSetMap = mutableMapOf>() + + /** + * The answer expression referencing an x-fhir-query has its evaluated data cached to avoid + * reloading resources unnecessarily. The value is updated each time an item with answer + * expression is evaluating the latest answer options. + */ + private val answerExpressionMap = mutableMapOf>() + + /** + * Returns a [Pair] of the enabled/allowed [Questionnaire.Item.AnswerOption] options and the + * disabled/disallowed [QuestionnaireResponse.Item.Answer] answers, based on the evaluation of + * [answerOptionsToggleExpressions] expressions + */ + internal suspend fun evaluate( + questionnaireItem: Questionnaire.Item, + questionnaireResponseItem: QuestionnaireResponse.Item, + ): Pair< + List, + List, + > { + val resolvedAnswerOptions = + answerOptions( + questionnaireItem, + ) + + if (questionnaireItem.answerOptionsToggleExpressions.isEmpty()) { + return Pair(resolvedAnswerOptions, emptyList()) + } + + val enabledQuestionnaireAnswerOptions = + evaluateAnswerOptionsToggleExpressions( + questionnaireItem, + resolvedAnswerOptions, + ) + val disabledAnswers = + questionnaireResponseItem.answer + .takeIf { it.isNotEmpty() } + ?.filterNot { ans -> enabledQuestionnaireAnswerOptions.any { ans.value == it.value } } + ?: emptyList() + return Pair(enabledQuestionnaireAnswerOptions, disabledAnswers) + } + + /** + * In a `choice` or `open-choice` type question, the answer options are defined in one of the + * three elements in the questionnaire: + * - `Questionnaire.item.answerOption`: a list of permitted answers to the question + * - `Questionnaire.item.answerValueSet`: a reference to a value set containing a list of + * permitted answers to the question + * - `Extension answer-expression`: an expression based extension which defines the x-fhir-query + * or fhirpath to evaluate permitted answer options + * + * Returns the answer options defined in one of the sources above. If the answer options are + * defined in `Questionnaire.item.answerValueSet`, the answer value set will be expanded. + */ + private suspend fun answerOptions( + questionnaireItem: Questionnaire.Item, + ): List = + when { + questionnaireItem.answerOption.isNotEmpty() -> questionnaireItem.answerOption + !questionnaireItem.answerValueSet?.value.isNullOrEmpty() -> + resolveAnswerValueSet(questionnaireItem.answerValueSet!!.value!!) + questionnaireItem.answerExpression != null -> + resolveAnswerExpression( + questionnaireItem, + ) + else -> emptyList() + } + + private suspend fun resolveAnswerValueSet(uri: String): List { + // If cache hit, return it + if (answerValueSetMap.contains(uri)) { + return answerValueSetMap[uri]!! + } + + val options: List = + if (uri.startsWith("#")) { + questionnaire.contained + .firstOrNull { resource -> + resource.id.equals(uri) && + resource is ValueSet && + resource.expansion != null && + resource.expansion!!.contains.isNotEmpty() + } + ?.let { resource -> + val valueSet = resource as ValueSet + valueSet.expansion!! + .contains + .filterNot { it.abstract?.value == true || it.inactive?.value == true } + .map { component -> + Questionnaire.Item.AnswerOption.Builder( + value = + Questionnaire.Item.AnswerOption.Value.Coding( + Coding.Builder() + .apply { + system = component.system?.toBuilder() + code = component.code?.toBuilder() + display = component.display?.toBuilder() + } + .build(), + ), + ) + .build() + } + } + } else { + // Ask the client to provide the answers from an external expanded value set. + externalValueSetResolver?.resolve(uri)?.map { coding -> + Questionnaire.Item.AnswerOption( + value = Questionnaire.Item.AnswerOption.Value.Coding(coding.copy()), + ) + } + } + ?: emptyList() + // save it so that we avoid have cache misses. + answerValueSetMap[uri] = options + return options + } + + // TODO persist previous answers in case options are changing and new list does not have selected + // answer and FHIRPath in x-fhir-query + // https://build.fhir.org/ig/HL7/sdc/expressions.html#x-fhir-query-enhancements + private suspend fun resolveAnswerExpression( + item: Questionnaire.Item, + ): List { + // Check cache first for database queries + val answerExpression = item.answerExpression ?: return emptyList() + + return when { + answerExpression.isXFhirQuery -> { + checkNotNull(xFhirQueryResolver) { + "XFhirQueryResolver cannot be null. Please provide the XFhirQueryResolver via DataCaptureConfig." + } + val variablesMap = expressionEvaluator.extractItemDependentVariables(answerExpression, item) + val xFhirExpressionString = + expressionEvaluator.createXFhirQueryFromExpression(answerExpression, variablesMap) + if (answerExpressionMap.containsKey(xFhirExpressionString)) { + return answerExpressionMap[xFhirExpressionString]!! + } + + val data = xFhirExpressionString?.let { xFhirQueryResolver.resolve(it) } ?: emptyList() + val options = item.extractAnswerOptions(data) + + xFhirExpressionString?.let { answerExpressionMap[it] = options } + options + } + answerExpression.isFhirPath -> { + val data = expressionEvaluator.evaluateExpression(answerExpression) + item.extractAnswerOptions(data) + } + else -> + throw UnsupportedOperationException( + "${answerExpression.language} not supported for answer-expression yet", + ) + } + } + + private fun evaluateAnswerOptionsToggleExpressions( + item: Questionnaire.Item, + answerOptions: List, + ): List { + val results = + item.answerOptionsToggleExpressions + .map { + val (expression, toggleOptions) = it + val evaluationResult = + if (expression?.value?.isFhirPath == true) { + convertToBoolean( + expressionEvaluator.evaluateExpression(expression.value), + ) + } else { + throw UnsupportedOperationException( + "${expression?.value?.language} not supported yet for answer-options-toggle-expression", + ) + } + evaluationResult to toggleOptions + } + .partition { it.first } + val (allowed, disallowed) = results + val allowedOptions = allowed.flatMap { it.second } + + val disallowedOptions = + disallowed.flatMap { + it.second.filterNot { option -> allowedOptions.any { type -> type == option } } + } + + return answerOptions.filterNot { answerOption -> + disallowedOptions.any { answerOption.value == it } + } + } +} diff --git a/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/extensions/MoreAttachments.kt b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/extensions/MoreAttachments.kt new file mode 100644 index 000000000..f3241df07 --- /dev/null +++ b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/extensions/MoreAttachments.kt @@ -0,0 +1,40 @@ +/* + * Copyright 2025-2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.ohs.fhir.datacapture.extensions + +import com.google.fhir.model.r4.Attachment +import com.google.fhir.model.r4.Base64Binary +import dev.ohs.fhir.datacapture.DataCapture +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi + +internal suspend fun Attachment.imageData(): ByteArray? { + check(mimeType == MimeType.IMAGE.value) { + "${mimeType?.capitalize()} attachment is not supported in Item Media extension yet" + } + + return data?.data + ?: url?.value?.let { DataCapture.getConfiguration().urlResolver?.resolveBitmapUrl(it) } +} + +/** Returns the main MIME type of a MIME type string (e.g. image/png returns image). */ +internal val Attachment.mimeType: String? + get() = contentType?.value?.substringBefore("/") + +@OptIn(ExperimentalEncodingApi::class) +internal val Base64Binary.data: ByteArray? + get() = value?.let { Base64.decode(it) } diff --git a/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/extensions/MoreBigDecimals.kt b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/extensions/MoreBigDecimals.kt new file mode 100644 index 000000000..f5d991633 --- /dev/null +++ b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/extensions/MoreBigDecimals.kt @@ -0,0 +1,22 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.ohs.fhir.datacapture.extensions + +import com.ionspin.kotlin.bignum.decimal.BigDecimal + +internal val BigDecimal.inMBs: BigDecimal + get() = this.div(1_048_576L) diff --git a/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/extensions/MoreElements.kt b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/extensions/MoreElements.kt new file mode 100644 index 000000000..acd0b7e36 --- /dev/null +++ b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/extensions/MoreElements.kt @@ -0,0 +1,89 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.ohs.fhir.datacapture.extensions + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import com.google.fhir.model.r4.Attachment +import com.google.fhir.model.r4.Coding +import com.google.fhir.model.r4.Date +import com.google.fhir.model.r4.DateTime +import com.google.fhir.model.r4.Decimal +import com.google.fhir.model.r4.Element +import com.google.fhir.model.r4.Expression +import com.google.fhir.model.r4.FhirDate +import com.google.fhir.model.r4.FhirDateTime +import com.google.fhir.model.r4.Quantity +import com.google.fhir.model.r4.Reference +import com.google.fhir.model.r4.Time +import dev.ohs.fhir.datacapture.generated.resources.Res +import dev.ohs.fhir.datacapture.generated.resources.no +import dev.ohs.fhir.datacapture.generated.resources.yes +import dev.ohs.fhir.datacapture.getLocalDateTimeFormatter +import org.jetbrains.compose.resources.stringResource + +internal const val EXTENSION_CQF_EXPRESSION_URL: String = + "http://hl7.org/fhir/StructureDefinition/cqf-expression" + +@get:Composable +internal val Element.displayString: String? + get() { + return when (this) { + is Coding -> remember(this) { display?.getLocalizedText() ?: code?.value } + is DateTime -> { + val localDateFormatter = getLocalDateTimeFormatter() + remember(this) { + val localDateTime = (value as? FhirDateTime.DateTime)?.dateTime + "${localDateTime?.date?.let { localDateFormatter.format(it) }} ${ + localDateTime?.time?.let { + localDateFormatter.localizedTimeString( + it, + ) + } + }" + } + } + is Date -> { + val localDateFormatter = getLocalDateTimeFormatter() + remember(this) { + val localDate = (value as? FhirDate.Date)?.date + localDate?.let { localDateFormatter.format(it) } + } + } + is Time -> { + val localDateFormatter = getLocalDateTimeFormatter() + remember(this) { value?.let { localDateFormatter.localizedTimeString(it) } } + } + is FhirR4Integer -> remember(this) { value?.toString() } + is Reference -> remember(this) { display?.value ?: reference?.value } + is FhirR4String -> remember(this) { getLocalizedText() } + is Attachment -> remember(this) { url?.value ?: title?.value } + is FhirR4Boolean -> { + val yesStringText = stringResource(Res.string.yes) + val noStringText = stringResource(Res.string.no) + + remember(this) { value?.let { if (it) yesStringText else noStringText } } + } + is Quantity -> remember(this) { value?.value?.toStringExpanded() } + is Decimal -> remember(this) { value?.toStringExpanded() } + else -> remember(this) { null } + } + } + +internal val Element.cqfExpression: Expression? + get() = + this.extension.find { it.url == EXTENSION_CQF_EXPRESSION_URL }?.value?.asExpression()?.value diff --git a/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/extensions/MoreExpressions.kt b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/extensions/MoreExpressions.kt new file mode 100644 index 000000000..519497c5a --- /dev/null +++ b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/extensions/MoreExpressions.kt @@ -0,0 +1,27 @@ +/* + * Copyright 2022-2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.ohs.fhir.datacapture.extensions + +import com.google.fhir.model.r4.Expression + +internal val Expression.isXFhirQuery: Boolean + get() = + Expression.ExpressionLanguage.Application_X_Fhir_Query.getCode() == + this.language.value?.getCode() + +internal val Expression.isFhirPath: Boolean + get() = Expression.ExpressionLanguage.Text_Fhirpath.getCode() == this.language.value?.getCode() diff --git a/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/extensions/MoreExtensions.kt b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/extensions/MoreExtensions.kt new file mode 100644 index 000000000..d31a63f82 --- /dev/null +++ b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/extensions/MoreExtensions.kt @@ -0,0 +1,38 @@ +/* + * Copyright 2025-2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.ohs.fhir.datacapture.extensions + +import com.google.fhir.model.r4.Extension + +internal const val EXTENSION_CQF_CALCULATED_VALUE_URL: String = + "http://hl7.org/fhir/StructureDefinition/cqf-calculatedValue" + +fun Extension.readStringExtension(uri: String): String? { + val ext = extension.single { it.url == uri } + return ext.value?.asUri()?.value?.value + ?: ext.value?.asCanonical()?.value?.value ?: ext.value?.asCode()?.value?.value + ?: ext.value?.asInteger()?.value?.value?.toString() ?: ext.value?.asMarkdown()?.value?.value + ?: ext.value?.asString()?.value?.value +} + +internal val Extension?.cqfCalculatedValueExpression + get() = + this?.extension + ?.find { it.url == EXTENSION_CQF_CALCULATED_VALUE_URL } + ?.value + ?.asExpression() + ?.value diff --git a/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/extensions/MoreInts.kt b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/extensions/MoreInts.kt new file mode 100644 index 000000000..c645f45d8 --- /dev/null +++ b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/extensions/MoreInts.kt @@ -0,0 +1,27 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.ohs.fhir.datacapture.extensions + +import kotlin.math.abs +import kotlin.math.log10 + +// Count the number of digits in an Integer +fun Int.length() = + when (this) { + 0 -> 1 + else -> log10(abs(toDouble())).toInt() + 1 + } diff --git a/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/extensions/MoreLocalDateTimes.kt b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/extensions/MoreLocalDateTimes.kt new file mode 100644 index 000000000..6662a033a --- /dev/null +++ b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/extensions/MoreLocalDateTimes.kt @@ -0,0 +1,31 @@ +/* + * Copyright 2022-2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.ohs.fhir.datacapture.extensions + +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.format.FormatStringsInDatetimeFormats +import kotlinx.datetime.format.byUnicodePattern + +// TODO: Add platform-specific time formatting that observes user's 24h/12h format preferences +// On Android, ICU does not observe DateFormat.is24HourFormat() setting. +// On iOS, should respect NSLocale time format preferences. +// See: https://developer.android.com/guide/topics/resources/internationalization#24h-setting +// Currently using basic 24h format as fallback. +@OptIn(FormatStringsInDatetimeFormats::class) +internal fun LocalDateTime.toLocalizedTimeString(): String { + return LocalDateTime.Format { byUnicodePattern("HH:mm") }.format(this) +} diff --git a/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/extensions/MoreLocalDates.kt b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/extensions/MoreLocalDates.kt new file mode 100644 index 000000000..c7df7d977 --- /dev/null +++ b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/extensions/MoreLocalDates.kt @@ -0,0 +1,73 @@ +/* + * Copyright 2022-2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.ohs.fhir.datacapture.extensions + +import kotlin.time.ExperimentalTime +import kotlin.time.Instant +import kotlinx.datetime.LocalDate +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toLocalDateTime + +/** + * Returns the first character that is not a letter in the given date pattern string (e.g. "/" for + * "dd/mm/yyyy") otherwise null. + */ +internal fun getDateSeparator(localeDatePattern: String): Char? = + localeDatePattern.filterNot { it.isLetter() }.firstOrNull() + +/** + * Converts date pattern to acceptable date pattern where 2 digits are expected for day(dd) and + * month(MM) and 4 digits are expected for year(yyyy), e.g., dd/mm/yyyy is returned for d/M/yy + */ +internal fun canonicalizeDatePattern(datePattern: String): String { + val datePatternSeparator = getDateSeparator(datePattern) + var hasDay = false + var hasMonth = false + var hasYear = false + return buildString { + datePattern.lowercase().forEach { + when (it) { + 'd' -> { + if (!hasDay) { + append("dd") + hasDay = true + } + } + 'm' -> { + if (!hasMonth) { + append("MM") + hasMonth = true + } + } + 'y' -> { + if (!hasYear) { + append("yyyy") + hasYear = true + } + } + datePatternSeparator -> { + append(datePatternSeparator) + } + else -> {} + } + } + } +} + +@OptIn(ExperimentalTime::class) +internal fun Long.toLocalDate(): LocalDate = + Instant.fromEpochMilliseconds(this).toLocalDateTime(TimeZone.UTC).date diff --git a/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/extensions/MoreQuestionnaireItemAnswerOptions.kt b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/extensions/MoreQuestionnaireItemAnswerOptions.kt new file mode 100644 index 000000000..5d4744e88 --- /dev/null +++ b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/extensions/MoreQuestionnaireItemAnswerOptions.kt @@ -0,0 +1,135 @@ +/* + * Copyright 2022-2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.ohs.fhir.datacapture.extensions + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.decodeToImageBitmap +import com.google.fhir.model.r4.Element +import com.google.fhir.model.r4.Extension +import com.google.fhir.model.r4.Questionnaire +import com.google.fhir.model.r4.QuestionnaireResponse + +internal const val EXTENSION_OPTION_EXCLUSIVE_URL = + "http://hl7.org/fhir/StructureDefinition/questionnaire-optionExclusive" +internal const val EXTENSION_ITEM_ANSWER_MEDIA = + "http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-itemAnswerMedia" + +/** Indicates that if this answerOption is selected, no other possible answers may be selected. */ +internal val Questionnaire.Item.AnswerOption.optionExclusive: Boolean + get() { + val extension = + this.extension.singleOrNull { it.url == EXTENSION_OPTION_EXCLUSIVE_URL } ?: return false + val value = extension.value + if (value is Extension.Value.Boolean) { + return value.asBoolean()?.value?.value ?: false + } + return false + } + +/** Get the answer options values with `initialSelected` set to true */ +internal val List.initialSelected + get() = this.filter { it.initialSelected?.value == true }.map { it.value } + +fun Questionnaire.Item.AnswerOption.itemAnswerOptionImage(): ImageBitmap? { + return extension + .singleOrNull { it.url == EXTENSION_ITEM_ANSWER_MEDIA } + ?.value + ?.asAttachment() + ?.value + ?.let { + if (it.contentType?.value == null || it.data?.value == null) { + return null + } + + when (it.contentType?.value!!) { + "image/jpeg", + "image/jpg", + "image/png", -> it.data?.data?.decodeToImageBitmap() + else -> null + } + } +} + +val Questionnaire.Item.AnswerOption.elementValue: Element + get() = + this.value.let { + when (it) { + is Questionnaire.Item.AnswerOption.Value.Reference -> it.value + is Questionnaire.Item.AnswerOption.Value.Coding -> it.value + is Questionnaire.Item.AnswerOption.Value.Date -> it.value + is Questionnaire.Item.AnswerOption.Value.Integer -> it.value + is Questionnaire.Item.AnswerOption.Value.String -> it.value + is Questionnaire.Item.AnswerOption.Value.Time -> it.value + } + } + +fun Questionnaire.Item.AnswerOption.toQuestionnaireResponseItemAnswer(): + QuestionnaireResponse.Item.Answer = + QuestionnaireResponse.Item.Answer( + value = + value.let { + when (it) { + is Questionnaire.Item.AnswerOption.Value.Integer -> + QuestionnaireResponse.Item.Answer.Value.Integer( + value = it.value, + ) + is Questionnaire.Item.AnswerOption.Value.Coding -> + QuestionnaireResponse.Item.Answer.Value.Coding( + value = it.value, + ) + is Questionnaire.Item.AnswerOption.Value.Date -> + QuestionnaireResponse.Item.Answer.Value.Date( + value = it.value, + ) + is Questionnaire.Item.AnswerOption.Value.Reference -> + QuestionnaireResponse.Item.Answer.Value.Reference( + value = it.value, + ) + is Questionnaire.Item.AnswerOption.Value.String -> + QuestionnaireResponse.Item.Answer.Value.String( + value = it.value, + ) + is Questionnaire.Item.AnswerOption.Value.Time -> + QuestionnaireResponse.Item.Answer.Value.Time( + value = it.value, + ) + } + }, + ) + +val Questionnaire.Item.AnswerOption.Value.id: String? + get() = + when (this) { + is Questionnaire.Item.AnswerOption.Value.Reference -> value.id + is Questionnaire.Item.AnswerOption.Value.Coding -> value.id + is Questionnaire.Item.AnswerOption.Value.Date -> value.id + is Questionnaire.Item.AnswerOption.Value.Integer -> value.id + is Questionnaire.Item.AnswerOption.Value.String -> value.id + is Questionnaire.Item.AnswerOption.Value.Time -> value.id + } + +/** + * Returns what to display on the UI depending on the [Questionnaire.Item.AnswerOption]. Used to get + * the display representation for item answer options. + */ +@Composable +fun Questionnaire.Item.AnswerOption.displayString(): String { + val localizedDisplayString = this.elementValue.displayString + return remember(this) { localizedDisplayString ?: "" } +} diff --git a/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/extensions/MoreQuestionnaireItems.kt b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/extensions/MoreQuestionnaireItems.kt new file mode 100644 index 000000000..b0d21c190 --- /dev/null +++ b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/extensions/MoreQuestionnaireItems.kt @@ -0,0 +1,1023 @@ +/* + * Copyright 2023-2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.ohs.fhir.datacapture.extensions + +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.buildAnnotatedString +import com.google.fhir.model.r4.Attachment +import com.google.fhir.model.r4.Coding +import com.google.fhir.model.r4.Date +import com.google.fhir.model.r4.Expression +import com.google.fhir.model.r4.Extension +import com.google.fhir.model.r4.Integer +import com.google.fhir.model.r4.Questionnaire +import com.google.fhir.model.r4.QuestionnaireResponse +import com.google.fhir.model.r4.Reference +import com.google.fhir.model.r4.Resource +import com.google.fhir.model.r4.String as FhirString +import com.google.fhir.model.r4.Time +import com.ionspin.kotlin.bignum.decimal.BigDecimal +import com.ionspin.kotlin.bignum.decimal.toBigDecimal +import dev.ohs.fhir.datacapture.QuestionnaireViewHolderType +import dev.ohs.fhir.datacapture.fhirpath.evaluateToDisplay + +internal const val MIN_VALUE_EXTENSION_URL = "http://hl7.org/fhir/StructureDefinition/minValue" + +internal const val MAX_VALUE_EXTENSION_URL = "http://hl7.org/fhir/StructureDefinition/maxValue" + +// Please note these URLs do not point to any FHIR Resource and are broken links. They are being +// used until we can engage the FHIR community to add these extensions officially. + +internal const val EXTENSION_ITEM_CONTROL_URL_ANDROID_FHIR = + "https://github.com/google/android-fhir/StructureDefinition/questionnaire-itemControl" + +internal const val EXTENSION_ITEM_CONTROL_SYSTEM_ANDROID_FHIR = + "https://github.com/google/android-fhir/questionnaire-item-control" + +internal const val EXTENSION_DIALOG_URL_ANDROID_FHIR = + "https://github.com/google/android-fhir/StructureDefinition/dialog" + +internal enum class StyleUrl(val url: String) { + BASE("https://github.com/google/android-fhir/tree/master/datacapture/android-style"), + PREFIX_TEXT_VIEW("prefix_text_view"), + QUESTION_TEXT_VIEW("question_text_view"), + SUBTITLE_TEXT_VIEW("subtitle_text_view"), +} + +// Below URLs exist and are supported by HL7 + +internal const val EXTENSION_ANSWER_EXPRESSION_URL: String = + "http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-answerExpression" + +internal const val EXTENSION_ANSWER_OPTION_TOGGLE_EXPRESSION_URL = + "http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-answerOptionsToggleExpression" + +internal const val EXTENSION_ANSWER_OPTION_TOGGLE_EXPRESSION_OPTION = "option" + +internal const val EXTENSION_ANSWER_OPTION_TOGGLE_EXPRESSION = "expression" + +internal const val EXTENSION_CANDIDATE_EXPRESSION_URL: String = + "http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-candidateExpression" + +internal const val EXTENSION_CALCULATED_EXPRESSION_URL = + "http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-calculatedExpression" + +internal const val EXTENSION_CHOICE_ORIENTATION_URL = + "http://hl7.org/fhir/StructureDefinition/questionnaire-choiceOrientation" + +internal const val EXTENSION_CHOICE_COLUMN_URL: String = + "http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-choiceColumn" + +internal const val EXTENSION_DISPLAY_CATEGORY_URL = + "http://hl7.org/fhir/StructureDefinition/questionnaire-displayCategory" + +internal const val EXTENSION_DISPLAY_CATEGORY_SYSTEM = + "http://hl7.org/fhir/questionnaire-display-category" + +internal const val EXTENSION_DISPLAY_CATEGORY_INSTRUCTIONS = "instructions" + +internal const val EXTENSION_ENABLE_WHEN_EXPRESSION_URL: String = + "http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-enableWhenExpression" + +internal const val EXTENSION_ENTRY_FORMAT_URL = + "http://hl7.org/fhir/StructureDefinition/entryFormat" + +internal const val EXTENSION_HIDDEN_URL = + "http://hl7.org/fhir/StructureDefinition/questionnaire-hidden" + +internal const val EXTENSION_ITEM_CONTROL_URL = + "http://hl7.org/fhir/StructureDefinition/questionnaire-itemControl" + +internal const val EXTENSION_ITEM_CONTROL_SYSTEM = "http://hl7.org/fhir/questionnaire-item-control" + +internal const val EXTENSION_ITEM_MEDIA = + "http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-itemMedia" + +internal const val EXTENSION_MAX_SIZE = "http://hl7.org/fhir/StructureDefinition/maxSize" + +internal const val EXTENSION_MIME_TYPE = "http://hl7.org/fhir/StructureDefinition/mimeType" + +/** + * Extension for questionnaire and its items, representing a rule that must be satisfied before + * [QuestionnaireResponse] can be considered valid. + * + * See https://hl7.org/fhir/extensions/StructureDefinition-questionnaire-constraint.html. + */ +internal const val EXTENSION_QUESTIONNAIRE_CONSTRAINT_URL = + "http://hl7.org/fhir/StructureDefinition/questionnaire-constraint" + +internal const val EXTENSION_QUESTIONNAIRE_CONSTRAINT_KEY = "key" + +internal const val EXTENSION_QUESTIONNAIRE_CONSTRAINT_REQUIREMENTS = "requirements" + +internal const val EXTENSION_QUESTIONNAIRE_CONSTRAINT_SEVERITY = "severity" + +internal const val EXTENSION_QUESTIONNAIRE_CONSTRAINT_EXPRESSION = "expression" + +internal const val EXTENSION_QUESTIONNAIRE_CONSTRAINT_HUMAN = "human" + +internal const val EXTENSION_QUESTIONNAIRE_CONSTRAINT_LOCATION = "location" + +/** + * Extension for questionnaire items of integer and decimal types including a single unit to be + * displayed. + * + * See https://hl7.org/fhir/extensions/StructureDefinition-questionnaire-unit.html. + */ +internal const val EXTENSION_QUESTIONNAIRE_UNIT_URL = + "http://hl7.org/fhir/StructureDefinition/questionnaire-unit" + +/** + * Extension for questionnaire items of quantity type including unit options to choose from. + * + * See https://hl7.org/fhir/extensions/StructureDefinition-questionnaire-unitOption.html. + */ +internal const val EXTENSION_QUESTIONNAIRE_UNIT_OPTION_URL = + "http://hl7.org/fhir/StructureDefinition/questionnaire-unitOption" + +/** + * Extension for questionnaire items of quantity type including a value set of unit options to + * choose from. + */ +internal const val EXTENSION_QUESTIONNAIRE_UNIT_VALUE_SET_URL = + "http://hl7.org/fhir/StructureDefinition/questionnaire-unitValueSet" + +internal const val EXTENSION_SLIDER_STEP_VALUE_URL = + "http://hl7.org/fhir/StructureDefinition/questionnaire-sliderStepValue" + +internal const val EXTENSION_VARIABLE_URL = "http://hl7.org/fhir/StructureDefinition/variable" + +internal const val ITEM_INITIAL_EXPRESSION_URL: String = + "http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-initialExpression" + +// ********************************************************************************************** // +// // +// Rendering extensions: item control, choice orientation, etc. // +// // +// See https://build.fhir.org/ig/HL7/sdc/rendering.html. // +// // +// ********************************************************************************************** // + +/** + * Item control types supported by the SDC library with `extensionCode` from the value set + * http://hl7.org/fhir/R4/valueset-questionnaire-item-control.html and `viewHolderType` as the + * [QuestionnaireViewHolderType] to be used to render the question. + */ +enum class ItemControlTypes( + val extensionCode: String, + val viewHolderType: QuestionnaireViewHolderType, +) { + AUTO_COMPLETE("autocomplete", QuestionnaireViewHolderType.AUTO_COMPLETE), + CHECK_BOX("check-box", QuestionnaireViewHolderType.CHECK_BOX_GROUP), + DROP_DOWN("drop-down", QuestionnaireViewHolderType.DROP_DOWN), + OPEN_CHOICE("open-choice", QuestionnaireViewHolderType.DIALOG_SELECT), + RADIO_BUTTON("radio-button", QuestionnaireViewHolderType.RADIO_GROUP), + SLIDER("slider", QuestionnaireViewHolderType.SLIDER), + PHONE_NUMBER("phone-number", QuestionnaireViewHolderType.PHONE_NUMBER), +} + +/** + * The initial-expression extension on [Questionnaire.Item] to allow dynamic selection of default or + * initially selected answers + */ +val Questionnaire.Item.initialExpression: Expression? + get() { + return this.extension + .firstOrNull { it.url == ITEM_INITIAL_EXPRESSION_URL } + ?.let { it.value?.asExpression()?.value } + } + +val Questionnaire.Item.itemControlCode: String? + get() { + val codeableConcept = + this.extension + .firstOrNull { + it.url == EXTENSION_ITEM_CONTROL_URL || it.url == EXTENSION_ITEM_CONTROL_URL_ANDROID_FHIR + } + ?.value + ?.asCodeableConcept() + ?.value + return codeableConcept + ?.coding + ?.firstOrNull { + EXTENSION_ITEM_CONTROL_SYSTEM == it.system?.value || + EXTENSION_ITEM_CONTROL_SYSTEM_ANDROID_FHIR == it.system?.value + } + ?.code + ?.value + } + +/** + * The [ItemControlTypes] of the questionnaire item if it is specified by the item control + * extension, or `null`. + * + * See http://hl7.org/fhir/R4/extension-questionnaire-itemcontrol.html. + */ +val Questionnaire.Item.itemControl: ItemControlTypes? + get() = ItemControlTypes.values().firstOrNull { it.extensionCode == itemControlCode } + +private val Questionnaire.Item.hasDialogExtension: Boolean + get() = this.extension.any { it.url == EXTENSION_DIALOG_URL_ANDROID_FHIR } + +val Questionnaire.Item.shouldUseDialog: Boolean + get() = + this.hasDialogExtension && + (this.itemControl?.viewHolderType == QuestionnaireViewHolderType.CHECK_BOX_GROUP || + this.itemControl?.viewHolderType == QuestionnaireViewHolderType.RADIO_GROUP) + +/** + * The desired orientation for the list of choices. + * + * See http://hl7.org/fhir/R4/extension-questionnaire-choiceorientation.html. + */ +enum class ChoiceOrientationTypes(val extensionCode: String) { + HORIZONTAL("horizontal"), + VERTICAL("vertical"), +} + +/** Desired orientation to render a list of choices. */ +val Questionnaire.Item.choiceOrientation: ChoiceOrientationTypes? + get() { + val code = + (this.extension.firstOrNull { it.url == EXTENSION_CHOICE_ORIENTATION_URL }?.value + as Extension.Value.Code?) + ?.value + return ChoiceOrientationTypes.entries.firstOrNull { it.extensionCode == code?.value } + } + +/** + * Whether the QuestionnaireItem should be hidden according to the hidden extension or lack thereof. + */ +internal val Questionnaire.Item.isHidden: Boolean + get() { + val extension = this.extension.singleOrNull { it.url == EXTENSION_HIDDEN_URL } ?: return false + val value = extension.value + if (value is Extension.Value.Boolean) { + return value.asBoolean()?.value?.value == true + } + return false + } + +/** + * The entry format specified in the extension https://hl7.org/fhir/R4/extension-entryformat.html. + */ +val Questionnaire.Item.entryFormat: String? + get() { + val extension = extension.singleOrNull { it.url == EXTENSION_ENTRY_FORMAT_URL } ?: return null + return extension.value?.asString()?.value?.value + } + +/** Slider step extension value. */ +val Questionnaire.Item.sliderStepValue: Int? + get() { + val extension = + this.extension.singleOrNull { it.url == EXTENSION_SLIDER_STEP_VALUE_URL } ?: return null + val value = extension.value + if (value is Extension.Value.Integer) { + return value.asInteger()?.value?.value + } + return null + } + +internal val Questionnaire.Item.minValue + get() = this.extension.find { it.url == MIN_VALUE_EXTENSION_URL }?.value + +internal val Questionnaire.Item.minValueCqfCalculatedValueExpression: Expression? + get() = this.extension.find { it.url == MIN_VALUE_EXTENSION_URL }?.cqfCalculatedValueExpression + +internal val Questionnaire.Item.maxValue + get() = this.extension.find { it.url == MAX_VALUE_EXTENSION_URL }?.value + +internal val Questionnaire.Item.maxValueCqfCalculatedValueExpression: Expression? + get() = this.extension.find { it.url == MAX_VALUE_EXTENSION_URL }?.cqfCalculatedValueExpression + +// ********************************************************************************************** // +// // +// Additional display utilities: display item control, localized text spanned, // +// localized prefix spanned, localized instruction spanned, etc. // +// // +// ********************************************************************************************** // + +/** UI controls relevant to rendering questionnaire items. */ +internal enum class DisplayItemControlType(val extensionCode: String) { + FLYOVER("flyover"), + PAGE("page"), + HELP("help"), +} + +/** Item control to show instruction text */ +internal val Questionnaire.Item.displayItemControl: DisplayItemControlType? + get() { + val codeableConcept = + this.extension + .firstOrNull { it.url == EXTENSION_ITEM_CONTROL_URL } + ?.value + ?.asCodeableConcept() + ?.value + val code = + codeableConcept + ?.coding + ?.firstOrNull { EXTENSION_ITEM_CONTROL_SYSTEM == it.system?.value } + ?.code + return DisplayItemControlType.entries.firstOrNull { it.extensionCode == code?.value } + } + +/** Whether any one of the nested display item has [DisplayItemControlType.HELP] control. */ +val Questionnaire.Item.hasHelpButton: Boolean + get() { + return item.any { it.isHelpCode } + } + +/** + * Localized and spanned value of [Questionnaire.Item.text] if translation is present. Default value + * otherwise. + */ +val Questionnaire.Item.localizedTextAnnotatedString: AnnotatedString? + get() = text?.getLocalizedText()?.toAnnotatedString() + +/** + * Localized and spanned value of [Questionnaire.Item.prefix] if translation is present. Default + * value otherwise. + */ +val Questionnaire.Item.localizedPrefixAnnotatedString: AnnotatedString? + get() = prefix?.getLocalizedText()?.toAnnotatedString() + +/** + * Returns a Spanned object that contains the localized instructions for all of the items in this + * list that are of type `Questionnaire.QuestionnaireItemType.DISPLAY` and have the + * `isInstructionsCode` flag set. The instructions are separated by newlines. + */ +fun List.getLocalizedInstructionsAnnotatedString( + separator: String = "\n", +) = buildAnnotatedString { + this@getLocalizedInstructionsAnnotatedString.filter { questionnaireItem -> + questionnaireItem.type.value == Questionnaire.QuestionnaireItemType.Display && + questionnaireItem.isInstructionsCode + } + .joinTo(this, separator) { it.localizedTextAnnotatedString.toString() } +} + +/** + * A nested questionnaire item of type display with code [DisplayItemControlType.FLYOVER] (if + * present) is used as the fly-over text of the parent question. + */ +internal val Questionnaire.Item.localizedFlyoverSpanned: AnnotatedString? + get() = item.localizedFlyoverSpanned + +/** [localizedFlyoverSpanned] over list of [Questionnaire.Item] */ +val List.localizedFlyoverSpanned: AnnotatedString? + get() = + this.firstOrNull { questionnaireItem -> + questionnaireItem.type.value == Questionnaire.QuestionnaireItemType.Display && + questionnaireItem.displayItemControl == DisplayItemControlType.FLYOVER + } + ?.localizedTextAnnotatedString + +val List.localizedFlyoverAnnotatedString: AnnotatedString? + get() = + this.firstOrNull { questionnaireItem -> + questionnaireItem.type.value == Questionnaire.QuestionnaireItemType.Display && + questionnaireItem.displayItemControl == DisplayItemControlType.FLYOVER + } + ?.localizedTextAnnotatedString + +/** + * A nested questionnaire item of type display with displayCategory extension with + * [EXTENSION_DISPLAY_CATEGORY_INSTRUCTIONS] code is used as the instructions of the parent + * question. + */ +val Questionnaire.Item.localizedHelpAnnotatedString: AnnotatedString? + get() = item.localizedHelpAnnotatedString + +/** [localizedHelpAnnotatedString] over list of [Questionnaire.Questionnaire.Item] */ +val List.localizedHelpAnnotatedString: AnnotatedString? + get() { + return this.firstOrNull { questionnaireItem -> questionnaireItem.isHelpCode } + ?.localizedTextAnnotatedString + } + +/** Returns `true` if extension is display category extension and contains 'instructions' code. */ +internal val Questionnaire.Item.isInstructionsCode: Boolean + get() { + return when (type.value) { + Questionnaire.QuestionnaireItemType.Display -> { + val codeableConcept = + this.extension + .firstOrNull { it.url == EXTENSION_DISPLAY_CATEGORY_URL } + ?.value + ?.asCodeableConcept() + ?.value + val code = + codeableConcept + ?.coding + ?.firstOrNull { EXTENSION_DISPLAY_CATEGORY_SYSTEM == it.system?.value } + ?.code + code?.value == EXTENSION_DISPLAY_CATEGORY_INSTRUCTIONS + } + else -> { + false + } + } + } + +/** + * Returns `true` if item type is display and [displayItemControl] is + * [DisplayItemControlType.FLYOVER]. + */ +internal val Questionnaire.Item.isFlyoverCode: Boolean + get() { + return when (type.value) { + Questionnaire.QuestionnaireItemType.Display -> { + displayItemControl == DisplayItemControlType.FLYOVER + } + else -> { + false + } + } + } + +/** Whether item type is display and [displayItemControl] is [DisplayItemControlType.HELP]. */ +internal val Questionnaire.Item.isHelpCode: Boolean + get() { + return when (type.value) { + Questionnaire.QuestionnaireItemType.Display -> { + displayItemControl == DisplayItemControlType.HELP + } + else -> { + false + } + } + } + +/** Whether item type is display. */ +internal val Questionnaire.Item.isDisplayItem: Boolean + get() = + (type.value == Questionnaire.QuestionnaireItemType.Display && + (isInstructionsCode || isFlyoverCode || isHelpCode)) + +// ********************************************************************************************** // +// // +// Form behavior: mime type, min size, max size, etc. // +// // +// See https://build.fhir.org/ig/HL7/sdc/behavior.html. // +// // +// ********************************************************************************************** // + +/** Identifies the kinds of attachment allowed to be sent for an element. */ +val Questionnaire.Item.mimeTypes: List + get() { + return extension + .filter { it.url == EXTENSION_MIME_TYPE } + .map { (it.value?.asCode()?.value)?.value.toString() } + .filter { it.isNotEmpty() } + .ifEmpty { listOf("*/*") } + } + +/** Currently supported mime types. */ +enum class MimeType(val value: String) { + AUDIO("audio"), + DOCUMENT("application"), + IMAGE("image"), + VIDEO("video"), +} + +/** Returns true if at least one mime type matches the given type. */ +fun Questionnaire.Item.hasMimeType(type: String): Boolean { + return mimeTypes.any { it.substringBefore("/") == type } +} + +/** Returns true if all mime types match the given type. */ +fun Questionnaire.Item.hasMimeTypeOnly(type: String): Boolean { + return mimeTypes.all { it.substringBefore("/") == type } +} + +/** The maximum size of an attachment in Bytes. */ +internal val Questionnaire.Item.maxSizeInBytes: BigDecimal? + get() = extension.firstOrNull { it.url == EXTENSION_MAX_SIZE }?.value?.asDecimal()?.value?.value + +internal val Questionnaire.Item.maxAllowedAttachmentSize: BigDecimal + get() = + maxSizeInBytes?.let { minOf(it, MAX_ALLOWABLE_ATTACHMENT_SIZE) } + ?: MAX_ALLOWABLE_ATTACHMENT_SIZE + +/** Maximum allowable attachment size */ +internal val MAX_ALLOWABLE_ATTACHMENT_SIZE = 15_728_640.toBigDecimal() + +/** A media that is attached to a [Questionnaire.Item]. */ +internal val Questionnaire.Item.itemMedia: Attachment? + get() = + (this.extension.find { it.url == EXTENSION_ITEM_MEDIA }?.value?.asAttachment()?.value)?.takeIf { + it.contentType != null + } + +// /* TODO: unify the code path from itemAnswerMedia to use fetchBitmapFromUrl +// (github.com/google/android-fhir/issues/1876) */ +// /** Fetches the Bitmap representation of [Attachment.url]. */ +// internal suspend fun Attachment.fetchBitmapFromUrl(context: Context): Bitmap? { +// if (!hasUrl() || !UrlUtil.isValid(url) || !hasContentType()) return null +// +// if (mimeType != MimeType.IMAGE.value) return null +// +// val urlResolver = DataCapture.getConfiguration(context).urlResolver ?: return null +// +// return withContext(Dispatchers.IO) { urlResolver.resolveBitmapUrl(url) } +// } +// +// /** Decodes the Bitmap representation of [Attachment.data]. */ +// internal fun Attachment.decodeToBitmap(): Bitmap? { +// if (!hasContentType() || !hasData()) return null +// +// if (mimeType != MimeType.IMAGE.value) return null +// +// return data.decodeToBitmap() +// } +// +// /** Returns Bitmap if Byte Array is a valid Bitmap representation, otherwise null. */ +// private fun ByteArray.decodeToBitmap(): Bitmap? { +// val bitmap = BitmapFactory.decodeByteArray(this, 0, this.size) +// +// if (bitmap == null) Timber.w("Image could not be decoded") +// +// return bitmap +// } + +/** + * The unit for the numerical question. + * + * See http://hl7.org/fhir/R4/extension-questionnaire-unit.html. + */ +internal val Questionnaire.Item.unit: Coding? + get() { + val extension = + this.extension.singleOrNull { it.url == EXTENSION_QUESTIONNAIRE_UNIT_URL } ?: return null + val value = extension.value + if (value is Extension.Value.Coding) { + return value.asCoding()?.value + } + return null + } + +/** + * The unit options for the quantity question. + * + * See http://hl7.org/fhir/R4/extension-questionnaire-unitoption.html. + */ +internal val Questionnaire.Item.unitOption: List + get() { + return this.extension + .filter { it.url == EXTENSION_QUESTIONNAIRE_UNIT_OPTION_URL } + .mapNotNull { it.value?.asCoding()?.value } + .plus( + // https://build.fhir.org/ig/HL7/sdc/behavior.html#initial + // quantity given as initial without value is for default unit reference purpose + this.initial.mapNotNull { it.value.asQuantity()?.value?.toCoding() }, + ) + .distinctBy { it.code } + } + +// ********************************************************************************************** // +// // +// Expressions: answer options toggle expression, variable expression, calculated expression, // +// expression dependency. // +// // +// See https://build.fhir.org/ig/HL7/sdc/expressions.html. // +// // +// ********************************************************************************************** // + +internal val Questionnaire.Item.answerOptionsToggleExpressions + get() = + this.extension + .filter { it.url == EXTENSION_ANSWER_OPTION_TOGGLE_EXPRESSION_URL } + .map { rootExtension -> + val options = + rootExtension.extension + .filter { it.url == EXTENSION_ANSWER_OPTION_TOGGLE_EXPRESSION_OPTION } + .map { it.value } + if (options.isEmpty()) { + throw IllegalArgumentException( + "Questionnaire item $linkId with extension '$EXTENSION_ANSWER_EXPRESSION_URL' requires at least one option. See http://hl7.org/fhir/uv/sdc/STU3/StructureDefinition-sdc-questionnaire-answerOptionsToggleExpression.html.", + ) + } + val expression = + rootExtension.extension + .single { it.url == EXTENSION_ANSWER_OPTION_TOGGLE_EXPRESSION } + .value + ?.asExpression() + expression to options + } + +// Return expression if Questionnaire.Item has ENABLE WHEN EXPRESSION URL +val Questionnaire.Item.enableWhenExpression: Expression? + get() { + return this.extension + .firstOrNull { it.url == EXTENSION_ENABLE_WHEN_EXPRESSION_URL } + ?.let { it.value?.asExpression()?.value } + } + +internal val Questionnaire.Item.variableExpressions: List + get() = + this.extension + .filter { it.url == EXTENSION_VARIABLE_URL } + .mapNotNull { it.value?.asExpression()?.value } + +/** + * Finds the specific variable name [String] at the questionnaire item [Questionnaire.Item] + * + * @param variableName the [String] to match the variable + * @return an [Expression] + */ +internal fun Questionnaire.Item.findVariableExpression( + variableName: String, +): Expression? { + return variableExpressions.find { variableName == it.name?.value } +} + +/** Returns Calculated expression, or null */ +internal val Questionnaire.Item.calculatedExpression: Expression? + get() { + return this.extension + .firstOrNull { it.url == EXTENSION_CALCULATED_EXPRESSION_URL } + ?.let { it.value?.asExpression()?.value } + } + +/** Returns list of extensions whose value is of type [Expression] */ +internal val Questionnaire.Item.expressionBasedExtensions + get() = this.extension.filter { it.value is Extension.Value.Expression } + +/** + * Whether [item] has any expression directly referencing the current questionnaire item by link ID + * (e.g. if [item] has an expression `%resource.item.where(linkId='this-question')` where + * `this-question` is the link ID of the current questionnaire item). + */ +internal fun Questionnaire.Item.isReferencedBy( + item: Questionnaire.Item, +) = + item.expressionBasedExtensions.any { + it.value + ?.asExpression() + ?.value + ?.expression + ?.value + ?.replace(" ", "") + ?.contains(Regex(".*linkId='${this.linkId}'.*")) == true + } + +internal val Questionnaire.Item.answerExpression: Expression? + get() = + this.extension.find { it.url == EXTENSION_ANSWER_EXPRESSION_URL }?.value?.asExpression()?.value + +internal val Questionnaire.Item.candidateExpression: Expression? + get() = + this.extension + .find { it.url == EXTENSION_CANDIDATE_EXPRESSION_URL } + ?.value + ?.asExpression() + ?.value + +// TODO implement full functionality of choice column +// https://github.com/google/android-fhir/issues/1495 +/** + * Choice column extension https://build.fhir.org/ig/HL7/sdc/examples.html#choiceColumn + * + * The extension choice-column defines its internal elements as nested extension with table + * properties + * - path -> the field in answerOption + * - width -> the width of given column if widget generates a table; TBD in #1495 + * - label -> the label of given column of table or answerOption + * - forDisplay -> if the column should be shown on UI + */ +internal val Questionnaire.Item.choiceColumn: List + get() = + this.extension + .filter { it.url == EXTENSION_CHOICE_COLUMN_URL } + .map { extension -> + extension.extension.let { nestedExtensions -> + ChoiceColumn( + path = nestedExtensions.find { it.url == "path" }!!.value?.asString()?.value?.value + ?: "", + label = nestedExtensions.find { it.url == "label" }?.value?.asString()?.value?.value, + forDisplay = + nestedExtensions.any { + it.url == "forDisplay" && it.value?.asBoolean()?.value?.value == true + }, + ) + } + } + +/** + * A choice column extracted from choice column extension contains following properties + * - path -> the path or expression in evaluated answerOption or resources to extract value + * - label -> the label of given column of table or answerOption + * - forDisplay -> if the column should be shown on UI + */ +internal data class ChoiceColumn(val path: String, val label: String?, val forDisplay: Boolean) + +// TODO implement full functionality of choice column +// https://github.com/google/android-fhir/issues/1495 +/** + * Apply and add each choice-column mapping to answer options + * https://build.fhir.org/ig/HL7/sdc/StructureDefinition-sdc-questionnaire-choiceColumn.html + * + * Control the information displayed in list. + * - With reference it allows selection of fields from the resource for display and reference + * - With other types it adds the options as is + * + * @param dataList the source data to extract the answer option values. The data could be list of + * resources [Resource], identifiers [com.google.fhir.model.r4.Identifier] or codes [Coding] + * @return list of answer options [AnswerOption] + */ +internal fun Questionnaire.Item.extractAnswerOptions( + dataList: List, +): List { + return when (this.type.value) { + Questionnaire.QuestionnaireItemType.Reference -> { + require(dataList.all { it is Resource }) { + "'${this.type.value?.getCode()}' cannot be used to populate $EXTENSION_CHOICE_COLUMN_URL. Only Resources can be used to populate the choice columns." + } + + dataList.map { data -> + data as Resource + Reference.Builder() + .apply { + reference = + FhirString.Builder().apply { value = "${data::class.simpleName}/${data.logicalId}" } + this@extractAnswerOptions.choiceColumn + .filter { it.forDisplay } + .map { it.path } + .let { evaluateToDisplay(it, data) } + .also { display = FhirString.Builder().apply { value = it } } + } + .build() + } + } + else -> { + require(dataList.all { it !is Resource }) { + "$EXTENSION_CHOICE_COLUMN_URL not applicable for '${this.type.value?.getCode()}'. Only type reference is allowed with resource." + } + dataList + } + }.mapNotNull { + when (it) { + is Integer -> Questionnaire.Item.AnswerOption.Value.Integer(it) + is FhirString -> Questionnaire.Item.AnswerOption.Value.String(it) + is Coding -> Questionnaire.Item.AnswerOption.Value.Coding(it) + is Reference -> Questionnaire.Item.AnswerOption.Value.Reference(it) + is Date -> Questionnaire.Item.AnswerOption.Value.Date(it) + is Time -> Questionnaire.Item.AnswerOption.Value.Time(it) + else -> null + }?.let { value -> Questionnaire.Item.AnswerOption(value = value) } + } +} + +/** See http://hl7.org/fhir/constraint-severity */ +enum class ConstraintSeverityTypes( + val code: String, +) { + ERROR("error"), + WARNING("warning"), +} + +// ********************************************************************************************** // +// // +// Utilities: zip with questionnaire response item list, nested items, create response items, // +// flattening, etc. // +// // +// ********************************************************************************************** // + +/** + * Returns a list of values built from the elements of `this` and the + * `questionnaireResponseItemList` with the same linkId using the provided `transform` function + * applied to each pair of questionnaire item and questionnaire response item. + * + * In case of repeated group item, `questionnaireResponseItemList` will contain + * QuestionnaireResponseItemComponent with same linkId. So these items are grouped with linkId and + * associated with its questionnaire item linkId. + */ +internal inline fun List.zipByLinkId( + questionnaireResponseItemList: List, + transform: + ( + Questionnaire.Item, + QuestionnaireResponse.Item, + ) -> T, +): List { + val linkIdToQuestionnaireResponseItemListMap = questionnaireResponseItemList.groupBy { it.linkId } + return flatMap { questionnaireItem -> + linkIdToQuestionnaireResponseItemListMap[questionnaireItem.linkId]?.mapNotNull { + questionnaireResponseItem -> + transform(questionnaireItem, questionnaireResponseItem) + } + ?: emptyList() + } +} + +/** + * Returns a list of values built from the elements of `this` and the + * `questionnaireResponseItemList` with the same linkId using the provided `transform` function + * applied to each pair of questionnaire item and questionnaire response item. + * + * In case of repeated group item, `questionnaireResponseItemList` will contain + * QuestionnaireResponseItemComponent with same linkId. So these items are grouped with linkId and + * associated with its questionnaire item linkId. + */ +internal inline fun groupByAndZipByLinkId( + questionnaireItemList: List, + questionnaireResponseItemList: List, + transform: + ( + List, + List, + ) -> T, +): List { + val linkIdToQuestionnaireItemListMap = questionnaireItemList.groupBy { it.linkId } + val linkIdToQuestionnaireResponseItemListMap = questionnaireResponseItemList.groupBy { it.linkId } + return (linkIdToQuestionnaireItemListMap.keys + linkIdToQuestionnaireResponseItemListMap.keys) + .map { linkId -> + transform( + linkIdToQuestionnaireItemListMap[linkId] ?: emptyList(), + linkIdToQuestionnaireResponseItemListMap[linkId] ?: emptyList(), + ) + } +} + +/** + * Whether the corresponding [QuestionnaireResponse.Item] should have [QuestionnaireResponse.Item]s + * nested under [QuestionnaireResponse.Item.Answer]s. + * + * This is true for the following two cases: + * 1. Questions with nested items + * 2. Repeated groups with nested items (Note that this is how repeated groups are organized in the + * [QuestionnaireViewModel], and that they will be flattened in the final + * [QuestionnaireResponse].) + * + * Non-repeated groups should have child items nested directly under the group itself. + * + * For background, see https://build.fhir.org/questionnaireresponse.html#link. + */ +internal val Questionnaire.Item.shouldHaveNestedItemsUnderAnswers: Boolean + get() = + item.isNotEmpty() && + (type.value != Questionnaire.QuestionnaireItemType.Group || repeats?.value == true) + +/** + * Creates a list of [QuestionnaireResponse.Item]s corresponding to the nested items under the + * questionnaire item. + * + * The list can be added as nested items under answers in a corresponding questionnaire response + * item. This may be because + * 1. the questionnaire item is a question with nested questions, in which case each answer in the + * questionnaire response item needs to have the same nested questions, or + * 2. the questionnaire item is a repeated group, in which case each answer in the questionnaire + * response item represents an instance of the repeated group, and needs to have the same nested + * questions. + * + * The hierarchy and order of child items will be retained as specified in the standard. See + * https://www.hl7.org/fhir/questionnaireresponse.html#notes for more details. + */ +internal fun Questionnaire.Item.createNestedQuestionnaireResponseItems() = + item.map { it.createQuestionnaireResponseItem() } + +/** + * Creates a corresponding [QuestionnaireResponse.Item] for the questionnaire item with the + * following properties: + * - same `linkId` as the questionnaire item, + * - any initial answer(s) specified either in the `initial` element or as `initialSelected` + * `answerOption`(s), + * - any nested questions under the initial answers (there will be no user input yet since this is + * just being created) if this is a question with nested questions, and + * - any nested questions if this is a non-repeated group. + * + * Note that although initial answers to a repeated group may be interpreted as initial instances of + * the repeated group in the in-memory representation of questionnaire response, they are not + * defined as such in the standard. As a result, we are not treating them as such in this function + * to be conformant. + * + * The hierarchy and order of child items will be retained as specified in the standard. See + * https://www.hl7.org/fhir/questionnaireresponse.html#notes for more details. + */ +internal fun Questionnaire.Item.createQuestionnaireResponseItem(): + QuestionnaireResponse.Item.Builder { + val qrLinkId = this@createQuestionnaireResponseItem.linkId + return QuestionnaireResponse.Item.Builder(FhirString.Builder().apply { value = qrLinkId.value }) + .apply { + linkId = FhirString.Builder().apply { value = qrLinkId.value } + answer = createQuestionnaireResponseItemAnswers() + if ( + type.value != Questionnaire.QuestionnaireItemType.Group && + this@createQuestionnaireResponseItem.item.isNotEmpty() && + answer.isNotEmpty() + ) { + this.copyNestedItemsToChildlessAnswers(this@createQuestionnaireResponseItem) + } else if ( + this@createQuestionnaireResponseItem.type.value == + Questionnaire.QuestionnaireItemType.Group && repeats?.value != true + ) { + this@createQuestionnaireResponseItem.item.forEach { + if (!it.isRepeatedGroup) { + this.item.add(it.createQuestionnaireResponseItem()) + } + } + } + } +} + +/** + * Returns a list of answers from the initial values of the questionnaire item. `null` if no initial + * value. + */ +private fun Questionnaire.Item.createQuestionnaireResponseItemAnswers(): + MutableList { + // TODO https://github.com/google/android-fhir/issues/2161 + // The rule can be by-passed if initial value was set by an initial-expression. + // The [ResourceMapper] at L260 wrongfully sets the initial property of questionnaire after + // evaluation of initial-expression. + require(answerOption.isEmpty() || initial.isEmpty() || initialExpression != null) { + "Questionnaire item $linkId has both initial value(s) and has answerOption. See rule que-11 at https://www.hl7.org/fhir/questionnaire-definitions.html#Questionnaire.item.initial." + } + + // https://build.fhir.org/ig/HL7/sdc/behavior.html#initial + // quantity given as initial without value is for unit reference purpose only. Answer conversion + // not needed + val initialFirstRep = initial.singleOrNull() + if ( + answerOption.initialSelected.isEmpty() && + (initial.isEmpty() || + (initialFirstRep?.value != null && initialFirstRep.value.asQuantity()?.value == null)) + ) { + return mutableListOf() + } + + if ( + type.value == Questionnaire.QuestionnaireItemType.Group || + type.value == Questionnaire.QuestionnaireItemType.Display + ) { + throw IllegalArgumentException( + "Questionnaire item $linkId has initial value(s) and is a group or display item. See rule que-8 at https://www.hl7.org/fhir/questionnaire-definitions.html#Questionnaire.item.initial.", + ) + } + + if ((answerOption.initialSelected.size > 1 || initial.size > 1) && repeats?.value == false) { + throw IllegalArgumentException( + "Questionnaire item $linkId can only have multiple initial values for repeating items. See rule que-13 at https://www.hl7.org/fhir/questionnaire-definitions.html#Questionnaire.item.initial.", + ) + } + + val thiso: List = initial.map { it.value } + val this1: List = answerOption.initialSelected + + return mutableListOf() +} + +/** + * Flatten a nested list of [Questionnaire.Item] recursively and returns a flat list of all items + * into list embedded at any level + */ +fun List.flattened(): List = + mutableListOf().also { flattenInto(it) } + +private fun List.flattenInto( + output: MutableList, +) { + forEach { + output.add(it) + it.item.flattenInto(output) + } +} + +internal val Questionnaire.Item.isRepeatedGroup: Boolean + get() = type.value == Questionnaire.QuestionnaireItemType.Group && repeats?.value == true + +// TODO: Move this elsewhere. +val Resource.logicalId: String + get() { + return this.id?.substringAfter("/")?.substringBefore("/") + ?: throw IllegalStateException("Id field cannot be null") + } + +internal fun Questionnaire.Item.readCustomStyleExtension(styleUrl: StyleUrl): String? { + // Find the base extension + val baseExtension = extension.find { it.url == StyleUrl.BASE.url } + baseExtension?.let { ext -> + // Extract nested extension based on the given StyleUrl + ext.extension.forEach { nestedExt -> + if (nestedExt.url == styleUrl.url) { + return nestedExt.value?.asString()?.value?.value + } + } + } + return null +} diff --git a/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/extensions/MoreQuestionnaireResponseItemAnswerComponents.kt b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/extensions/MoreQuestionnaireResponseItemAnswerComponents.kt new file mode 100644 index 000000000..8187ae9e4 --- /dev/null +++ b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/extensions/MoreQuestionnaireResponseItemAnswerComponents.kt @@ -0,0 +1,22 @@ +/* + * Copyright 2022-2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.ohs.fhir.datacapture.extensions + +import com.google.fhir.model.r4.QuestionnaireResponse + +internal fun List.hasDifferentAnswerSet(answers: List) = + this.size != answers.size || this.map { it.value }.zip(answers).any { (v1, v2) -> v1 != v2 } diff --git a/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/extensions/MoreQuestionnaireResponseItemAnswers.kt b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/extensions/MoreQuestionnaireResponseItemAnswers.kt new file mode 100644 index 000000000..e702fd319 --- /dev/null +++ b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/extensions/MoreQuestionnaireResponseItemAnswers.kt @@ -0,0 +1,39 @@ +/* + * Copyright 2022-2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.ohs.fhir.datacapture.extensions + +import com.google.fhir.model.r4.Element +import com.google.fhir.model.r4.QuestionnaireResponse + +val QuestionnaireResponse.Item.Answer.elementValue: Element? + get() = + this.value?.let { + when (it) { + is QuestionnaireResponse.Item.Answer.Value.Attachment -> it.value + is QuestionnaireResponse.Item.Answer.Value.Boolean -> it.value + is QuestionnaireResponse.Item.Answer.Value.Coding -> it.value + is QuestionnaireResponse.Item.Answer.Value.Date -> it.value + is QuestionnaireResponse.Item.Answer.Value.DateTime -> it.value + is QuestionnaireResponse.Item.Answer.Value.Decimal -> it.value + is QuestionnaireResponse.Item.Answer.Value.Integer -> it.value + is QuestionnaireResponse.Item.Answer.Value.Quantity -> it.value + is QuestionnaireResponse.Item.Answer.Value.Reference -> it.value + is QuestionnaireResponse.Item.Answer.Value.String -> it.value + is QuestionnaireResponse.Item.Answer.Value.Time -> it.value + is QuestionnaireResponse.Item.Answer.Value.Uri -> it.value + } + } diff --git a/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/extensions/MoreQuestionnaireResponseItemComponents.kt b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/extensions/MoreQuestionnaireResponseItemComponents.kt new file mode 100644 index 000000000..42206cb99 --- /dev/null +++ b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/extensions/MoreQuestionnaireResponseItemComponents.kt @@ -0,0 +1,58 @@ +/* + * Copyright 2023-2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.ohs.fhir.datacapture.extensions + +import com.google.fhir.model.r4.Questionnaire +import com.google.fhir.model.r4.QuestionnaireResponse + +/** + * Pre-order list of descendants of the questionnaire response item (inclusive of the current item). + */ +val QuestionnaireResponse.Item.descendant: List + get() = mutableListOf().also { appendDescendantTo(it) } + +private fun QuestionnaireResponse.Item.appendDescendantTo( + output: MutableList, +) { + output.add(this) + item.forEach { it.appendDescendantTo(output) } + answer.forEach { answer -> answer.item.forEach { it.appendDescendantTo(output) } } +} + +/** + * Copies nested items under `questionnaireItem` to each answer without children. The hierarchy and + * order of nested items will be retained as specified in the standard. + * + * Existing answers with nested items will not be modified because the nested items may contain + * answers already. + * + * This should be used when + * - a new answer is added to a question with nested questions, or + * - a new answer is added to a repeated group (in which case this indicates a new instance of the + * repeated group will be added to the final questionnaire response). + * + * See https://www.hl7.org/fhir/questionnaireresponse.html#notes for more details. + */ +internal fun QuestionnaireResponse.Item.Builder.copyNestedItemsToChildlessAnswers( + questionnaireItem: Questionnaire.Item, +) { + answer + .filter { it.item.isEmpty() } + .forEach { + it.item = questionnaireItem.createNestedQuestionnaireResponseItems().toMutableList() + } +} diff --git a/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/extensions/MoreQuestionnaireResponses.kt b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/extensions/MoreQuestionnaireResponses.kt new file mode 100644 index 000000000..7e5105bf0 --- /dev/null +++ b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/extensions/MoreQuestionnaireResponses.kt @@ -0,0 +1,179 @@ +/* + * Copyright 2022-2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.ohs.fhir.datacapture.extensions + +import com.google.fhir.model.r4.DateTime +import com.google.fhir.model.r4.Questionnaire +import com.google.fhir.model.r4.QuestionnaireResponse + +internal const val EXTENSION_LAST_LAUNCHED_TIMESTAMP: String = + "http://github.com/google-android/questionnaire-lastLaunched-timestamp" + +/** Pre-order list of all questionnaire response items in the questionnaire. */ +val QuestionnaireResponse.Builder.allItems: List + get() = item.flatMap { it.build().descendant } + +/** + * Packs repeated groups under the same questionnaire response item. + * + * Repeated groups need some massaging before the questionnaire view model can interpret them + * correctly. This is because they are flattened out and nested directly under the parent in the + * FHIR data format. + * + * More details on the structure of questionnaire responses: + * https://build.fhir.org/questionnaireresponse.html#link. + * + * Specifically, this function will go through the items in the questionnaire response, and if + * multiple questionnaire response items exist for the same repeated group (identified by the link + * id), they will be converted into answers under the same questionnaire response item so that there + * is a 1:1 relationship from questionnaire item for the repeated group to questionnaire response + * item for the same repeated group. + * + * This function should be called before the questionnaire view model accepts an + * application-provided questionnaire response. + * + * See also [unpackRepeatedGroups]. + * + * @throws IllegalArgumentException if more than one sibling questionnaire items (nested under the + * questionnaire root or the same parent item) share the same id + */ +internal fun QuestionnaireResponse.Builder.packRepeatedGroups(questionnaire: Questionnaire) { + item = item.packRepeatedGroups(questionnaire.item).toMutableList() +} + +private fun List.packRepeatedGroups( + questionnaireItems: List, +): List { + return groupByAndZipByLinkId(questionnaireItems, map { it.build() }) { + questionnaireItems, + questionnaireResponseItems, + -> + if (questionnaireItems.isEmpty()) { + return@groupByAndZipByLinkId questionnaireResponseItems.map { it.toBuilder() } + } + + val questionnaireItem = questionnaireItems.single() + + val updatedResponseItems = + questionnaireResponseItems.map { responseItem -> + responseItem + .toBuilder() + .apply { item = item.packRepeatedGroups(questionnaireItem.item).toMutableList() } + .build() + } + + if ( + questionnaireItem.type.value == Questionnaire.QuestionnaireItemType.Group && + questionnaireItem.repeats?.value == true + ) { + listOf( + QuestionnaireResponse.Item.Builder(questionnaireItem.linkId.toBuilder()).apply { + linkId = questionnaireItem.linkId.toBuilder() + answer = + updatedResponseItems + .map { responseItem -> + QuestionnaireResponse.Item.Answer.Builder().apply { + item = responseItem.toBuilder().item.toMutableList() + } + } + .toMutableList() + }, + ) + } else { + updatedResponseItems.map { it.toBuilder() } + } + } + .flatten() +} + +/** + * Unpacks repeated groups as separate questionnaire response items under their parent. + * + * Repeated groups need some massaging for their returned data-format; each instance of the group + * should be flattened out to be its own item in the parent, rather than an answer to the main item. + * + * More details: https://build.fhir.org/questionnaireresponse.html#link. + * + * For example, if the group contains 2 questions, and the user answered the group 3 times, this + * function will return a list with 3 responses; each of those responses will have the linkId of the + * provided group, and each will contain an item array with 2 items (the answers to the individual + * questions within this particular group instance). + * + * This function should be called before returning the questionnaire response to the application. + * + * See also [packRepeatedGroups]. + */ +internal fun QuestionnaireResponse.Builder.unpackRepeatedGroups(questionnaire: Questionnaire) { + item = unpackRepeatedGroups(questionnaire.item, item).toMutableList() +} + +private fun unpackRepeatedGroups( + questionnaireItems: List, + questionnaireResponseItems: List, +): List { + return questionnaireItems + .zipByLinkId(questionnaireResponseItems.map { it.build() }) { + questionnaireItem, + questionnaireResponseItem, + -> + unpackRepeatedGroups(questionnaireItem, questionnaireResponseItem.toBuilder()) + } + .flatten() +} + +private fun unpackRepeatedGroups( + questionnaireItem: Questionnaire.Item, + questionnaireResponseItem: QuestionnaireResponse.Item.Builder, +): List { + questionnaireResponseItem.item = + unpackRepeatedGroups(questionnaireItem.item, questionnaireResponseItem.item).toMutableList() + questionnaireResponseItem.answer.forEach { + it.item = unpackRepeatedGroups(questionnaireItem.item, it.item).toMutableList() + } + return if (questionnaireItem.isRepeatedGroup) { + questionnaireResponseItem.answer.map { + QuestionnaireResponse.Item.Builder( + com.google.fhir.model.r4.String.Builder().apply { + value = questionnaireItem.linkId.value + }, + ) + .apply { + linkId = + com.google.fhir.model.r4.String.Builder().apply { + value = questionnaireItem.linkId.value + } + text = + com.google.fhir.model.r4.String.Builder().apply { + value = questionnaireItem.localizedTextAnnotatedString?.toString() + } + item = it.item + } + } + } else { + listOf(questionnaireResponseItem) + } +} + +/** + * Adds a launch timestamp extension to the Questionnaire Response. If the extension @see + * EXTENSION_LAUNCH_TIMESTAMP already exists, it updates its value; otherwise, it adds a new one. + */ +internal val QuestionnaireResponse.launchTimestamp: DateTime? + get() { + val extension = this.extension.firstOrNull { it.url == EXTENSION_LAST_LAUNCHED_TIMESTAMP } + return extension?.value?.asDateTime()?.value + } diff --git a/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/extensions/MoreQuestionnaires.kt b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/extensions/MoreQuestionnaires.kt new file mode 100644 index 000000000..4da187905 --- /dev/null +++ b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/extensions/MoreQuestionnaires.kt @@ -0,0 +1,236 @@ +/* + * Copyright 2023-2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.ohs.fhir.datacapture.extensions + +import com.google.fhir.model.r4.Expression +import com.google.fhir.model.r4.Extension +import com.google.fhir.model.r4.Questionnaire +import com.google.fhir.model.r4.QuestionnaireResponse +import com.google.fhir.model.r4.Resource +import com.google.fhir.model.r4.terminologies.ResourceType + +/** + * The StructureMap url in the + * [target structure-map extension](http://build.fhir.org/ig/HL7/sdc/StructureDefinition-sdc-questionnaire-targetStructureMap.html) + * s. + */ +val Questionnaire.targetStructureMap: String? + get() { + val extensionValue = + this.extension.singleOrNull { it.url == TARGET_STRUCTURE_MAP }?.value ?: return null + return if (extensionValue is Extension.Value.Canonical) { + extensionValue.asString()?.value?.value + } else { + null + } + } + +internal val Questionnaire.variableExpressions: List + get() = + this.extension + .filter { it.url == EXTENSION_VARIABLE_URL } + .mapNotNull { it.value?.asExpression()?.value } + +/** + * A list of extensions that define the resources that provide context for form processing logic: + * https://build.fhir.org/ig/HL7/sdc/StructureDefinition-sdc-questionnaire-launchContext.html + */ +internal val Questionnaire.questionnaireLaunchContexts: List? + get() = + this.extension + .filter { it.url == EXTENSION_SDC_QUESTIONNAIRE_LAUNCH_CONTEXT } + .takeIf { it.isNotEmpty() } + +/** + * Finds the specific variable name [String] at questionnaire [Questionnaire] level + * + * @param variableName the [String] to match the variable at questionnaire [Questionnaire] level + * @return [Expression] the matching expression + */ +internal fun Questionnaire.findVariableExpression(variableName: String): Expression? = + variableExpressions.find { it.name?.value == variableName } + +/** + * Validates each questionnaire launch context extension matches: + * https://build.fhir.org/ig/HL7/sdc/StructureDefinition-sdc-questionnaire-launchContext.html + */ +internal fun validateLaunchContextExtensions(launchContextExtensions: List) = + launchContextExtensions.forEach { launchExtension -> + validateLaunchContextExtension(launchExtension) + } + +/** + * Verifies the existence of extension:name and extension:type with valid name system and type + * values. + */ +private fun validateLaunchContextExtension(launchExtension: Extension) { + val nameCoding = + launchExtension.takeIf { it.url == "name" }?.value?.asCoding() + ?: error( + "The extension:name is missing or is not of type Coding in $EXTENSION_SDC_QUESTIONNAIRE_LAUNCH_CONTEXT", + ) + + val typeCodeType = + launchExtension.takeIf { it.url == "type" }?.value?.asCode() + ?: error( + "The extension:type is missing or is not of type CodeType in $EXTENSION_SDC_QUESTIONNAIRE_LAUNCH_CONTEXT", + ) + + val isValidResourceType = + try { + typeCodeType.value.value?.let { + ResourceType.fromCode(it) + true + } + ?: false + } catch (exception: IllegalArgumentException) { + false + } + + if (CODE_SYSTEM_LAUNCH_CONTEXT != nameCoding.value.system?.value || !isValidResourceType) { + error( + "The extension:name and/or extension:type do not follow the format specified in $EXTENSION_SDC_QUESTIONNAIRE_LAUNCH_CONTEXT", + ) + } +} + +/** + * Filters the provided launch contexts by matching the keys with the `code` values found in the + * "name" extensions. + */ +internal fun filterByCodeInNameExtension( + launchContexts: Map, + launchContextExtensions: List, +): Map { + val nameCodes: Set = + launchContextExtensions + .mapNotNull { extension -> + (extension.takeIf { it.url == "name" }?.value?.asCoding()?.value)?.code?.value + } + .toSet() + + return launchContexts.filterKeys { nameCodes.contains(it) } +} + +/** + * See + * [Extension: target structure map](http://build.fhir.org/ig/HL7/sdc/StructureDefinition-sdc-questionnaire-targetStructureMap.html) + * . + */ +private const val TARGET_STRUCTURE_MAP: String = + "http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-targetStructureMap" + +val Questionnaire.isPaginated: Boolean + get() = item.any { item -> item.displayItemControl == DisplayItemControlType.PAGE } + +/** + * See + * [Extension: Entry mode](http://build.fhir.org/ig/HL7/sdc/StructureDefinition-sdc-questionnaire-entryMode.html) + * . + */ +internal const val EXTENSION_ENTRY_MODE_URL: String = + "http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-entryMode" + +internal const val EXTENSION_SDC_QUESTIONNAIRE_LAUNCH_CONTEXT = + "http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-launchContext" + +internal const val CODE_SYSTEM_LAUNCH_CONTEXT = + "http://hl7.org/fhir/uv/sdc/CodeSystem/launchContext" + +val Questionnaire.entryMode: EntryMode? + get() { + val entryMode = + this.extension + .firstOrNull { it.url == EXTENSION_ENTRY_MODE_URL } + ?.value + ?.toString() + ?.lowercase() + return EntryMode.from(entryMode) + } + +enum class EntryMode(val value: String) { + PRIOR_EDIT("prior-edit"), + RANDOM("random"), + SEQUENTIAL("sequential"), + ; + + companion object { + fun from(type: String?): EntryMode? = entries.find { it.value == type } + } +} + +/** + * Applies `forEach` on each questionnaire item and questionnaire response item pair in the + * questionnaire and the given `questionnaireResponse`. + * + * Questionnaire items and questionnaire response items are visited in pre-order. + * + * Items nested under repeated groups and repeated questions will be repeated for each repeated + * group instance or answer provided by the user. + * + * Note: use this function only with a questionnaire response that has been packed using + * [QuestionnaireResponse.packRepeatedGroups]. + */ +internal suspend fun Questionnaire.forEachItemPair( + questionnaireResponse: QuestionnaireResponse, + forEach: + suspend ( + questionnaireItem: Questionnaire.Item, + questionnaireResponseItem: QuestionnaireResponse.Item, + ) -> Unit, +) { + forEachItemPair(item, questionnaireResponse.item, forEach) +} + +private suspend fun forEachItemPair( + questionnaireItems: List, + questionnaireResponseItems: List, + forEach: + suspend ( + questionnaireItem: Questionnaire.Item, + questionnaireResponseItem: QuestionnaireResponse.Item, + ) -> Unit, +) { + require(questionnaireItems.size == questionnaireResponseItems.size) + questionnaireItems.zip(questionnaireResponseItems).forEach { + (questionnaireItem, questionnaireResponseItem) -> + require(questionnaireItem.linkId == questionnaireResponseItem.linkId) + + // Apply forEach on the current questionnaire item and questionnaire response item + forEach(questionnaireItem, questionnaireResponseItem) + + // For non-repeated groups, simply match the child questionnaire items with child questionnaire + // response items. + if ( + questionnaireItem.type.value == Questionnaire.QuestionnaireItemType.Group && + questionnaireItem.repeats?.value == false && + questionnaireItem.item.isNotEmpty() + ) { + forEachItemPair(questionnaireItem.item, questionnaireResponseItem.item, forEach) + } + + // The following block handles two separate cases: + // 1. questionnaire items nested under repeated group are repeated for each instance of the + // repeated group, each represented as an answer components in the questionnaire response item. + // 2. questionnaire items nested directly under question are repeated for each answer. + if (questionnaireItem.repeats?.value == true && questionnaireItem.item.isNotEmpty()) { + questionnaireResponseItem.answer.forEach { + forEachItemPair(questionnaireItem.item, it.item, forEach) + } + } + } +} diff --git a/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/extensions/MoreStrings.kt b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/extensions/MoreStrings.kt new file mode 100644 index 000000000..533640a7b --- /dev/null +++ b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/extensions/MoreStrings.kt @@ -0,0 +1,34 @@ +/* + * Copyright 2025-2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.ohs.fhir.datacapture.extensions + +import androidx.compose.ui.text.AnnotatedString +import com.ionspin.kotlin.bignum.decimal.BigDecimal +import com.ionspin.kotlin.bignum.decimal.toBigDecimal + +internal fun String.toAnnotatedString(): AnnotatedString { + return AnnotatedString(this) +} + +internal fun String.toBigDecimalOrNull(): BigDecimal? = + try { + this.toBigDecimal() + } catch (_: NumberFormatException) { + null + } catch (_: ArithmeticException) { + null + } diff --git a/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/extensions/MoreTypes.kt b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/extensions/MoreTypes.kt new file mode 100644 index 000000000..c3a292939 --- /dev/null +++ b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/extensions/MoreTypes.kt @@ -0,0 +1,68 @@ +/* + * Copyright 2025-2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.ohs.fhir.datacapture.extensions + +import androidx.compose.ui.text.intl.Locale +import com.google.fhir.model.r4.Boolean +import com.google.fhir.model.r4.Coding +import com.google.fhir.model.r4.Date +import com.google.fhir.model.r4.Decimal +import com.google.fhir.model.r4.Integer +import com.google.fhir.model.r4.Quantity + +internal const val EXT_TRANSLATION = "http://hl7.org/fhir/StructureDefinition/translation" + +internal fun com.google.fhir.model.r4.String.getLocalizedText( + lang: String = Locale.current.toLanguageTag(), +): String? { + return getTranslation(lang) ?: getTranslation(lang.split("-").firstOrNull()) ?: value +} + +internal fun com.google.fhir.model.r4.String.getTranslation(l: String?): String? { + for (e in extension) { + if (e.url == EXT_TRANSLATION) { + val langExtValue = e.readStringExtension("lang") + if (langExtValue == l) return e.readStringExtension("content") + } + } + return null +} + +/** + * Converts Quantity to Coding type. The resulting Coding properties are equivalent of Coding.system + * = Quantity.system Coding.code = Quantity.code Coding.display = Quantity.unit + */ +internal fun Quantity.toCoding(): Coding = + Coding(system = this.system, code = this.code, display = this.unit) + +internal fun Coding.hasCode() = !this.code?.value.isNullOrBlank() + +internal fun Coding.hasDisplay() = !this.display?.value.isNullOrBlank() + +internal fun Coding.hasSystem() = !this.system?.value.isNullOrBlank() + +internal fun Coding.hasVersion() = !this.version?.value.isNullOrBlank() + +typealias FhirR4Boolean = Boolean + +typealias FhirR4DateType = Date + +typealias FhirR4Decimal = Decimal + +typealias FhirR4Integer = Integer + +typealias FhirR4String = com.google.fhir.model.r4.String diff --git a/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/fhirpath/ExpressionEvaluator.kt b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/fhirpath/ExpressionEvaluator.kt new file mode 100644 index 000000000..54ea71181 --- /dev/null +++ b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/fhirpath/ExpressionEvaluator.kt @@ -0,0 +1,521 @@ +/* + * Copyright 2023-2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.ohs.fhir.datacapture.fhirpath + +import co.touchlab.kermit.Logger +import com.google.fhir.model.r4.Bundle +import com.google.fhir.model.r4.Enumeration +import com.google.fhir.model.r4.Expression +import com.google.fhir.model.r4.Questionnaire +import com.google.fhir.model.r4.QuestionnaireResponse +import com.google.fhir.model.r4.Resource +import dev.ohs.fhir.datacapture.XFhirQueryResolver +import dev.ohs.fhir.datacapture.extensions.calculatedExpression +import dev.ohs.fhir.datacapture.extensions.findVariableExpression +import dev.ohs.fhir.datacapture.extensions.flattened +import dev.ohs.fhir.datacapture.extensions.isFhirPath +import dev.ohs.fhir.datacapture.extensions.isReferencedBy +import dev.ohs.fhir.datacapture.extensions.isXFhirQuery +import dev.ohs.fhir.datacapture.extensions.variableExpressions +import kotlin.collections.get +import kotlin.let + +/** + * Evaluates an expression and returns its result. + * + * The evaluator works in the context of a [Questionnaire] and the corresponding + * [QuestionnaireResponse]. It is the caller's responsibility to make sure to call the evaluator + * with [Questionnaire.item] and [QuestionnaireResponse.item] that belong to the [Questionnaire] and + * the [QuestionnaireResponse]. + * + * Expressions can be defined at questionnaire level and questionnaire item level. This + * [ExpressionEvaluator] supports evaluation of + * [variable expression](http://hl7.org/fhir/R4/extension-variable.html) defined at either + * questionnaire level or questionnaire item level. + * + * @param questionnaire the [Questionnaire] where the expression belong to + * @param questionnaireResponse the [QuestionnaireResponse] related to the [Questionnaire] + * @param questionnaireItemParentMap the [Map] of items parent + * @param questionnaireLaunchContextMap the [Map] of launchContext names to their resource values + */ +internal class ExpressionEvaluator( + private val questionnaire: Questionnaire, + private val questionnaireResponse: QuestionnaireResponse, + private val questionnaireItemParentMap: Map = emptyMap(), + private val questionnaireLaunchContextMap: Map? = emptyMap(), + private val xFhirQueryResolver: XFhirQueryResolver? = null, +) { + + private val reservedItemVariables = + listOf( + "sct", + "loinc", + "ucum", + "resource", + "rootResource", + "context", + "map-codes", + "questionnaire", + "qItem", + ) + + private val reservedRootVariables = + listOf( + "sct", + "loinc", + "ucum", + "resource", + "rootResource", + "context", + "map-codes", + "questionnaire", + ) + + /** + * Finds all the matching occurrences of variables. For example, when we apply regex to the + * expression "%X + %Y", if we simply groupValues, it returns [%X, X], [%Y, Y] The group with + * index 0 is always the entire matched string (%X and %Y). The indices greater than 0 represent + * groups in the regular expression (X and Y) so we groupValues by first index to get only the + * variables name without % as prefix i.e, ([X, Y]) + * + * If we apply regex to the expression "X + Y", it returns nothing as there are no matching groups + * in this expression + */ + private val variableRegex = Regex("%([A-Za-z0-9\\-]{1,64})") + + /** + * Finds all the matching occurrences of FHIRPaths in x-fhir-query. See: + * https://build.fhir.org/ig/HL7/sdc/expressions.html#x-fhir-query-enhancements + */ + private val xFhirQueryEnhancementRegex = Regex("\\{\\{(.*?)\\}\\}") + + /** + * Variable %questionnaire corresponds to the Questionnaire resource into + * QuestionnaireResponse.questionnaire element. + * https://build.fhir.org/ig/HL7/sdc/expressions.html#fhirpath-supplements + */ + private val questionnaireFhirPathSupplement = "questionnaire" + + /** + * Variable %qitem refer to Questionnaire.item that corresponds to context + * QuestionnaireResponse.item. It is only valid for FHIRPath expressions defined within a + * Questionnaire item. https://build.fhir.org/ig/HL7/sdc/expressions.html#fhirpath-supplements + */ + private val questionnaireItemFhirPathSupplement = "qItem" + + /** Detects if any item into list is referencing a dependent item in its calculated expression */ + internal fun detectExpressionCyclicDependency(items: List) { + items + .flattened() + .filter { it.calculatedExpression != null } + .run { + forEach { current -> + // no calculable item depending on current item should be used as dependency into current + // item + this.forEach { dependent -> + check(!(current.isReferencedBy(dependent) && dependent.isReferencedBy(current))) { + "${current.linkId} and ${dependent.linkId} have cyclic dependency in expression based extension" + } + } + } + } + } + + /** + * Returns the evaluation result of the expression. + * + * FHIRPath supplements are handled according to + * https://build.fhir.org/ig/HL7/sdc/expressions.html#fhirpath-supplements. + * + * %resource = [QuestionnaireResponse] %context = [QuestionnaireResponse.Item] + */ + fun evaluateExpression( + expression: Expression?, + variables: Map = emptyMap(), + ): List { + if (expression == null) return emptyList() + return r4FhirPathEngine.evaluateExpression( + expression.expression?.value ?: "", + questionnaireResponse, + variables, + ) as List + } + + /** + * Returns a list of [Any] evaluation value result of an expression, including cqf-expression and + * cqf-calculatedValue expressions + */ + fun evaluateExpressionValue(expression: Expression): List? { + if (!expression.isFhirPath) { + throw UnsupportedOperationException("${expression.language} not supported yet") + } + return try { + evaluateExpression(expression) + } catch (e: Exception) { + Logger.w("Could not evaluate expression ${expression.expression} with FHIRPathEngine", e) + null + } + } + + /** + * Returns a list of pair of item and the calculated and evaluated value for all items with + * calculated expression extension, which is dependent on value of updated response + */ + fun evaluateAllAffectedCalculatedExpressions( + questionnaireItem: Questionnaire.Item, + ): List { + return questionnaire.item + .flattened() + .filter { item -> + // Condition 1. item is calculable + // Condition 2. item answer depends on the updated item answer OR has a variable dependency + item.calculatedExpression != null && + (questionnaireItem.isReferencedBy(item) || + findDependentVariables(item.calculatedExpression!!).isNotEmpty()) + } + .map { item -> + // TODO: Pass the questionnaire response item corresponding to the + // questionnaire item with the calculated expression for the FHIRPath supplement + // `%context`. + val updatedAnswer = evaluateExpression(item.calculatedExpression!!) + item to updatedAnswer + } + } + + /** + * Returns the evaluated value of [calculatedExpression] from the given [questionnaireItem]. A + * [NullPointerException] will be thrown if [calculatedExpression] is not present. + */ + fun evaluateCalculatedExpression( + questionnaireItem: Questionnaire.Item, + questionnaireResponseItem: QuestionnaireResponse.Item? = null, + ): List { + val variables = + mutableMapOf().apply { + put("questionnaire", questionnaire) + put("qItem", questionnaireItem) + questionnaireResponseItem?.let { put("context", it) } + questionnaireLaunchContextMap?.let { putAll(it) } + } + return evaluateExpression(questionnaireItem.calculatedExpression!!, variables) + } + + /** + * Evaluates variable expression defined at questionnaire item level and returns the evaluated + * result. + * + * Parses the expression using regex [Regex] for variable (For example: A variable name could be + * %weight) and build a list of variables that the expression contains and for every variable, we + * first find it at questionnaire item, then up in the ancestors and then at questionnaire level, + * if found we get their expressions and pass them into the same function to evaluate its value + * recursively, we put the variable name and its evaluated value into the map [Map] to use this + * map to pass into fhirPathEngine's evaluate method to apply the evaluated values to the + * expression being evaluated. + * + * @param expression the [Expression] Variable expression Questionnaire.Questionnaire.Item>] of + * child to parent + * @param questionnaireItem the [Questionnaire.Item] where this expression is defined, + * @param variablesMap the [Map] of variables, the default value is empty map + * @return [Any] the result of expression + */ + internal suspend fun evaluateQuestionnaireItemVariableExpression( + expression: Expression, + questionnaireItem: Questionnaire.Item, + variablesMap: MutableMap = mutableMapOf(), + ): Any? { + require( + questionnaireItem.variableExpressions.any { + it.name == expression.name && it.expression == expression.expression + }, + ) { + "The expression should come from the same questionnaire item" + } + extractItemDependentVariables( + expression, + questionnaireItem, + variablesMap, + ) + + return evaluateVariable( + expression, + variablesMap, + ) + } + + /** + * Parses the expression using regex [Regex] for variable and build a map of variables and its + * values respecting the scope and hierarchy level + * + * @param expression the [Expression] expression to find variables applicable + * @param questionnaireItem the [Questionnaire.Item] where this expression + * @param variablesMap the [Map] of variables, the default value is empty map is + * defined + */ + internal suspend fun extractItemDependentVariables( + expression: Expression, + questionnaireItem: Questionnaire.Item, + variablesMap: MutableMap = mutableMapOf(), + ): MutableMap { + questionnaireLaunchContextMap?.let { variablesMap.putAll(it) } + findDependentVariables(expression) + .filterNot { variable -> reservedItemVariables.contains(variable) } + .forEach { variableName -> + if (variablesMap[variableName] == null) { + findAndEvaluateVariable( + variableName, + questionnaireItem, + variablesMap, + ) + } + } + return variablesMap.apply { + put(questionnaireFhirPathSupplement, questionnaire) + put(questionnaireItemFhirPathSupplement, questionnaireItem) + } + } + + /** + * Evaluates variable expression defined at questionnaire level and returns the evaluated result. + * + * Parses the expression using [Regex] for variable (For example: A variable name could be + * %weight) and build a list of variables that the expression contains and for every variable, we + * first find it at questionnaire level, if found we get their expressions and pass them into the + * same function to evaluate its value recursively, we put the variable name and its evaluated + * value into the map [Map] to use this map to pass into fhirPathEngine's evaluate method to apply + * the evaluated values to the expression being evaluated. + * + * @param expression the [Expression] Variable expression + * @param variablesMap the [Map] of variables, the default value is empty map + * @return [Any] the result of expression + */ + internal suspend fun evaluateQuestionnaireVariableExpression( + expression: Expression, + variablesMap: MutableMap = mutableMapOf(), + ): Any? { + findDependentVariables(expression) + .filterNot { variable -> reservedRootVariables.contains(variable) } + .forEach { variableName -> + questionnaire.findVariableExpression(variableName)?.let { expression -> + if (expression.name?.value != null && variablesMap[expression.name!!.value] == null) { + variablesMap[expression.name!!.value!!] = + evaluateQuestionnaireVariableExpression( + expression, + variablesMap, + ) + } + } + } + + return evaluateVariable( + expression, + variablesMap, + ) + } + + /** + * Creates an x-fhir-query string for evaluation. For this, it evaluates both variables and + * fhir-paths in the expression. + */ + internal fun createXFhirQueryFromExpression( + expression: Expression, + variablesMap: Map = emptyMap(), + ): String? { + // get all dependent variables and their evaluated values + val variablesEvaluatedPairs = + variablesMap + .filterKeys { expression.expression?.value?.contains("{{%$it}}") == true } + .map { Pair("{{%${it.key}}}", it.value ?: "") } + + val fhirPathsEvaluatedPairs = + questionnaireLaunchContextMap + ?.toMutableMap() + .takeIf { !it.isNullOrEmpty() } + ?.also { it[questionnaireFhirPathSupplement] = questionnaire } + ?.let { evaluateXFhirEnhancement(expression, it) } + ?: emptyList() + + return (variablesEvaluatedPairs + fhirPathsEvaluatedPairs).fold(expression.expression?.value) { + acc: String?, + pair: Pair, + -> + acc?.replace(pair.first, pair.second as String) + } + } + + /** + * Evaluates an x-fhir-query that contains fhir-paths, returning a list of pairs. The first + * element in the pair is the FhirPath expression surrounded by curly brackets {{ fhir.path }}, + * and the second element is the evaluated string result from evaluating the resource passed in. + * + * @param expression x-fhir-query expression containing a FHIRpath, e.g. + * Practitioner?active=true&{{Practitioner.name.family}} + * @param launchContextMap the launch context to evaluate the expression against + */ + private fun evaluateXFhirEnhancement( + expression: Expression, + launchContextMap: Map, + ): List> { + return xFhirQueryEnhancementRegex + .findAll(expression.expression?.value.toString()) + .map { it.groupValues } + .map { (fhirPathWithParentheses, fhirPath) -> + val resourceType = extractResourceTypeFromPath(fhirPath) + val evaluatedResult = + evaluateFhirPathToString( + expression = fhirPath, + resource = launchContextMap[resourceType], + ) + + // If the result of evaluating the FHIRPath expressions is an invalid query, it returns + // null. As per the spec: + // Systems SHOULD log it and continue with extraction as if the query had returned no + // data. + // See : http://build.fhir.org/ig/HL7/sdc/extraction.html#structuremap-based-extraction + if (evaluatedResult.isEmpty()) { + Logger.w( + "$fhirPath evaluated to null. The expression is either invalid, or the " + + "expression returned no, or more than one resource. The expression will be " + + "replaced with a blank string.", + ) + } + fhirPathWithParentheses to evaluatedResult + } + .toList() + } + + private fun findDependentVariables(expression: Expression) = + variableRegex + .findAll(expression.expression?.value.toString()) + .map { it.groupValues[1] } + .toList() + + /** + * Finds the dependent variables at questionnaire item level first, then in ancestors and then at + * questionnaire level + * + * @param variableName the [String] to match the variable in the ancestors + * @param questionnaireItem the [Questionnaire.Item] from where we have to track hierarchy up in + * the ancestors + * @param variablesMap the [Map] of variables + */ + private suspend fun findAndEvaluateVariable( + variableName: String, + questionnaireItem: Questionnaire.Item, + variablesMap: MutableMap = mutableMapOf(), + ) { + // First, check the questionnaire item itself + val evaluatedValue = + questionnaireItem.findVariableExpression(variableName)?.let { expression -> + evaluateQuestionnaireItemVariableExpression( + expression, + questionnaireItem, + variablesMap, + ) + } // Secondly, check the ancestors of the questionnaire item + ?: findVariableInAncestors(variableName, questionnaireItem)?.let { + (questionnaireItem, expression) -> + evaluateQuestionnaireItemVariableExpression( + expression, + questionnaireItem, + variablesMap, + ) + } // Finally, check the variables defined on the questionnaire itself + ?: questionnaire.findVariableExpression(variableName)?.let { expression -> + evaluateQuestionnaireVariableExpression( + expression, + variablesMap, + ) + } + + evaluatedValue?.also { variablesMap[variableName] = it } + } + + /** + * Finds the questionnaire item having specific variable name [String] in the ancestors of + * questionnaire item [Questionnaire.Item] + * + * @param variableName the [String] to match the variable in the ancestors + * @param questionnaireItem the [Questionnaire.Item] whose ancestors we visit + * @return [Pair] containing [Questionnaire.Item] and an [Expression] + */ + private fun findVariableInAncestors( + variableName: String, + questionnaireItem: Questionnaire.Item, + ): Pair? { + var parent = questionnaireItemParentMap[questionnaireItem] + while (parent != null) { + val expression = parent.findVariableExpression(variableName) + if (expression != null) return Pair(parent, expression) + + parent = questionnaireItemParentMap[parent] + } + return null + } + + /** + * Evaluates the value of variable expression and returns its evaluated value + * + * @param expression the [Expression] the expression to evaluate + * @param dependentVariables the [Map] of variable names to their values + * @return [Any] the result of an expression + */ + private suspend fun evaluateVariable( + expression: Expression, + dependentVariables: Map = emptyMap(), + ) = + try { + require(expression.name?.value?.isNotBlank() == true) { + "Expression name should be a valid expression name" + } + + if (expression.isXFhirQuery) { + checkNotNull(xFhirQueryResolver) { + "XFhirQueryResolver cannot be null. Please provide the XFhirQueryResolver via DataCaptureConfig." + } + + val xFhirExpressionString = + createXFhirQueryFromExpression(expression, dependentVariables) ?: "" + + Bundle.Builder(type = Enumeration(value = Bundle.BundleType.Searchset)) + .apply { + entry = + xFhirQueryResolver + .resolve(xFhirExpressionString) + .map { res -> Bundle.Entry.Builder().apply { resource = res.toBuilder() } } + .toMutableList() + } + .build() + } else if (expression.isFhirPath) { + r4FhirPathEngine + .evaluateExpression( + expression.expression?.value ?: "", + questionnaireResponse, + ) + .firstOrNull() + } else { + throw UnsupportedOperationException( + "${expression.language} not supported for variable-expression yet", + ) + } + } catch (exception: Exception) { + Logger.w("Could not evaluate expression with FHIRPathEngine", exception) + null + } +} + +/** Pair of a [Questionnaire.Item] with its evaluated answers */ +internal typealias ItemToAnswersPair = Pair> diff --git a/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/fhirpath/FhirPathUtils.kt b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/fhirpath/FhirPathUtils.kt new file mode 100644 index 000000000..c12c6f5a5 --- /dev/null +++ b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/fhirpath/FhirPathUtils.kt @@ -0,0 +1,92 @@ +/* + * Copyright 2025-2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.ohs.fhir.datacapture.fhirpath + +import co.touchlab.kermit.Logger +import com.google.fhir.fhirpath.FhirPathEngine +import com.google.fhir.model.r4.Code +import com.google.fhir.model.r4.Coding +import com.google.fhir.model.r4.Date +import com.google.fhir.model.r4.DateTime +import com.google.fhir.model.r4.Decimal +import com.google.fhir.model.r4.Integer +import com.google.fhir.model.r4.Quantity +import com.google.fhir.model.r4.Resource +import com.google.fhir.model.r4.Time +import com.google.fhir.model.r4.Uri + +val r4FhirPathEngine = FhirPathEngine.forR4() + +fun convertToBoolean(result: List): Boolean { + if (result.isEmpty()) return false + if (result.size == 1) return result.first() as Boolean + return result.isNotEmpty() +} + +internal fun convertToString(results: List): String { + return when { + results.isEmpty() -> "" + results.size == 1 -> convertSingleResultToString(results.first()) + else -> results.joinToString(", ") { convertSingleResultToString(it) } + } +} + +private fun convertSingleResultToString(value: Any): String { + return when (value) { + is com.google.fhir.model.r4.String -> value.value ?: "" + is Integer -> value.value?.toString() ?: "" + is Decimal -> value.value?.toString() ?: "" + is com.google.fhir.model.r4.Boolean -> value.value?.toString() ?: "" + is Date -> value.value?.toString() ?: "" + is DateTime -> value.value?.toString() ?: "" + is Time -> value.value?.toString() ?: "" + is Code -> value.value ?: "" + is Uri -> value.value ?: "" + is Coding -> value.display?.value ?: value.code?.value ?: "" + is Quantity -> value.value?.value?.toString() ?: "" + else -> value.toString() + } +} + +internal fun extractResourceTypeFromPath(fhirPath: String): String? { + val trimmedPath = fhirPath.trim() + val firstToken = trimmedPath.split('.', '(', '[', ' ').firstOrNull() ?: return null + return firstToken.takeIf { it.firstOrNull()?.isUpperCase() == true } +} + +internal fun evaluateFhirPathToString( + expression: String, + resource: Resource?, +): String { + return try { + if (resource == null) { + "" + } else { + val results = r4FhirPathEngine.evaluateExpression(expression, resource).toList() + convertToString(results) + } + } catch (throwable: Throwable) { + Logger.e("Error evaluating fhirPath expression $expression to string", throwable) + "" + } +} + +/** + * Evaluates the expressions over list of resources [Resource] and joins to space separated string + */ +internal fun evaluateToDisplay(expressions: List, data: Resource) = + expressions.joinToString(" ") { evaluateFhirPathToString(it, data) } diff --git a/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/theme/Theme.kt b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/theme/Theme.kt new file mode 100644 index 000000000..f7d6ac2bd --- /dev/null +++ b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/theme/Theme.kt @@ -0,0 +1,307 @@ +/* + * Copyright 2021-2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.ohs.fhir.datacapture.theme + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Shapes +import androidx.compose.material3.Typography +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +@Immutable +data class QuestionnaireDimensions( + // Widget related + val itemMarginHorizontal: Dp = 16.dp, + val itemMarginVertical: Dp = 8.dp, + + // Action button + val actionButtonMarginHorizontal: Dp = 16.dp, + val actionButtonMarginVertical: Dp = 16.dp, + + // Title + val titleLayoutMarginHorizontal: Dp = 16.dp, + val titleMarginEnd: Dp = 2.dp, + + // Header + val errorTextAtHeaderMarginTop: Dp = 4.dp, + val headerPaddingBottom: Dp = 4.dp, + val headerMarginBottom: Dp = 16.dp, + val helpButtonMarginStart: Dp = 4.dp, + val helpButtonHeight: Dp = 24.dp, + val helpButtonWidth: Dp = 24.dp, + val helpIconPadding: Dp = 2.dp, + val helpCardMarginTop: Dp = 16.dp, + val helpContainerMarginTop: Dp = 4.dp, + val helpHeaderMarginBottom: Dp = 4.dp, + val helpHeaderMarginHorizontal: Dp = 16.dp, + val helpHeaderMarginTop: Dp = 16.dp, + val helpMarginBottom: Dp = 16.dp, + val helpTextMarginBottom: Dp = 16.dp, + val helpTextMarginHorizontal: Dp = 16.dp, + val prefixPaddingEnd: Dp = 5.dp, + + // Error text + val errorTextMarginHorizontal: Dp = 16.dp, + val errorTextMarginVertical: Dp = 16.dp, + + // Attachment + val attachmentActionButtonIconSize: Dp = 24.dp, + val attachmentActionButtonMarginEnd: Dp = 8.dp, + val attachmentActionButtonPaddingHorizontal: Dp = 16.dp, + val attachmentDividerHeight: Dp = 1.dp, + val attachmentDividerMarginTop: Dp = 16.dp, + val attachmentErrorMarginTop: Dp = 4.dp, + val attachmentPreviewDividerMarginTop: Dp = 8.dp, + val attachmentPreviewFileIconMargin: Dp = 16.dp, + val attachmentPreviewMarginTop: Dp = 8.dp, + val attachmentPreviewPhotoHeight: Dp = 72.dp, + val attachmentPreviewPhotoWidth: Dp = 72.dp, + val attachmentPreviewTitleMarginHorizontal: Dp = 8.dp, + val attachmentUploadedLabelMarginBottom: Dp = 4.dp, + val attachmentUploadedLabelMarginTop: Dp = 16.dp, + + // Auto Complete + val autoCompleteChipMargin: Dp = 10.dp, + val autoCompleteChipMarginBottom: Dp = 50.dp, + val autoCompleteTextFieldContentPadding: Dp = 16.dp, + + // Barcode + val barcodeQuestionMarginEnd: Dp = 8.dp, + val barcodeQuestionMarginStart: Dp = 8.dp, + + // Option item + val optionItemAfterTextPadding: Dp = 24.dp, + val optionItemBetweenTextAndIconPadding: Dp = 16.dp, + val optionItemMarginHorizontal: Dp = 16.dp, + val optionItemMarginVertical: Dp = 16.dp, + val optionItemPadding: Dp = 16.dp, + + // Date picker and Date time picker + val datePickerAndTimePickerGap: Dp = 16.dp, + + // Dialog + val dialogConfirmationButtonPadding: Dp = 8.dp, + val dialogContentScrollMarginVertical: Dp = 8.dp, + val dialogOptionScrollMarginTop: Dp = 16.dp, + val dialogPadding: Dp = 24.dp, + val dialogSubtitleMarginTop: Dp = 16.dp, + val dialogTitleMarginBottom: Dp = 4.dp, + + // Dropdown + val dropDownPadding: Dp = 16.dp, + val dropDownClearIconMarginEnd: Dp = 38.dp, + val dropDownClearIconMarginTop: Dp = 4.dp, + + // Item Answer Media + val itemAnswerMediaImageSize: Dp = 48.dp, + val choiceButtonImage: Dp = 48.dp, + + // Item Media + val itemMediaImageMarginHorizontal: Dp = 16.dp, + val itemMediaImageMarginVertical: Dp = 8.dp, + val itemMediaImageMaxHeight: Dp = 200.dp, + val itemMediaImageMaxWidth: Dp = 200.dp, + val itemMediaImagePreviewMaxWidth: Dp = 200.dp, + val itemMediaImagePreviewMaxHeight: Dp = 200.dp, + + // Simple question + val simpleQuestionViewAnswerMarginTop: Dp = 8.dp, + val simpleQuestionViewDividerMarginTop: Dp = 8.dp, + + // Radio Button + val paddingBetweenTextAndIcon: Dp = 16.dp, + val paddingAfterText: Dp = 24.dp, + + // Icon + val iconDrawablePadding: Dp = 16.dp, + val iconInset: Dp = 16.dp, + + // Bottom Container + val bottomContainerPaddingVertical: Dp = 8.dp, + + // Error Icon + val errorIconWidth: Dp = 20.dp, + val errorIconHeight: Dp = 20.dp, + val errorIconMarginEnd: Dp = 4.dp, +) + +@Immutable +data class QuestionnaireShapes( + val attachmentPreviewPhoto: RoundedCornerShape = RoundedCornerShape(8.dp), + val addRepeatedGroupButton: RoundedCornerShape = RoundedCornerShape(4.dp), + val deleteRepeatedGroupButton: RoundedCornerShape = RoundedCornerShape(4.dp), +) + +@Immutable +data class QuestionnaireTextStyles( + val groupTypeQuestionText: TextStyle, + val questionText: TextStyle, + val reviewModeQuestionText: TextStyle, + val subtitleText: TextStyle, + val helpHeaderText: TextStyle, + val helpText: TextStyle, + val reviewModeAnswerText: TextStyle, + val reviewModeNotAnsweredText: TextStyle, + val dropDownText: TextStyle, + val dropDownSelectedText: TextStyle, + val errorText: TextStyle, + val attachmentUploadedLabel: TextStyle, + val attachmentPreviewTitle: TextStyle, + val dialogTitle: TextStyle, + val validationDialogTitle: TextStyle, + val validationDialogBody: TextStyle, + val titleText: TextStyle, +) { + internal constructor( + typography: Typography, + ) : this( + groupTypeQuestionText = typography.titleMedium, + questionText = typography.titleMedium, + reviewModeQuestionText = typography.titleMedium, + subtitleText = typography.bodyMedium, + helpHeaderText = typography.titleSmall, + helpText = typography.bodyMedium, + reviewModeAnswerText = typography.bodyMedium, + reviewModeNotAnsweredText = typography.bodyMedium, + dropDownText = typography.bodyLarge, + dropDownSelectedText = typography.bodyLarge, + errorText = typography.bodySmall, + attachmentUploadedLabel = typography.titleSmall, + attachmentPreviewTitle = typography.bodyLarge, + dialogTitle = typography.titleMedium, + validationDialogTitle = typography.headlineSmall, + validationDialogBody = typography.bodyMedium, + titleText = typography.titleLarge, + ) +} + +@Immutable +data class QuestionnaireAlphas( + val reviewModeAnswer: Float = 0.5f, + val reviewModeDivider: Float = 0.5f, +) + +/** Local composition providers for Questionnaire theme. */ +val LocalQuestionnaireDimensions = staticCompositionLocalOf { QuestionnaireDimensions() } +val LocalQuestionnaireShapes = staticCompositionLocalOf { QuestionnaireShapes() } +val LocalQuestionnaireTextStyles = staticCompositionLocalOf { + QuestionnaireTextStyles(Typography()) +} +val LocalQuestionnaireAlphas = staticCompositionLocalOf { QuestionnaireAlphas() } + +/** + * The default theme applied to the questionnaire rendered using Compose. + * + * To override the theme attributes, you can provide custom values through the composition locals: + * - LocalQuestionnaireDimensions + * - LocalQuestionnaireShapes + * - LocalQuestionnaireTextStyles + * - LocalQuestionnaireAlphas + * + * Example: + * ``` + * QuestionnaireTheme { + * // Your questionnaire composables + * } + * ``` + * + * @param darkTheme Whether to use dark theme colors. Defaults to system theme. + * @param dimensions Custom dimensions for the questionnaire. Defaults to QuestionnaireDimensions(). + * @param textStyles Custom textStyles for the questionnaire. + * @param shapes Custom shapes for the questionnaire. Defaults to QuestionnaireShapes(). + * @param alphas Custom alpha values for the questionnaire. Defaults to QuestionnaireAlphas(). + * @param content The composable content to be themed. + */ +@Composable +fun QuestionnaireTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + colorScheme: ColorScheme = + if (darkTheme) { + darkColorScheme() + } else { + lightColorScheme() + }, + typography: Typography = Typography(), + textStyles: QuestionnaireTextStyles = QuestionnaireTextStyles(typography), + dimensions: QuestionnaireDimensions = QuestionnaireDimensions(), + shapes: QuestionnaireShapes = QuestionnaireShapes(), + alphas: QuestionnaireAlphas = QuestionnaireAlphas(), + content: @Composable () -> Unit, +) { + val materialShapes = + Shapes( + small = RoundedCornerShape(4.dp), + medium = RoundedCornerShape(8.dp), + large = RoundedCornerShape(16.dp), + ) + + MaterialTheme( + colorScheme = colorScheme, + typography = typography, + shapes = materialShapes, + ) { + CompositionLocalProvider( + LocalQuestionnaireDimensions provides dimensions, + LocalQuestionnaireShapes provides shapes, + LocalQuestionnaireTextStyles provides textStyles, + LocalQuestionnaireAlphas provides alphas, + ) { + content() + } + } +} + +/** Object to access the current questionnaire theme values. */ +object QuestionnaireTheme { + /** + * Retrieves the current [QuestionnaireDimensions] at the call site's position in the hierarchy. + */ + val dimensions: QuestionnaireDimensions + @Composable get() = LocalQuestionnaireDimensions.current + + /** Retrieves the current [QuestionnaireShapes] at the call site's position in the hierarchy. */ + val shapes: QuestionnaireShapes + @Composable get() = LocalQuestionnaireShapes.current + + /** + * Retrieves the current [QuestionnaireTextStyles] at the call site's position in the hierarchy. + */ + val textStyles: QuestionnaireTextStyles + @Composable get() = LocalQuestionnaireTextStyles.current + + /** Retrieves the current [QuestionnaireAlphas] at the call site's position in the hierarchy. */ + val alphas: QuestionnaireAlphas + @Composable get() = LocalQuestionnaireAlphas.current + + /** Retrieves the current Material3 [ColorScheme] from MaterialTheme. */ + val colorScheme + @Composable get() = MaterialTheme.colorScheme + + /** Retrieves the current Material3 [Typography] from MaterialTheme. */ + val typography + @Composable get() = MaterialTheme.typography +} diff --git a/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/validation/AnswerConstraintValidator.kt b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/validation/AnswerConstraintValidator.kt new file mode 100644 index 000000000..4e86992ba --- /dev/null +++ b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/validation/AnswerConstraintValidator.kt @@ -0,0 +1,40 @@ +/* + * Copyright 2022-2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.ohs.fhir.datacapture.validation + +import com.google.fhir.model.r4.Expression +import com.google.fhir.model.r4.Questionnaire +import com.google.fhir.model.r4.QuestionnaireResponse + +/** Validates [QuestionnaireResponse.Item.Answer] against a particular constraint. */ +internal interface AnswerConstraintValidator : ConstraintValidator { + /** + * Validates whether the [answer] satisfies any constraints of the [questionnaireItem] according + * to the [Structured Data Capture Implementation Guide] + * (http://build.fhir.org/ig/HL7/sdc/behavior.html). + * + * This does not validate the consistency between the structure of the [answer] and its + * descendants and that of the [questionnaireItem] and its descendants. + * + * [Learn more](https://www.hl7.org/fhir/questionnaireresponse.html#link). + */ + suspend fun validate( + questionnaireItem: Questionnaire.Item, + answer: QuestionnaireResponse.Item.Answer, + expressionEvaluator: suspend (Expression) -> Any?, + ): ConstraintValidator.Result +} diff --git a/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/validation/AnswerExtensionConstraintValidator.kt b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/validation/AnswerExtensionConstraintValidator.kt new file mode 100644 index 000000000..f49539d7e --- /dev/null +++ b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/validation/AnswerExtensionConstraintValidator.kt @@ -0,0 +1,64 @@ +/* + * Copyright 2022-2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.ohs.fhir.datacapture.validation + +import com.google.fhir.model.r4.Expression +import com.google.fhir.model.r4.Questionnaire +import com.google.fhir.model.r4.QuestionnaireResponse +import dev.ohs.fhir.datacapture.extensions.cqfCalculatedValueExpression + +/** + * Validates [QuestionnaireResponse.Item.Answer] against a constraint defined in an extension. + * + * For example, an implementation may validate answers against a min value defined in the minValue + * extension (see http://hl7.org/fhir/R4/extension-minvalue.html). + * + * @param url the URL of the extension that defines the constraint + * @param predicate the predicate that determines if the answer is valid + * @param messageGenerator a generator of error message if the answer is not valid + */ +internal open class AnswerExtensionConstraintValidator( + val url: String, + val predicate: + ( + /*constraintValue*/ + Any, + QuestionnaireResponse.Item.Answer, + ) -> Boolean, + val messageGenerator: suspend (Any) -> String, +) : AnswerConstraintValidator { + override suspend fun validate( + questionnaireItem: Questionnaire.Item, + answer: QuestionnaireResponse.Item.Answer, + expressionEvaluator: suspend (Expression) -> Any?, + ): ConstraintValidator.Result { + if (questionnaireItem.extension.isNotEmpty()) { + val extension = questionnaireItem.extension.find { it.url == url } + val extensionValue = + extension.cqfCalculatedValueExpression?.let { expressionEvaluator(it) } ?: extension?.value + + // Only checks constraint if both extension and answer have a value + if (extensionValue != null && answer.value != null && predicate(extensionValue, answer)) { + return ConstraintValidator.Result( + false, + messageGenerator(extensionValue), + ) + } + } + return ConstraintValidator.Result(true, null) + } +} diff --git a/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/validation/ConstraintItemExtensionValidator.kt b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/validation/ConstraintItemExtensionValidator.kt new file mode 100644 index 000000000..e3e424859 --- /dev/null +++ b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/validation/ConstraintItemExtensionValidator.kt @@ -0,0 +1,91 @@ +/* + * Copyright 2022-2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.ohs.fhir.datacapture.validation + +import com.google.fhir.model.r4.Enumeration +import com.google.fhir.model.r4.Expression +import com.google.fhir.model.r4.Questionnaire +import com.google.fhir.model.r4.QuestionnaireResponse +import com.google.fhir.model.r4.String +import dev.ohs.fhir.datacapture.extensions.ConstraintSeverityTypes +import dev.ohs.fhir.datacapture.extensions.EXTENSION_QUESTIONNAIRE_CONSTRAINT_EXPRESSION +import dev.ohs.fhir.datacapture.extensions.EXTENSION_QUESTIONNAIRE_CONSTRAINT_HUMAN +import dev.ohs.fhir.datacapture.extensions.EXTENSION_QUESTIONNAIRE_CONSTRAINT_SEVERITY +import dev.ohs.fhir.datacapture.extensions.EXTENSION_QUESTIONNAIRE_CONSTRAINT_URL +import dev.ohs.fhir.datacapture.fhirpath.ExpressionEvaluator +import dev.ohs.fhir.datacapture.fhirpath.convertToBoolean + +/** + * TODO: Add constraint support for global case, create a separate validator, + * https://github.com/google/android-fhir/issues/2479 + */ +internal class ConstraintItemExtensionValidator( + private val expressionEvaluator: ExpressionEvaluator, +) : QuestionnaireResponseItemConstraintValidator { + override suspend fun validate( + questionnaireItem: Questionnaire.Item, + questionnaireResponseItem: QuestionnaireResponse.Item, + ): List { + return questionnaireItem.extension + .filter { extension -> + /** + * TODO: Add constraint support for warning case, update the [Result] data class to also + * include warning state, https://github.com/google/android-fhir/issues/2480 + */ + extension.url == EXTENSION_QUESTIONNAIRE_CONSTRAINT_URL && + ConstraintSeverityTypes.ERROR.code == + extension.extension + .find { it.url == EXTENSION_QUESTIONNAIRE_CONSTRAINT_SEVERITY } + ?.value + ?.asCode() + ?.value + ?.value + } + .map { extension -> + val expression = + Expression.Builder( + language = Enumeration(value = Expression.ExpressionLanguage.Text_Fhirpath), + ) + .apply { + expression = + String.Builder().apply { + value = + extension.extension + .find { it.url == EXTENSION_QUESTIONNAIRE_CONSTRAINT_EXPRESSION } + ?.value + ?.asString() + ?.value + ?.value + } + } + .build() + val isValid = convertToBoolean(expressionEvaluator.evaluateExpression(expression)) + if (isValid) { + ConstraintValidator.Result(true, null) + } else { + val errorMessage = + extension.extension + .find { it.url == EXTENSION_QUESTIONNAIRE_CONSTRAINT_HUMAN } + ?.value + ?.asString() + ?.value + ?.value + ConstraintValidator.Result(false, errorMessage) + } + } + } +} diff --git a/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/validation/ConstraintValidator.kt b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/validation/ConstraintValidator.kt new file mode 100644 index 000000000..a6ce43af7 --- /dev/null +++ b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/validation/ConstraintValidator.kt @@ -0,0 +1,27 @@ +/* + * Copyright 2022-2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.ohs.fhir.datacapture.validation + +/** Validator base interface. */ +internal interface ConstraintValidator { + + /** + * The validation result containing whether the response item is valid and any error message if it + * is not valid. + */ + data class Result(val isValid: Boolean, val errorMessage: String?) +} diff --git a/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/validation/MaxDecimalPlacesValidator.kt b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/validation/MaxDecimalPlacesValidator.kt new file mode 100644 index 000000000..f8f69377f --- /dev/null +++ b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/validation/MaxDecimalPlacesValidator.kt @@ -0,0 +1,60 @@ +/* + * Copyright 2023-2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.ohs.fhir.datacapture.validation + +/** + * A validator to check if the answer (a decimal value) exceeds the maximum number of permitted + * decimal places. + * + * Only decimal types permitted in questionnaires response are subjected to this validation. See + * https://www.hl7.org/fhir/extension-maxdecimalplaces.html + */ +import com.google.fhir.model.r4.Extension +import com.google.fhir.model.r4.Integer +import com.google.fhir.model.r4.QuestionnaireResponse +import dev.ohs.fhir.datacapture.generated.resources.Res +import dev.ohs.fhir.datacapture.generated.resources.max_decimal_validation_error_msg +import org.jetbrains.compose.resources.getString + +private const val MAX_DECIMAL_URL = "http://hl7.org/fhir/StructureDefinition/maxDecimalPlaces" + +internal object MaxDecimalPlacesValidator : + AnswerExtensionConstraintValidator( + url = MAX_DECIMAL_URL, + predicate = { constraintValue: Any, answer: QuestionnaireResponse.Item.Answer -> + val maxDecimalPlaces = getValue(constraintValue) + answer.value != null && + maxDecimalPlaces != null && + answer.value!! + .asDecimal()!! + .value + .value + ?.toStringExpanded() + ?.substringAfter(".") + ?.length!! > maxDecimalPlaces + }, + messageGenerator = { constraintValue: Any -> + getString(Res.string.max_decimal_validation_error_msg, getValue(constraintValue).toString()) + }, + ) + +private fun getValue(constraintValue: Any): Int? = + when (constraintValue) { + is Integer -> constraintValue.value + is Extension.Value.Integer -> constraintValue.asInteger()?.value?.value + else -> null + } diff --git a/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/validation/MaxLengthValidator.kt b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/validation/MaxLengthValidator.kt new file mode 100644 index 000000000..b77d6196c --- /dev/null +++ b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/validation/MaxLengthValidator.kt @@ -0,0 +1,47 @@ +/* + * Copyright 2022-2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.ohs.fhir.datacapture.validation + +import com.google.fhir.model.r4.Expression +import com.google.fhir.model.r4.Questionnaire +import com.google.fhir.model.r4.QuestionnaireResponse + +/** + * A validator to check if the answer exceeds the maximum number of permitted characters. + * + * Only primitive types permitted in questionnaires response are subjected to this validation. See + * https://www.hl7.org/fhir/valueset-item-type.html#expansion + */ +internal object MaxLengthValidator : AnswerConstraintValidator { + override suspend fun validate( + questionnaireItem: Questionnaire.Item, + answer: QuestionnaireResponse.Item.Answer, + expressionEvaluator: suspend (Expression) -> Any?, + ): ConstraintValidator.Result { + if ( + questionnaireItem.maxLength != null && + answer.value != null && + answer.value!!.asString()?.value!!.value!!.length > questionnaireItem.maxLength!!.value!! + ) { + return ConstraintValidator.Result( + false, + "The maximum number of characters that are permitted in the answer is: ${questionnaireItem.maxLength}", + ) + } + return ConstraintValidator.Result(true, null) + } +} diff --git a/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/validation/MaxValueValidator.kt b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/validation/MaxValueValidator.kt new file mode 100644 index 000000000..c336b86fd --- /dev/null +++ b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/validation/MaxValueValidator.kt @@ -0,0 +1,50 @@ +/* + * Copyright 2023-2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.ohs.fhir.datacapture.validation + +import com.google.fhir.model.r4.Extension +import com.google.fhir.model.r4.Integer +import com.google.fhir.model.r4.QuestionnaireResponse +import dev.ohs.fhir.datacapture.enablement.compareFhirValue +import dev.ohs.fhir.datacapture.generated.resources.Res +import dev.ohs.fhir.datacapture.generated.resources.max_value_validation_error_msg +import org.jetbrains.compose.resources.getString + +internal const val MAX_VALUE_EXTENSION_URL = "http://hl7.org/fhir/StructureDefinition/maxValue" + +/** A validator to check if the value of an answer exceeded the permitted value. */ +internal object MaxValueValidator : + AnswerExtensionConstraintValidator( + url = MAX_VALUE_EXTENSION_URL, + predicate = { constraintValue: Any, answer: QuestionnaireResponse.Item.Answer, + -> + answer.value compareFhirValue constraintValue > 0 + }, + messageGenerator = { constraintValue: Any -> + getString( + Res.string.max_value_validation_error_msg, + getValue(constraintValue).toString(), + ) + }, + ) + +private fun getValue(constraintValue: Any): Int? = + when (constraintValue) { + is Integer -> constraintValue.value + is Extension.Value.Integer -> constraintValue.asInteger()?.value?.value + else -> null + } diff --git a/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/validation/MinLengthValidator.kt b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/validation/MinLengthValidator.kt new file mode 100644 index 000000000..5340c4e45 --- /dev/null +++ b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/validation/MinLengthValidator.kt @@ -0,0 +1,61 @@ +/* + * Copyright 2023-2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.ohs.fhir.datacapture.validation + +import com.google.fhir.model.r4.Extension +import com.google.fhir.model.r4.Integer +import dev.ohs.fhir.datacapture.generated.resources.Res +import dev.ohs.fhir.datacapture.generated.resources.min_length_validation_error_msg +import org.jetbrains.compose.resources.getString + +internal const val MIN_LENGTH_EXTENSION_URL = "http://hl7.org/fhir/StructureDefinition/minLength" + +/** + * A validator to check if the answer fulfills the minimum number of permitted characters. + * + *

Only the following primitive types are subjected to this validation: + * 1. Boolean + * 2. Decimal + * 3. Integer + * 4. Date + * 5. Time + * 6. String + * 7. Uri + */ +internal object MinLengthValidator : + AnswerExtensionConstraintValidator( + url = MIN_LENGTH_EXTENSION_URL, + predicate = { constraintValue, answer -> + val minLengthValue = getMinLengthValue(constraintValue) + answer.value != null && + minLengthValue != null && + (answer.value!!.asString()?.value?.value ?: "").length < minLengthValue + }, + messageGenerator = { constraintValue: Any -> + getString( + Res.string.min_length_validation_error_msg, + getMinLengthValue(constraintValue).toString(), + ) + }, + ) + +private fun getMinLengthValue(constraintValue: Any): Int? = + when (constraintValue) { + is Integer -> constraintValue.value + is Extension.Value.Integer -> constraintValue.asInteger()?.value?.value + else -> null + } diff --git a/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/validation/MinValueValidator.kt b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/validation/MinValueValidator.kt new file mode 100644 index 000000000..56470e3ce --- /dev/null +++ b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/validation/MinValueValidator.kt @@ -0,0 +1,50 @@ +/* + * Copyright 2023-2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.ohs.fhir.datacapture.validation + +import com.google.fhir.model.r4.Extension +import com.google.fhir.model.r4.Integer +import com.google.fhir.model.r4.QuestionnaireResponse +import dev.ohs.fhir.datacapture.enablement.compareFhirValue +import dev.ohs.fhir.datacapture.generated.resources.Res +import dev.ohs.fhir.datacapture.generated.resources.min_value_validation_error_msg +import org.jetbrains.compose.resources.getString + +internal const val MIN_VALUE_EXTENSION_URL = "http://hl7.org/fhir/StructureDefinition/minValue" + +/** A validator to check if the value of an answer is at least the permitted value. */ +internal object MinValueValidator : + AnswerExtensionConstraintValidator( + url = MIN_VALUE_EXTENSION_URL, + predicate = { constraintValue: Any, answer: QuestionnaireResponse.Item.Answer, + -> + answer.value compareFhirValue constraintValue < 0 + }, + messageGenerator = { constraintValue: Any -> + getString( + Res.string.min_value_validation_error_msg, + getValue(constraintValue).toString(), + ) + }, + ) + +private fun getValue(constraintValue: Any): Int? = + when (constraintValue) { + is Integer -> constraintValue.value + is Extension.Value.Integer -> constraintValue.asInteger()?.value?.value + else -> null + } diff --git a/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/validation/QuestionnaireResponseItemConstraintValidator.kt b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/validation/QuestionnaireResponseItemConstraintValidator.kt new file mode 100644 index 000000000..fcfd5d79d --- /dev/null +++ b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/validation/QuestionnaireResponseItemConstraintValidator.kt @@ -0,0 +1,38 @@ +/* + * Copyright 2022-2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.ohs.fhir.datacapture.validation + +import com.google.fhir.model.r4.Questionnaire +import com.google.fhir.model.r4.QuestionnaireResponse + +/** Validates [QuestionnaireResponse.Item] against a particular constraint. */ +internal interface QuestionnaireResponseItemConstraintValidator : ConstraintValidator { + /** + * Validates that [questionnaireResponseItem] satisfy a particular constraint of the + * [questionnaireItem] according to the [structured data capture implementation guide] + * (http://build.fhir.org/ig/HL7/sdc/behavior.html). + * + * This does not validate the consistency between the structure of the [questionnaireResponseItem] + * and their descendants and that of the [questionnaireItem] and its descendants. + * + * [Learn more](https://www.hl7.org/fhir/questionnaireresponse.html#link). + */ + suspend fun validate( + questionnaireItem: Questionnaire.Item, + questionnaireResponseItem: QuestionnaireResponse.Item, + ): List +} diff --git a/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/validation/QuestionnaireResponseItemValidator.kt b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/validation/QuestionnaireResponseItemValidator.kt new file mode 100644 index 000000000..8fe4cb07f --- /dev/null +++ b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/validation/QuestionnaireResponseItemValidator.kt @@ -0,0 +1,79 @@ +/* + * Copyright 2022-2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.ohs.fhir.datacapture.validation + +import com.google.fhir.model.r4.Questionnaire +import com.google.fhir.model.r4.QuestionnaireResponse +import dev.ohs.fhir.datacapture.extensions.isHidden +import dev.ohs.fhir.datacapture.fhirpath.ExpressionEvaluator + +internal class QuestionnaireResponseItemValidator( + val expressionEvaluator: ExpressionEvaluator, +) { + + /** Validators for [QuestionnaireResponse.Item]. */ + private val questionnaireResponseItemConstraintValidators = + listOf( + RequiredValidator, + ConstraintItemExtensionValidator(expressionEvaluator), + ) + + /** Validators for [QuestionnaireResponse.Item.Answer]. */ + private val answerConstraintValidators = + listOf( + MinValueValidator, + MaxValueValidator, + MinLengthValidator, + MaxLengthValidator, + MaxDecimalPlacesValidator, + RegexValidator, + ) + + /** Validates [questionnaireResponseItem] contains valid answer(s) to [questionnaireItem]. */ + suspend fun validate( + questionnaireItem: Questionnaire.Item, + questionnaireResponseItem: QuestionnaireResponse.Item, + ): ValidationResult { + if (questionnaireItem.isHidden) return NotValidated + + val questionnaireResponseItemConstraintValidationResult = + questionnaireResponseItemConstraintValidators.flatMap { + it.validate(questionnaireItem, questionnaireResponseItem) + } + val questionnaireResponseItemAnswerConstraintValidationResult = + answerConstraintValidators.flatMap { validator -> + questionnaireResponseItem.answer.map { answer -> + validator.validate(questionnaireItem, answer) { + expressionEvaluator.evaluateExpressionValue(it) + ?: expressionEvaluator.evaluateExpression(it) + } + } + } + + return if ( + questionnaireResponseItemConstraintValidationResult.all { it.isValid } && + questionnaireResponseItemAnswerConstraintValidationResult.all { it.isValid } + ) { + Valid + } else { + Invalid( + questionnaireResponseItemConstraintValidationResult.mapNotNull { it.errorMessage } + + questionnaireResponseItemAnswerConstraintValidationResult.mapNotNull { it.errorMessage }, + ) + } + } +} diff --git a/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/validation/QuestionnaireResponseValidator.kt b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/validation/QuestionnaireResponseValidator.kt new file mode 100644 index 000000000..be6658d2f --- /dev/null +++ b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/validation/QuestionnaireResponseValidator.kt @@ -0,0 +1,361 @@ +/* + * Copyright 2022-2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.ohs.fhir.datacapture.validation + +import com.google.fhir.model.r4.Questionnaire +import com.google.fhir.model.r4.QuestionnaireResponse +import com.google.fhir.model.r4.Resource +import dev.ohs.fhir.datacapture.XFhirQueryResolver +import dev.ohs.fhir.datacapture.enablement.EnablementEvaluator +import dev.ohs.fhir.datacapture.extensions.packRepeatedGroups +import dev.ohs.fhir.datacapture.fhirpath.ExpressionEvaluator +import kotlin.collections.get + +object QuestionnaireResponseValidator { + + /** + * Validates [QuestionnaireResponse] using the constraints defined in the [Questionnaire]. + * - Each item in the [QuestionnaireResponse] must have a corresponding item in the + * [Questionnaire] with the same `linkId` and `type` + * - The order of items in the [QuestionnaireResponse] must be the same as the order of the items + * in the + * [Questionnaire] - [Items nested under group](http://www.hl7.org/fhir/questionnaireresponse-definitions.html#QuestionnaireResponse.item.item) + * and + * [items nested under answer](http://www.hl7.org/fhir/questionnaireresponse-definitions.html#QuestionnaireResponse.item.answer.item) + * should follow the same rules recursively + * + * Note that although all the items in the [Questionnaire] SHOULD be included in the + * [QuestionnaireResponse], we do not throw an exception for missing items. This allows the + * [QuestionnaireResponse] to not include items that are not enabled due to `enableWhen`. + * + * @return a map of linkIds to list of ValidationResult + * @throws IllegalArgumentException if `questionnaireResponse` does not match `questionnaire`'s + * URL (if specified) + * @throws IllegalArgumentException if there is no questionnaire item with the same `linkId` as a + * questionnaire response item + * @throws IllegalArgumentException if the questionnaire response items are out of order + * @throws IllegalArgumentException if multiple answers are provided for a non-repeat + * questionnaire item + * + * See http://www.hl7.org/fhir/questionnaireresponse.html#link for more information. + */ + suspend fun validateQuestionnaireResponse( + questionnaire: Questionnaire, + questionnaireResponse: QuestionnaireResponse, + questionnaireItemParentMap: Map = mapOf(), + launchContextMap: Map? = mapOf(), + xFhirQueryResolver: XFhirQueryResolver? = null, + ): Map> { + require( + questionnaireResponse.questionnaire == null || + questionnaire.url == questionnaireResponse.questionnaire, + ) { + "Mismatching Questionnaire ${questionnaire.url} and QuestionnaireResponse (for Questionnaire ${questionnaireResponse.questionnaire})" + } + + val enablementEvaluator = + EnablementEvaluator( + questionnaire, + questionnaireResponse, + questionnaireItemParentMap, + launchContextMap, + xFhirQueryResolver, + ) + val questionnaireResponseItemValidator = + QuestionnaireResponseItemValidator( + ExpressionEvaluator( + questionnaire, + questionnaireResponse, + questionnaireItemParentMap, + launchContextMap, + ), + ) + val linkIdToValidationResultMap = mutableMapOf>() + + validateQuestionnaireResponseItems( + questionnaire.item, + questionnaireResponse.item, + enablementEvaluator, + questionnaireResponseItemValidator, + linkIdToValidationResultMap, + ) + + return linkIdToValidationResultMap + } + + private suspend fun validateQuestionnaireResponseItems( + questionnaireItemList: List, + questionnaireResponseItemList: List, + enablementEvaluator: EnablementEvaluator, + questionnaireResponseItemValidator: QuestionnaireResponseItemValidator, + linkIdToValidationResultMap: MutableMap>, + ): Map> { + val questionnaireItemListIterator = questionnaireItemList.iterator() + val questionnaireResponseItemListIterator = questionnaireResponseItemList.iterator() + + while (questionnaireResponseItemListIterator.hasNext()) { + val questionnaireResponseItem = questionnaireResponseItemListIterator.next() + var questionnaireItem: Questionnaire.Item? + do { + require(questionnaireItemListIterator.hasNext()) { + "Missing questionnaire item for questionnaire response item${questionnaireResponseItem.linkId}" + } + questionnaireItem = questionnaireItemListIterator.next() + } while (questionnaireItem.linkId != questionnaireResponseItem.linkId) + + val enabled = + enablementEvaluator.evaluate( + questionnaireItem, + questionnaireResponseItem, + ) + + if (enabled) { + validateQuestionnaireResponseItem( + questionnaireItem, + questionnaireResponseItem, + enablementEvaluator, + questionnaireResponseItemValidator, + linkIdToValidationResultMap, + ) + } + } + return linkIdToValidationResultMap + } + + private suspend fun validateQuestionnaireResponseItem( + questionnaireItem: Questionnaire.Item, + questionnaireResponseItem: QuestionnaireResponse.Item, + enablementEvaluator: EnablementEvaluator, + questionnaireResponseItemValidator: QuestionnaireResponseItemValidator, + linkIdToValidationResultMap: MutableMap>, + ): Map> { + checkNotNull(questionnaireItem.type) { "Questionnaire item must have type" } + when { + questionnaireItem.type.value == Questionnaire.QuestionnaireItemType.Display -> Unit + (questionnaireItem.type.value == Questionnaire.QuestionnaireItemType.Group && + questionnaireItem.repeats?.value == false) -> + // Nested items under group + // http://www.hl7.org/fhir/questionnaireresponse-definitions.html#QuestionnaireResponse.item.item + validateQuestionnaireResponseItems( + questionnaireItem.item, + questionnaireResponseItem.item, + enablementEvaluator, + questionnaireResponseItemValidator, + linkIdToValidationResultMap, + ) + else -> { + require( + questionnaireItem.repeats?.value == true || questionnaireResponseItem.answer.size <= 1, + ) { + "Multiple answers for non-repeat questionnaire item ${questionnaireItem.linkId}" + } + + questionnaireResponseItem.answer.forEach { + validateQuestionnaireResponseItems( + questionnaireItem.item, + it.item, + enablementEvaluator, + questionnaireResponseItemValidator, + linkIdToValidationResultMap, + ) + } + + if (questionnaireItem.linkId.value != null) { + linkIdToValidationResultMap[questionnaireItem.linkId.value!!] = mutableListOf() + linkIdToValidationResultMap[questionnaireItem.linkId.value]?.add( + questionnaireResponseItemValidator.validate( + questionnaireItem, + questionnaireResponseItem, + ), + ) + } + } + } + return linkIdToValidationResultMap + } + + /** + * Checks that the [QuestionnaireResponse] is structurally consistent with the [Questionnaire]. + * - Each item in the [QuestionnaireResponse] must have a corresponding item in the + * [Questionnaire] with the same `linkId` and `type` + * - The order of items in the [QuestionnaireResponse] must be the same as the order of the items + * in the + * [Questionnaire] - [Items nested under group](http://www.hl7.org/fhir/questionnaireresponse-definitions.html#QuestionnaireResponse.item.item) + * and + * [items nested under answer](http://www.hl7.org/fhir/questionnaireresponse-definitions.html#QuestionnaireResponse.item.answer.item) + * should follow the same rules recursively + * + * Note that although all the items in the [Questionnaire] SHOULD be included in the + * [QuestionnaireResponse], we do not throw an exception for missing items. This allows the + * [QuestionnaireResponse] to not include items that are not enabled due to `enableWhen`. + * + * @throws IllegalArgumentException if `questionnaireResponse` does not match `questionnaire`'s + * URL (if specified) + * @throws IllegalArgumentException if there is no questionnaire item with the same `linkId` as a + * questionnaire response item + * @throws IllegalArgumentException if the questionnaire response items are out of order + * @throws IllegalArgumentException if the type of a questionnaire response item does not match + * that of the questionnaire item + * @throws IllegalArgumentException if multiple answers are provided for a non-repeat + * questionnaire item + * + * See http://www.hl7.org/fhir/questionnaireresponse.html#link for more information. + */ + fun checkQuestionnaireResponse( + questionnaire: Questionnaire, + questionnaireResponse: QuestionnaireResponse, + ) { + require( + questionnaireResponse.questionnaire == null || + questionnaire.url == questionnaireResponse.questionnaire, + ) { + "Mismatching Questionnaire ${questionnaire.url} and QuestionnaireResponse (for Questionnaire ${questionnaireResponse.questionnaire})" + } + checkQuestionnaireResponseItems( + questionnaire.item, + questionnaireResponse.toBuilder().apply { packRepeatedGroups(questionnaire) }.build().item, + ) + } + + private fun checkQuestionnaireResponseItems( + questionnaireItemList: List, + questionnaireResponseItemList: List, + ) { + val questionnaireItemIterator = questionnaireItemList.iterator() + val questionnaireResponseInputItemIterator = questionnaireResponseItemList.iterator() + + while (questionnaireResponseInputItemIterator.hasNext()) { + val questionnaireResponseItem = questionnaireResponseInputItemIterator.next() + var questionnaireItem: Questionnaire.Item? + do { + require(questionnaireItemIterator.hasNext()) { + "Missing questionnaire item for questionnaire response item ${questionnaireResponseItem.linkId}" + } + questionnaireItem = questionnaireItemIterator.next() + } while (questionnaireItem.linkId != questionnaireResponseItem.linkId) + + checkQuestionnaireResponseItem(questionnaireItem, questionnaireResponseItem) + } + } + + private fun checkQuestionnaireResponseItem( + questionnaireItem: Questionnaire.Item, + questionnaireResponseItem: QuestionnaireResponse.Item, + ) { + checkNotNull(questionnaireItem.type) { "Questionnaire item must have type" } + + when { + questionnaireItem.type.value == Questionnaire.QuestionnaireItemType.Display -> Unit + (questionnaireItem.type.value == Questionnaire.QuestionnaireItemType.Group && + questionnaireItem.repeats?.value == false) -> + // Nested items under group + // http://www.hl7.org/fhir/questionnaireresponse-definitions.html#QuestionnaireResponse.item.item + checkQuestionnaireResponseItems(questionnaireItem.item, questionnaireResponseItem.item) + else -> { + require( + questionnaireItem.repeats?.value == true || questionnaireResponseItem.answer.size <= 1, + ) { + "Multiple answers for non-repeat questionnaire item ${questionnaireItem.linkId}" + } + questionnaireResponseItem.answer.forEach { + checkQuestionnaireResponseAnswerItem(questionnaireItem, it) + } + } + } + } + + private fun checkQuestionnaireResponseAnswerItem( + questionnaireItem: Questionnaire.Item, + answerItem: QuestionnaireResponse.Item.Answer, + ) { + if (answerItem.value != null) { + checkQuestionnaireResponseAnswerItemType( + questionnaireItem.linkId.value, + questionnaireItem.type.value, + answerItem.value, + ) + } + // Nested items under answer + // http://www.hl7.org/fhir/questionnaireresponse-definitions.html#QuestionnaireResponse.item.answer.item + checkQuestionnaireResponseItems(questionnaireItem.item, answerItem.item) + } + + private fun checkQuestionnaireResponseAnswerItemType( + linkId: String?, + questionnaireItemType: Questionnaire.QuestionnaireItemType?, + value: QuestionnaireResponse.Item.Answer.Value?, + ) { + if (value == null) return + val answerType = value::class.simpleName + when (questionnaireItemType) { + Questionnaire.QuestionnaireItemType.Boolean -> + require(answerType == "Boolean") { + "Mismatching question type $questionnaireItemType and answer type $answerType for $linkId" + } + Questionnaire.QuestionnaireItemType.Decimal -> + require(answerType == "Decimal") { + "Mismatching question type $questionnaireItemType and answer type $answerType for $linkId" + } + Questionnaire.QuestionnaireItemType.Integer -> + require(answerType == "Integer") { + "Mismatching question type $questionnaireItemType and answer type $answerType for $linkId" + } + Questionnaire.QuestionnaireItemType.Date -> + require(answerType == "Date") { + "Mismatching question type $questionnaireItemType and answer type $answerType for $linkId" + } + Questionnaire.QuestionnaireItemType.DateTime -> + require(answerType == "DateTime") { + "Mismatching question type $questionnaireItemType and answer type $answerType for $linkId" + } + Questionnaire.QuestionnaireItemType.Time -> + require(answerType == "Time") { + "Mismatching question type $questionnaireItemType and answer type $answerType for $linkId" + } + Questionnaire.QuestionnaireItemType.String -> + require(answerType == "String") { + "Mismatching question type $questionnaireItemType and answer type $answerType for $linkId" + } + Questionnaire.QuestionnaireItemType.Text -> + require(answerType == "String") { + "Mismatching question type $questionnaireItemType and answer type $answerType for $linkId" + } + Questionnaire.QuestionnaireItemType.Url -> + require(answerType == "Url") { + "Mismatching question type $questionnaireItemType and answer type $answerType for $linkId" + } + Questionnaire.QuestionnaireItemType.Choice, + Questionnaire.QuestionnaireItemType.Open_Choice, -> + require(answerType == "Coding" || answerType == "String") { + "Mismatching question type $questionnaireItemType and answer type $answerType for $linkId" + } + Questionnaire.QuestionnaireItemType.Attachment -> + require(answerType == "Attachment") { + "Mismatching question type $questionnaireItemType and answer type $answerType for $linkId" + } + Questionnaire.QuestionnaireItemType.Reference -> + require(answerType == "Reference") { + "Mismatching question type $questionnaireItemType and answer type $answerType for $linkId" + } + Questionnaire.QuestionnaireItemType.Quantity -> + require(answerType == "Quantity") { + "Mismatching question type $questionnaireItemType and answer type $answerType for $linkId" + } + else -> Unit + } + } +} diff --git a/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/validation/RegexValidator.kt b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/validation/RegexValidator.kt new file mode 100644 index 000000000..012914c98 --- /dev/null +++ b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/validation/RegexValidator.kt @@ -0,0 +1,61 @@ +/* + * Copyright 2023-2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.ohs.fhir.datacapture.validation + +import co.touchlab.kermit.Logger +import com.google.fhir.model.r4.Extension +import com.google.fhir.model.r4.QuestionnaireResponse +import com.google.fhir.model.r4.String +import dev.ohs.fhir.datacapture.generated.resources.Res +import dev.ohs.fhir.datacapture.generated.resources.regex_validation_error_msg +import org.jetbrains.compose.resources.getString + +internal const val REGEX_EXTENSION_URL = "http://hl7.org/fhir/StructureDefinition/regex" + +/** + * A validator to check if the answer matches a given regular expression. + * + *

Only primitive types permitted in questionnaires response are subjected to this validation. + * See https://www.hl7.org/fhir/valueset-item-type.html#expansion + */ +internal object RegexValidator : + AnswerExtensionConstraintValidator( + url = REGEX_EXTENSION_URL, + predicate = predicate@{ constraintValue: Any, answer: QuestionnaireResponse.Item.Answer -> + val regex = getValue(constraintValue) + if (regex == null || answer.value == null) { + return@predicate false + } + try { + val answerString = answer.value!!.asString()?.value?.value ?: "" + !regex.toRegex().matches(answerString) + } catch (e: IllegalArgumentException) { + Logger.w("Can't parse regex: $regex", e) + false + } + }, + messageGenerator = { constraintValue: Any -> + getString(Res.string.regex_validation_error_msg, getValue(constraintValue) as kotlin.String) + }, + ) + +private fun getValue(constraintValue: Any): kotlin.String? = + when (constraintValue) { + is String -> constraintValue.value + is Extension.Value.String -> constraintValue.asString()?.value?.value + else -> null + } diff --git a/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/validation/RequiredValidator.kt b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/validation/RequiredValidator.kt new file mode 100644 index 000000000..0e54d2348 --- /dev/null +++ b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/validation/RequiredValidator.kt @@ -0,0 +1,43 @@ +/* + * Copyright 2022-2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.ohs.fhir.datacapture.validation + +import com.google.fhir.model.r4.Questionnaire +import com.google.fhir.model.r4.QuestionnaireResponse +import dev.ohs.fhir.datacapture.generated.resources.Res +import dev.ohs.fhir.datacapture.generated.resources.required_constraint_validation_error_msg +import org.jetbrains.compose.resources.getString + +internal object RequiredValidator : QuestionnaireResponseItemConstraintValidator { + override suspend fun validate( + questionnaireItem: Questionnaire.Item, + questionnaireResponseItem: QuestionnaireResponse.Item, + ): List { + if ( + questionnaireItem.required?.value == false || + questionnaireResponseItem.answer.any { it.value != null } + ) { + return listOf(ConstraintValidator.Result(true, null)) + } + return listOf( + ConstraintValidator.Result( + false, + getString(Res.string.required_constraint_validation_error_msg), + ), + ) + } +} diff --git a/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/validation/ValidationResult.kt b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/validation/ValidationResult.kt new file mode 100644 index 000000000..44e622032 --- /dev/null +++ b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/validation/ValidationResult.kt @@ -0,0 +1,28 @@ +/* + * Copyright 2022-2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.ohs.fhir.datacapture.validation + +sealed class ValidationResult + +object NotValidated : ValidationResult() + +object Valid : ValidationResult() + +data class Invalid(private val validationMessages: List) : ValidationResult() { + val singleStringValidationMessage: String + get() = this.validationMessages.joinToString(separator = "\n") +} diff --git a/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/views/QuestionTextConfiguration.kt b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/views/QuestionTextConfiguration.kt new file mode 100644 index 000000000..db78e9956 --- /dev/null +++ b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/views/QuestionTextConfiguration.kt @@ -0,0 +1,23 @@ +/* + * Copyright 2022-2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.ohs.fhir.datacapture.views + +data class QuestionTextConfiguration( + val showAsterisk: Boolean = false, + val showRequiredText: Boolean = false, + val showOptionalText: Boolean = false, +) diff --git a/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/views/QuestionnaireViewItem.kt b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/views/QuestionnaireViewItem.kt new file mode 100644 index 000000000..ce93b1f20 --- /dev/null +++ b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/views/QuestionnaireViewItem.kt @@ -0,0 +1,248 @@ +/* + * Copyright 2023-2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.ohs.fhir.datacapture.views + +import androidx.compose.ui.text.AnnotatedString +import com.google.fhir.model.r4.Extension +import com.google.fhir.model.r4.Questionnaire +import com.google.fhir.model.r4.QuestionnaireResponse +import dev.ohs.fhir.datacapture.extensions.elementValue +import dev.ohs.fhir.datacapture.extensions.isHelpCode +import dev.ohs.fhir.datacapture.extensions.localizedTextAnnotatedString +import dev.ohs.fhir.datacapture.extensions.maxValue +import dev.ohs.fhir.datacapture.extensions.minValue +import dev.ohs.fhir.datacapture.extensions.toAnnotatedString +import dev.ohs.fhir.datacapture.validation.NotValidated +import dev.ohs.fhir.datacapture.validation.Valid +import dev.ohs.fhir.datacapture.validation.ValidationResult + +/** + * Data item for [QuestionnaireItemViewHolder] in [androidx.compose.foundation.lazy.LazyColumn]. + * + * The view should use [questionnaireItem], [answers], [enabledAnswerOptions], [validationResult] + * and [enabledDisplayItems] to render the data item in the UI. The view SHOULD NOT mutate the data + * using these properties. + * + * The view should use the following answer APIs to update the answer(s): + * - [setAnswer] (for single and repeated answers) + * - [clearAnswer] (for single and repeated answers) + * - [addAnswer] (for repeated answers only) + * - [removeAnswer] (for repeated answers only) + * + * Updates to the answers using these APIs will invoke [answersChangedCallback] to notify the view + * model that the answer(s) have been changed. This will trigger a refresh of the + * [androidx.compose.foundation.lazy.LazyColumn] UI. + * + * @param questionnaireItem the [Questionnaire.Item] in the [Questionnaire] + * @param questionnaireResponseItem the [QuestionnaireResponse.Item] in the [QuestionnaireResponse] + * @param validationResult the [ValidationResult] of the answer(s) against the `questionnaireItem` + * @param answersChangedCallback the callback to notify the view model that the answers have been + * changed for the [QuestionnaireResponse.Item] + * @param enabledAnswerOptions the enabled answer options in [questionnaireItem] + * @param minAnswerValue the inclusive lower bound on the range of allowed answer values, that may + * be used for widgets that check for bounds and change behavior based on the min allowed answer + * value, e.g the Slider widget + * @param maxAnswerValue the inclusive upper bound on the range of allowed answer values, that may + * be used for widgets that check for bounds and change behavior based on the max allowed answer + * value, e.g the Slider widget + * @param draftAnswer the draft input that cannot be stored in the [QuestionnaireResponse]. + * @param enabledDisplayItems the enabled display items in the given [questionnaireItem] + * @param questionViewTextConfiguration configuration to show asterisk, required and optional text + * in the header view. + */ +data class QuestionnaireViewItem( + val questionnaireItem: Questionnaire.Item, + private val questionnaireResponseItem: QuestionnaireResponse.Item, + val validationResult: ValidationResult, + internal val answersChangedCallback: + suspend ( + Questionnaire.Item, + QuestionnaireResponse.Item, + List, + Any?, + ) -> Unit, + val enabledAnswerOptions: List = + questionnaireItem.answerOption.ifEmpty { emptyList() }, + val minAnswerValue: Extension.Value? = questionnaireItem.minValue, + val maxAnswerValue: Extension.Value? = questionnaireItem.maxValue, + val draftAnswer: Any? = null, + val enabledDisplayItems: List = emptyList(), + val questionViewTextConfiguration: QuestionTextConfiguration = QuestionTextConfiguration(), + val isHelpCardOpen: Boolean = questionnaireItem.isHelpCode, + val helpCardStateChangedCallback: (Boolean, QuestionnaireResponse.Item) -> Unit = { _, _ -> }, +) { + + fun getQuestionnaireResponseItem(): QuestionnaireResponse.Item = questionnaireResponseItem + + /** + * A read-only list of answers to be rendered in the view. + * + * The view should call the APIs provided in this class ([setAnswer], [addAnswer], [removeAnswer] + * and [clearAnswer]) to modify the answers. This is to make sure any updates to the answers are + * propagated to the view model and a subsequent UI refresh will be triggered (e.g. in case the + * enablement status or validation results of this or other questions are affected). + */ + val answers: List = questionnaireResponseItem.answer + + /** Updates the answers. This will override any existing answers and removes the draft answer. */ + suspend fun setAnswer( + vararg questionnaireResponseItemAnswerComponent: QuestionnaireResponse.Item.Answer, + ) { + check( + questionnaireItem.repeats?.value == true || + questionnaireResponseItemAnswerComponent.size <= 1, + ) { + "Questionnaire item with linkId ${questionnaireItem.linkId} has repeated answers." + } + answersChangedCallback( + questionnaireItem, + questionnaireResponseItem, + questionnaireResponseItemAnswerComponent.toList(), + null, + ) + } + + /** Clears existing answers and any draft answer. */ + suspend fun clearAnswer() { + answersChangedCallback(questionnaireItem, questionnaireResponseItem, listOf(), null) + } + + /** Adds an answer to the existing answers and removes the draft answer. */ + suspend fun addAnswer( + questionnaireResponseItemAnswerComponent: QuestionnaireResponse.Item.Answer, + ) { + check(questionnaireItem.repeats?.value == true) { + "Questionnaire item with linkId ${questionnaireItem.linkId} does not allow repeated answers" + } + answersChangedCallback( + questionnaireItem, + questionnaireResponseItem, + answers + questionnaireResponseItemAnswerComponent, + null, + ) + } + + /** Removes an answer from the existing answers, as well as any draft answer. */ + suspend fun removeAnswer( + vararg questionnaireResponseItemAnswerComponent: QuestionnaireResponse.Item.Answer, + ) { + check(questionnaireItem.repeats?.value == true) { + "Questionnaire item with linkId ${questionnaireItem.linkId} does not allow repeated answers" + } + answersChangedCallback( + questionnaireItem, + questionnaireResponseItem, + answers.filterNot { ans -> + questionnaireResponseItemAnswerComponent.any { ans.value == it.value } + }, + null, + ) + } + + internal suspend fun removeAnswerAt(index: Int) { + check(questionnaireItem.repeats?.value == true) { + "Questionnaire item with linkId ${questionnaireItem.linkId} does not allow repeated answers" + } + require(index in answers.indices) { + "removeAnswerAt($index), but ${questionnaireItem.linkId} only has ${answers.size} answers" + } + answersChangedCallback( + questionnaireItem, + questionnaireResponseItem, + answers.filterIndexed { currentIndex, _ -> currentIndex != index }, + null, + ) + } + + /** + * Updates the draft answer stored in `QuestionnaireViewModel`. This clears any actual answer for + * the question. + */ + suspend fun setDraftAnswer(draftAnswer: Any? = null) { + answersChangedCallback(questionnaireItem, questionnaireResponseItem, listOf(), draftAnswer) + } + + /** + * Fetches the question title that should be displayed to user. The title is first fetched from + * [QuestionnaireResponse.Item] (derived from cqf-expression), otherwise it is derived from + * [localizedTextAnnotatedString] of [QuestionnaireResponse.Item] + */ + val questionText: AnnotatedString? by lazy { + questionnaireResponseItem.text?.value?.toAnnotatedString() + ?: questionnaireItem.localizedTextAnnotatedString + } + + /** + * Returns a given answer (The respondent's answer(s) to the question) along with [displayString] + * if question is answered else 'Not Answered' + */ + + // fun answerString(context: Context): String { + // if (!questionnaireResponseItem.hasAnswer()) return context.getString(R.string.not_answered) + // return questionnaireResponseItem.answer.joinToString { it.value.displayString(context) } + // } + // + + /** + * Returns whether this [QuestionnaireViewItem] and the `other` [QuestionnaireViewItem] have the + * same [Questionnaire.Item] and [QuestionnaireResponse.Item]. + * + * This is useful for determining if two [QuestionnaireViewItem]s are representing the same + * question and answer in the [Questionnaire] and [QuestionnaireResponse]. This can be used to + * update the [RecyclerView] UI. + */ + internal fun hasTheSameItem(other: QuestionnaireViewItem) = + questionnaireItem === other.questionnaireItem && + questionnaireResponseItem === other.questionnaireResponseItem + + /** + * Returns whether this [QuestionnaireViewItem] and the `other` [QuestionnaireViewItem] have the + * same response. + * + * This is useful for determining if the [QuestionnaireViewItem] has outdated answer(s) or + * question text and therefore needs to be updated in the list UI. + */ + internal fun hasTheSameResponse(other: QuestionnaireViewItem) = + answers.size == other.answers.size && + answers + .zip(other.answers) { answer, otherAnswer -> + answer.value != null && otherAnswer.value != null && answer.value == otherAnswer.value + } + .all { it } && + draftAnswer == other.draftAnswer && + questionText == other.questionText + + /** + * Returns whether this [QuestionnaireViewItem] and the `other` [QuestionnaireViewItem] have the + * same [ValidationResult]. + * + * This is useful for determining if the [QuestionnaireViewItem] has outdated [ValidationResult] + * and therefore needs to be updated in the list UI. + */ + internal fun hasTheSameValidationResult(other: QuestionnaireViewItem): Boolean { + if (validationResult is NotValidated || validationResult is Valid) { + return other.validationResult is NotValidated || other.validationResult is Valid + } + return validationResult == other.validationResult + } +} + +internal fun QuestionnaireViewItem.isAnswerOptionSelected( + answerOption: Questionnaire.Item.AnswerOption, +): Boolean { + return answers.any { it.elementValue == answerOption.elementValue } +} diff --git a/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/views/components/ChoiceCheckbox.kt b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/views/components/ChoiceCheckbox.kt new file mode 100644 index 000000000..224770a62 --- /dev/null +++ b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/views/components/ChoiceCheckbox.kt @@ -0,0 +1,132 @@ +/* + * Copyright 2025-2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.ohs.fhir.datacapture.views.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.selection.toggleable +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Checkbox +import androidx.compose.material3.CheckboxDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.unit.dp +import dev.ohs.fhir.datacapture.theme.QuestionnaireTheme + +internal const val CHOICE_CHECKBOX_IMAGE_TAG = "checkbox_option_icon" + +@Composable +internal fun ChoiceCheckbox( + label: AnnotatedString, + checked: Boolean, + enabled: Boolean, + modifier: Modifier = Modifier, + image: ImageBitmap? = null, + onCheckedChange: (Boolean) -> Unit, +) { + val backgroundColor = + if (checked) { + MaterialTheme.colorScheme.primary.copy(alpha = 0.12f) + } else { + MaterialTheme.colorScheme.surface + } + + val borderColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.15f) + val textColor = + if (checked) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurface + } + + val shape = + if (checked) { + RoundedCornerShape(4.dp) + } else { + RoundedCornerShape(8.dp) + } + + Row( + modifier = + modifier + .clip(shape) + .background(backgroundColor) + .then( + if (!checked) { + Modifier.border(1.dp, borderColor, shape) + } else { + Modifier + }, + ) + .toggleable( + value = checked, + enabled = enabled, + role = Role.Checkbox, + onValueChange = onCheckedChange, + ) + .padding( + start = QuestionnaireTheme.dimensions.optionItemBetweenTextAndIconPadding, + end = QuestionnaireTheme.dimensions.optionItemAfterTextPadding, + top = QuestionnaireTheme.dimensions.optionItemPadding, + bottom = QuestionnaireTheme.dimensions.optionItemPadding, + ), + verticalAlignment = Alignment.CenterVertically, + ) { + Checkbox( + checked = checked, + onCheckedChange = null, + enabled = enabled, + colors = + CheckboxDefaults.colors( + checkedColor = MaterialTheme.colorScheme.primary, + uncheckedColor = MaterialTheme.colorScheme.onSurface, + checkmarkColor = MaterialTheme.colorScheme.surface, + ), + ) + // Display image + image?.let { + Spacer(modifier = Modifier.width(8.dp)) + Icon( + bitmap = it, + contentDescription = null, + modifier = Modifier.testTag(CHOICE_CHECKBOX_IMAGE_TAG).size(24.dp), + ) + } + Spacer( + modifier = Modifier.width(QuestionnaireTheme.dimensions.optionItemBetweenTextAndIconPadding), + ) + Text( + text = label, + color = textColor, + ) + Spacer(modifier = Modifier.width(QuestionnaireTheme.dimensions.optionItemAfterTextPadding)) + } +} diff --git a/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/views/components/ChoiceRadioButton.kt b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/views/components/ChoiceRadioButton.kt new file mode 100644 index 000000000..8e5426f65 --- /dev/null +++ b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/views/components/ChoiceRadioButton.kt @@ -0,0 +1,125 @@ +/* + * Copyright 2025-2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.ohs.fhir.datacapture.views.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.unit.dp +import dev.ohs.fhir.datacapture.theme.QuestionnaireTheme + +internal const val CHOICE_RADIO_BUTTON_IMAGE_TAG = "radio_button_option_icon" + +@Composable +internal fun ChoiceRadioButton( + label: AnnotatedString, + selected: Boolean, + enabled: Boolean, + modifier: Modifier = Modifier, + image: ImageBitmap? = null, + onClick: () -> Unit, +) { + val backgroundColor = + if (selected) { + MaterialTheme.colorScheme.primary.copy(alpha = 0.12f) + } else { + MaterialTheme.colorScheme.surface + } + + val borderColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.15f) + val textColor = + if (selected) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurface + } + + val shape = + if (selected) { + RoundedCornerShape(4.dp) + } else { + RoundedCornerShape(8.dp) + } + + Row( + modifier = + modifier + .clip(shape) + .background(backgroundColor) + .then( + if (!selected) { + Modifier.border(1.dp, borderColor, shape) + } else { + Modifier + }, + ) + .selectable( + selected = selected, + enabled = enabled, + role = Role.RadioButton, + onClick = onClick, + ) + .padding( + start = QuestionnaireTheme.dimensions.optionItemBetweenTextAndIconPadding, + end = QuestionnaireTheme.dimensions.optionItemAfterTextPadding, + top = QuestionnaireTheme.dimensions.optionItemPadding, + bottom = QuestionnaireTheme.dimensions.optionItemPadding, + ), + verticalAlignment = Alignment.CenterVertically, + ) { + RadioButton( + selected = selected, + onClick = null, + enabled = enabled, + ) + // Display image + image?.let { + Spacer(modifier = Modifier.width(8.dp)) + Icon( + bitmap = it, + contentDescription = null, + modifier = Modifier.testTag(CHOICE_RADIO_BUTTON_IMAGE_TAG).size(24.dp), + ) + } + Spacer( + modifier = Modifier.width(QuestionnaireTheme.dimensions.optionItemBetweenTextAndIconPadding), + ) + Text( + text = label, + color = textColor, + ) + Spacer(modifier = Modifier.width(QuestionnaireTheme.dimensions.optionItemAfterTextPadding)) + } +} diff --git a/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/views/components/DateFieldItem.kt b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/views/components/DateFieldItem.kt new file mode 100644 index 000000000..e33f67f5b --- /dev/null +++ b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/views/components/DateFieldItem.kt @@ -0,0 +1,265 @@ +/* + * Copyright 2025-2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.ohs.fhir.datacapture.views.components + +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.DatePicker +import androidx.compose.material3.DatePickerDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.SelectableDates +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberDatePickerState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusDirection +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.error +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.TextFieldValue +import dev.ohs.fhir.datacapture.extensions.toLocalDate +import dev.ohs.fhir.datacapture.generated.resources.Res +import dev.ohs.fhir.datacapture.generated.resources.gm_calendar_today_24 +import dev.ohs.fhir.datacapture.generated.resources.select_date +import dev.ohs.fhir.datacapture.getLocalDateTimeFormatter +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.datetime.LocalDate +import org.jetbrains.compose.resources.painterResource +import org.jetbrains.compose.resources.stringResource + +internal const val DATE_TEXT_INPUT_FIELD = "date_picker_text_field" + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun DateFieldItem( + modifier: Modifier = Modifier, + initialSelectedDateMillis: Long?, + dateInput: DateInput, + labelText: String, + helperText: String?, + isError: Boolean, + enabled: Boolean, + dateInputFormat: DateInputFormat, + selectableDates: SelectableDates?, + parseStringToLocalDate: (String, DateFormatPattern) -> LocalDate?, + onDateInputEntry: (DateInput) -> Unit, +) { + val focusManager = LocalFocusManager.current + val keyboardController = LocalSoftwareKeyboardController.current + val coroutineScope = rememberCoroutineScope { Dispatchers.Main } + val localDateTimeFormatter = getLocalDateTimeFormatter() + + var dateInputDisplay by + remember(dateInput) { + mutableStateOf( + TextFieldValue( + text = dateInput.display, + selection = TextRange(dateInputFormat.pattern.length), + ), + ) + } + + var showDatePickerModal by remember { mutableStateOf(false) } + var typingJob by remember { mutableStateOf(null) } + val postDelayedNewDateInput: (DateInput, Long) -> Unit = + remember(dateInput) { + { newDateInput, delayInMillis -> + typingJob?.cancel() // Cancel previous debounce + typingJob = + coroutineScope.launch { + delay(delayInMillis) // Debounce delay + if (newDateInput != dateInput) { + onDateInputEntry(newDateInput) + } + } + } + } + + OutlinedTextField( + value = dateInputDisplay, + onValueChange = { textFieldValue -> + if (dateInputFormat.isTextValid(textFieldValue.text)) { + textFieldValue.text.let { + val isDeletion = it.length < dateInputDisplay.text.length + val formattedText = + if (!dateInputFormat.delimiterExistsInPattern || isDeletion) { + it + } else { + StringBuilder(it) + .apply { + dateInputFormat.delimiterIndex.forEach { index -> + if (this.length > index && get(index) != dateInputFormat.delimiter) { + insert(index, dateInputFormat.delimiter) + } + } + } + .toString() + } + val localDate = + if (formattedText.length == dateInputFormat.pattern.length) { + parseStringToLocalDate(formattedText, dateInputFormat.pattern) + } else { + null + } + dateInputDisplay = + dateInputDisplay.copy( + text = formattedText, + selection = TextRange(dateInputFormat.pattern.length), + ) + postDelayedNewDateInput( + DateInput(formattedText, localDate), + HANDLE_INPUT_DEBOUNCE_TIME, + ) + } + } + }, + singleLine = true, + label = { Text(labelText) }, + modifier = + modifier + .testTag(DATE_TEXT_INPUT_FIELD) + .onFocusChanged { + if (!it.isFocused) { + keyboardController?.hide() + } + } + .semantics { if (isError) error(helperText ?: "") }, + supportingText = { helperText?.let { Text(it) } }, + isError = isError, + trailingIcon = { + IconButton(onClick = { showDatePickerModal = true }, enabled = enabled) { + Icon( + painterResource(Res.drawable.gm_calendar_today_24), + contentDescription = stringResource(Res.string.select_date), + ) + } + }, + enabled = enabled, + keyboardOptions = + KeyboardOptions( + autoCorrectEnabled = false, + keyboardType = KeyboardType.Number, + imeAction = ImeAction.Done, + ), + keyboardActions = + KeyboardActions( + onNext = { focusManager.moveFocus(FocusDirection.Down) }, + ), + ) + + if (selectableDates != null && showDatePickerModal) { + DatePickerModal( + initialSelectedDateMillis, + selectableDates, + onDateSelected = { dateMillis -> + dateMillis?.toLocalDate()?.let { + val dateDisplay = localDateTimeFormatter.format(it, dateInputFormat.pattern) + dateInputDisplay = + dateInputDisplay.copy( + text = dateDisplay, + selection = TextRange(dateInputFormat.pattern.length), + ) + val newDateInput = + DateInput( + display = dateDisplay, + value = it, + ) + postDelayedNewDateInput(newDateInput, 0L) + } + }, + ) { + showDatePickerModal = false + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun DatePickerModal( + initialSelectedDateMillis: Long?, + selectableDates: SelectableDates, + onDateSelected: (Long?) -> Unit, + onDismiss: () -> Unit, +) { + val datePickerState = + rememberDatePickerState(initialSelectedDateMillis, selectableDates = selectableDates) + val datePickerSelectedDateMillis = + remember(initialSelectedDateMillis) { initialSelectedDateMillis } + val confirmEnabled by remember { derivedStateOf { datePickerState.selectedDateMillis != null } } + + LaunchedEffect(datePickerSelectedDateMillis) { + if (datePickerSelectedDateMillis != datePickerState.selectedDateMillis) { + datePickerState.selectedDateMillis = datePickerSelectedDateMillis + } + } + + DatePickerDialog( + onDismissRequest = onDismiss, + confirmButton = { + TextButton( + onClick = { + onDateSelected(datePickerState.selectedDateMillis) + onDismiss() + }, + enabled = confirmEnabled, + ) { + Text("OK") + } + }, + dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } }, + ) { + DatePicker(state = datePickerState) + } +} + +internal typealias DateFormatPattern = String + +internal data class DateInput(val display: String, val value: LocalDate?) + +internal data class DateInputFormat(val pattern: String, val delimiter: Char) { + val delimiterIndex = pattern.indices.filter { pattern[it] == delimiter } + val delimiterExistsInPattern = delimiterIndex.isNotEmpty() + + fun isTextValid(text: String): Boolean = + (text.length <= pattern.length && + text + .filterIndexed { index, ch -> + ch.isDigit() || (ch == delimiter && index in delimiterIndex) + } + .isNotEmpty()) + .or(text.isEmpty()) +} diff --git a/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/views/components/DropDownItem.kt b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/views/components/DropDownItem.kt new file mode 100644 index 000000000..3a0585665 --- /dev/null +++ b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/views/components/DropDownItem.kt @@ -0,0 +1,301 @@ +/* + * Copyright 2025-2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.ohs.fhir.datacapture.views.components + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuAnchorType +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.error +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.TextFieldValue +import com.google.fhir.model.r4.Element +import com.google.fhir.model.r4.Questionnaire +import dev.ohs.fhir.datacapture.extensions.displayString +import dev.ohs.fhir.datacapture.extensions.elementValue +import dev.ohs.fhir.datacapture.extensions.itemAnswerOptionImage +import dev.ohs.fhir.datacapture.extensions.toAnnotatedString +import dev.ohs.fhir.datacapture.generated.resources.Res +import dev.ohs.fhir.datacapture.generated.resources.ic_clear +import dev.ohs.fhir.datacapture.theme.QuestionnaireTheme +import org.jetbrains.compose.resources.painterResource + +internal const val CLEAR_TEXT_ICON_BUTTON_TAG = "clear_field_text" +internal const val DROP_DOWN_TEXT_FIELD_TAG = "drop_down_text_field" +internal const val DROP_DOWN_TEXT_FIELD_LEADING_ICON_TAG = "drop_down_text_field_leading_icon" +internal const val DROP_DOWN_ANSWER_MENU_ITEM_TAG = "drop_down_answer_list_menu_item" + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun DropDownItem( + modifier: Modifier, + enabled: Boolean, + labelText: AnnotatedString? = null, + supportingText: String? = null, + isError: Boolean = false, + selectedOption: DropDownAnswerOption? = null, + options: List, + onDropDownAnswerOptionSelected: (DropDownAnswerOption?) -> Unit, +) { + var expanded by remember { mutableStateOf(false) } + var selectedDropDownAnswerOption by + remember(selectedOption, options) { mutableStateOf(selectedOption) } + val selectedOptionDisplay by + remember(selectedDropDownAnswerOption) { + derivedStateOf { selectedDropDownAnswerOption?.displayString ?: "" } + } + + LaunchedEffect(selectedDropDownAnswerOption) { + onDropDownAnswerOptionSelected(selectedDropDownAnswerOption) + } + + ExposedDropdownMenuBox( + modifier = modifier, + expanded = expanded, + onExpandedChange = { expanded = it }, + ) { + OutlinedTextField( + value = selectedOptionDisplay, + onValueChange = {}, + modifier = + Modifier.fillMaxWidth() + .testTag(DROP_DOWN_TEXT_FIELD_TAG) + .semantics { if (isError) error(supportingText ?: "") } + .menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable, enabled), + readOnly = true, + enabled = enabled, + minLines = 1, + isError = isError, + label = { labelText?.let { Text(it) } }, + supportingText = { supportingText?.let { Text(it) } }, + leadingIcon = + selectedDropDownAnswerOption?.iconImage?.let { + { + Icon( + it, + contentDescription = selectedOptionDisplay, + modifier = Modifier.testTag(DROP_DOWN_TEXT_FIELD_LEADING_ICON_TAG), + ) + } + }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, + ) + ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + options.forEach { option -> + DropDownAnswerMenuItem(enabled, option) { + selectedDropDownAnswerOption = option + expanded = false + } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun DropDownAnswerMenuItem( + enabled: Boolean, + answerOption: DropDownAnswerOption, + onSelected: () -> Unit, +) { + DropdownMenuItem( + modifier = Modifier.testTag(DROP_DOWN_ANSWER_MENU_ITEM_TAG), + text = { + Text( + answerOption.displayAnnotatedString(), + style = QuestionnaireTheme.textStyles.dropDownText, + ) + }, + leadingIcon = + answerOption.iconImage?.let { + { + Icon( + it, + contentDescription = answerOption.displayString, + ) + } + }, + enabled = enabled, + onClick = { onSelected() }, + contentPadding = ExposedDropdownMenuDefaults.ItemContentPadding, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun AutoCompleteDropDownItem( + modifier: Modifier, + enabled: Boolean, + labelText: AnnotatedString? = null, + supportingText: String? = null, + isError: Boolean = false, + showClearIcon: Boolean = false, + readOnly: Boolean = showClearIcon, + selectedOption: DropDownAnswerOption? = null, + options: List, + onDropDownAnswerOptionSelected: (DropDownAnswerOption?) -> Unit, +) { + val focusManager = LocalFocusManager.current + var expanded by remember { mutableStateOf(false) } + var selectedDropDownAnswerOption by + remember(selectedOption, options) { mutableStateOf(selectedOption) } + + // A flag to track when the user is in the process of filtering the options. + var inFilterMode: Boolean by remember(options) { mutableStateOf(false) } + + var selectedOptionDisplay by + remember(selectedDropDownAnswerOption) { + // When an option is selected, the filter is no longer active. + inFilterMode = false + val stringValue = selectedDropDownAnswerOption?.displayString ?: "" + mutableStateOf(TextFieldValue(stringValue, selection = TextRange(stringValue.length))) + } + + // The options are filtered only when the user is in filter mode. + val filteredOptions = + remember(options, selectedOptionDisplay, inFilterMode) { + if (inFilterMode) { + options.filter { it.displayString.contains(selectedOptionDisplay.text, true) } + } else { + // When not in filter mode, all options are displayed. + options + } + } + + LaunchedEffect(selectedDropDownAnswerOption) { + if (selectedDropDownAnswerOption != null) { + focusManager.clearFocus() + } + + if (selectedDropDownAnswerOption != selectedOption) { + onDropDownAnswerOptionSelected(selectedDropDownAnswerOption) + } + } + + ExposedDropdownMenuBox( + modifier = modifier, + expanded = expanded, + onExpandedChange = { expanded = it }, + ) { + OutlinedTextField( + value = selectedOptionDisplay, + onValueChange = { + inFilterMode = true + selectedOptionDisplay = it + if (!expanded) expanded = true + }, + modifier = + Modifier.fillMaxWidth() + .testTag(DROP_DOWN_TEXT_FIELD_TAG) + .semantics { if (isError) error(supportingText ?: "") } + .menuAnchor(ExposedDropdownMenuAnchorType.PrimaryEditable, enabled), + readOnly = readOnly, + enabled = enabled, + minLines = 1, + isError = isError, + label = { labelText?.let { Text(it) } }, + supportingText = { supportingText?.let { Text(it) } }, + leadingIcon = + selectedDropDownAnswerOption?.iconImage?.let { + { + Icon( + it, + contentDescription = selectedDropDownAnswerOption!!.displayString, + modifier = Modifier.testTag(DROP_DOWN_TEXT_FIELD_LEADING_ICON_TAG), + ) + } + }, + trailingIcon = { + Row(verticalAlignment = Alignment.CenterVertically) { + if (showClearIcon) { + IconButton( + onClick = { selectedDropDownAnswerOption = null }, + modifier = Modifier.testTag(CLEAR_TEXT_ICON_BUTTON_TAG), + ) { + Icon(painterResource(Res.drawable.ic_clear), contentDescription = "clear") + } + } + ExposedDropdownMenuDefaults.TrailingIcon( + expanded = expanded, + modifier = + Modifier.menuAnchor( + ExposedDropdownMenuAnchorType.SecondaryEditable, + enabled, + ), + ) + } + }, + ) + + if (filteredOptions.isNotEmpty()) { + ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + filteredOptions.forEach { option -> + DropDownAnswerMenuItem(enabled, option) { + selectedDropDownAnswerOption = option + expanded = false + } + } + } + } + } +} + +internal data class DropDownAnswerOption( + val elementValue: Element, + val displayString: String, + val iconImage: ImageBitmap? = null, +) { + override fun toString(): String = this.displayString + + fun displayAnnotatedString() = displayString.toAnnotatedString() + + companion object { + @Composable + fun of(answerOption: Questionnaire.Item.AnswerOption): DropDownAnswerOption { + val answerOptionDisplayString = answerOption.displayString() + + return remember(answerOption) { + DropDownAnswerOption( + answerOption.elementValue, + answerOptionDisplayString, + answerOption.itemAnswerOptionImage(), + ) + } + } + } +} diff --git a/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/views/components/EditTextFieldItem.kt b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/views/components/EditTextFieldItem.kt new file mode 100644 index 000000000..0ef7de501 --- /dev/null +++ b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/views/components/EditTextFieldItem.kt @@ -0,0 +1,197 @@ +/* + * Copyright 2025-2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.ohs.fhir.datacapture.views.components + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Error +import androidx.compose.material3.Icon +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusDirection +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.unit.dp +import dev.ohs.fhir.datacapture.theme.QuestionnaireTheme +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.launch +import org.jetbrains.compose.ui.tooling.preview.Preview + +internal const val EDIT_TEXT_FIELD_TEST_TAG = "text_input_edit_text" +internal const val UNIT_TEXT_TEST_TAG = "unit_text_view" +internal const val HANDLE_INPUT_DEBOUNCE_TIME = 500L + +@Composable +internal fun EditTextFieldItem(modifier: Modifier, textFieldState: EditTextFieldState) { + OutlinedEditTextFieldItem( + modifier = modifier, + inputText = textFieldState.inputText, + onInputTextChange = textFieldState::onInputTextChange, + hint = textFieldState.hint, + helperText = textFieldState.helperText, + isError = textFieldState.isError, + isReadOnly = textFieldState.isReadOnly, + keyboardOptions = textFieldState.keyboardOptions, + isMultiLine = textFieldState.isMultiLine, + ) +} + +@Composable +internal fun OutlinedEditTextFieldItem( + modifier: Modifier, + inputText: String, + onInputTextChange: (String) -> Unit, + hint: AnnotatedString?, + helperText: String?, + isError: Boolean, + isReadOnly: Boolean, + keyboardOptions: KeyboardOptions, + isMultiLine: Boolean, +) { + val focusManager = LocalFocusManager.current + val keyboardController = LocalSoftwareKeyboardController.current + var textFieldValue by remember { + mutableStateOf(TextFieldValue(text = inputText, selection = TextRange(inputText.length))) + } + var isFocused by remember { mutableStateOf(false) } + + // Update the local state when the initial inputText changes and the field is not focused + LaunchedEffect(inputText) { + if (!isFocused && textFieldValue.text != inputText) { + textFieldValue = + textFieldValue.copy(text = inputText, selection = TextRange(inputText.length)) + } + } + + OutlinedTextField( + value = textFieldValue, + onValueChange = { + textFieldValue = it + onInputTextChange(it.text) + }, + minLines = if (isMultiLine) 3 else 1, + singleLine = !isMultiLine, + modifier = + modifier + .onFocusChanged { + isFocused = it.isFocused + if (!it.isFocused) { + keyboardController?.hide() + // Sync with external state on focus loss + if (textFieldValue.text != inputText) { + onInputTextChange(textFieldValue.text) + } + } + } + .testTag(EDIT_TEXT_FIELD_TEST_TAG), + label = { hint?.let { Text(it) } }, + supportingText = { helperText?.let { Text(it) } }, + isError = isError, + trailingIcon = { + if (isError) { + Icon(imageVector = Icons.Default.Error, contentDescription = "Error") + } + }, + readOnly = isReadOnly, + enabled = !isReadOnly, + keyboardOptions = keyboardOptions, + keyboardActions = + KeyboardActions( + onNext = { focusManager.moveFocus(FocusDirection.Down) }, + ), + ) +} + +@Composable +internal fun UnitText(unitString: String) { + Box( + modifier = Modifier.padding(horizontal = 16.dp), + contentAlignment = Alignment.Center, + ) { + Text( + unitString, + style = QuestionnaireTheme.typography.bodyMedium, + modifier = Modifier.testTag(UNIT_TEXT_TEST_TAG), + ) + } +} + +@Composable +@Preview() +internal fun PreviewQuestionnaireItemEditText() { + OutlinedEditTextFieldItem( + inputText = "Input", + onInputTextChange = {}, + hint = null, + helperText = null, + isError = false, + isReadOnly = false, + isMultiLine = false, + modifier = Modifier, + keyboardOptions = KeyboardOptions(), + ) +} + +@OptIn(FlowPreview::class) +internal data class EditTextFieldState( + val hint: AnnotatedString?, + val helperText: String?, + val isError: Boolean, + val isReadOnly: Boolean, + val keyboardOptions: KeyboardOptions, + val isMultiLine: Boolean, + private val initialInputText: String, + private val handleTextInputChange: suspend (String) -> Unit, + private val coroutineScope: CoroutineScope, +) { + var inputText by mutableStateOf(initialInputText) + private set + + init { + coroutineScope.launch { + snapshotFlow { inputText } + .drop(1) // Drops the initial value emitted by snapshotFlow + .debounce(HANDLE_INPUT_DEBOUNCE_TIME) + .collectLatest { handleTextInputChange(it) } + } + } + + fun onInputTextChange(text: String) { + inputText = text + } +} diff --git a/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/views/components/ErrorText.kt b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/views/components/ErrorText.kt new file mode 100644 index 000000000..ca2a5a6ff --- /dev/null +++ b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/views/components/ErrorText.kt @@ -0,0 +1,39 @@ +/* + * Copyright 2025-2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.ohs.fhir.datacapture.views.components + +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import dev.ohs.fhir.datacapture.theme.QuestionnaireTheme + +internal const val ERROR_TEXT_TAG = "error_text" + +@Composable +internal fun ErrorText(validationMessage: String) { + Text( + text = validationMessage, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + modifier = + Modifier.padding(start = QuestionnaireTheme.dimensions.errorTextMarginHorizontal) + .testTag(ERROR_TEXT_TAG), + ) +} diff --git a/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/views/components/Header.kt b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/views/components/Header.kt new file mode 100644 index 000000000..9a6f98785 --- /dev/null +++ b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/views/components/Header.kt @@ -0,0 +1,364 @@ +/* + * Copyright 2025-2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.ohs.fhir.datacapture.views.components + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Help +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.unit.dp +import dev.ohs.fhir.datacapture.extensions.StyleUrl +import dev.ohs.fhir.datacapture.extensions.getLocalizedInstructionsAnnotatedString +import dev.ohs.fhir.datacapture.extensions.getLocalizedText +import dev.ohs.fhir.datacapture.extensions.hasHelpButton +import dev.ohs.fhir.datacapture.extensions.localizedHelpAnnotatedString +import dev.ohs.fhir.datacapture.extensions.localizedPrefixAnnotatedString +import dev.ohs.fhir.datacapture.extensions.readCustomStyleExtension +import dev.ohs.fhir.datacapture.generated.resources.Res +import dev.ohs.fhir.datacapture.generated.resources.help +import dev.ohs.fhir.datacapture.generated.resources.space_asterisk +import dev.ohs.fhir.datacapture.theme.QuestionnaireTheme +import dev.ohs.fhir.datacapture.validation.Invalid +import dev.ohs.fhir.datacapture.validation.ValidationResult +import dev.ohs.fhir.datacapture.views.QuestionnaireViewItem +import org.jetbrains.compose.resources.stringResource + +internal const val ERROR_TEXT_AT_HEADER_TEST_TAG = "error-text-at-header" +internal const val REQUIRED_OPTIONAL_HEADER_TEXT_TAG = "required_optional_header_text" +internal const val HELP_BUTTON_TAG = "help-button" +internal const val HELP_CARD_TAG = "help-card-view" +internal const val HELP_HEADER_TAG = "hint_text" +internal const val HEADER_TAG = "header-View" +internal const val HINT_HEADER_TAG = "hint_text" +internal const val PREFIX_HEADER_TAG = "prefix_text" +internal const val QUESTION_HEADER_TAG = "question_text" + +@Composable +fun Header( + questionnaireViewItem: QuestionnaireViewItem, + displayValidationResult: Boolean = false, + showRequiredOrOptionalText: Boolean = false, +) { + val validationResult = + remember(questionnaireViewItem.validationResult) { questionnaireViewItem.validationResult } + val questionnaireItem = + remember(questionnaireViewItem.questionnaireItem) { questionnaireViewItem.questionnaireItem } + val questionnaireResponseItem = + remember(questionnaireViewItem) { questionnaireViewItem.getQuestionnaireResponseItem() } + val requiredOptionalText = getRequiredOrOptionalText(questionnaireViewItem) + + val prefixLocalizedText = questionnaireViewItem.questionnaireItem.localizedPrefixAnnotatedString + val spaceAsterisk = stringResource(Res.string.space_asterisk) + val questionLocalizedText = + remember(questionnaireViewItem) { + buildAnnotatedString { + questionnaireViewItem.questionText?.let { append(it) } + if ( + questionnaireViewItem.questionViewTextConfiguration.showAsterisk && + questionnaireViewItem.questionnaireItem.required?.value == true && + !questionnaireViewItem.questionnaireItem.text?.getLocalizedText().isNullOrEmpty() + ) { + append(spaceAsterisk) + } + } + } + val hintLocalizedText = + questionnaireViewItem.enabledDisplayItems.getLocalizedInstructionsAnnotatedString() + val itemLocalizedHelpText = questionnaireItem.localizedHelpAnnotatedString + + // This is to avoid an empty row in the questionnaire. + if ( + listOf(prefixLocalizedText, questionLocalizedText, hintLocalizedText, itemLocalizedHelpText) + .any { !it.isNullOrBlank() } || + (showRequiredOrOptionalText && !requiredOptionalText.isNullOrBlank()) || + (displayValidationResult && validationResult is Invalid) + ) { + Header( + prefixLocalizedText = prefixLocalizedText, + questionLocalizedText = questionLocalizedText, + readCustomStyleName = remember { { questionnaireItem.readCustomStyleExtension(it) } }, + hintLocalizedText = hintLocalizedText, + isHelpCardOpen = questionnaireViewItem.isHelpCardOpen, + isHelpButtonVisible = questionnaireItem.hasHelpButton, + helpCardLocalizedText = itemLocalizedHelpText, + helpButtonOnClick = { + questionnaireViewItem.helpCardStateChangedCallback(it, questionnaireResponseItem) + }, + validationResult = validationResult, + displayValidationResult = displayValidationResult, + showRequiredOrOptionalText = showRequiredOrOptionalText, + requiredOptionalText = requiredOptionalText, + ) + } +} + +@Composable +internal fun Header( + prefixLocalizedText: AnnotatedString?, + questionLocalizedText: AnnotatedString, + readCustomStyleName: (StyleUrl) -> String?, + hintLocalizedText: AnnotatedString?, + isHelpCardOpen: Boolean, + isHelpButtonVisible: Boolean, + helpCardLocalizedText: AnnotatedString?, + helpButtonOnClick: (Boolean) -> Unit, + validationResult: ValidationResult, + displayValidationResult: Boolean, + showRequiredOrOptionalText: Boolean, + requiredOptionalText: String?, +) { + Column( + modifier = Modifier.fillMaxWidth().padding(bottom = 4.dp).testTag(HEADER_TAG), + ) { + PrefixQuestionTitle(prefixLocalizedText, questionLocalizedText, readCustomStyleName) + + if (!hintLocalizedText.isNullOrBlank() || isHelpButtonVisible || isHelpCardOpen) { + Help( + hintLocalizedText, + readCustomStyleName, + isHelpCardOpen, + isHelpButtonVisible, + helpButtonOnClick, + helpCardLocalizedText, + ) + } + + // Required/Optional Text + if (showRequiredOrOptionalText && !requiredOptionalText.isNullOrBlank()) { + Text( + text = requiredOptionalText, + style = QuestionnaireTheme.typography.bodyMedium, + modifier = Modifier.testTag(REQUIRED_OPTIONAL_HEADER_TEXT_TAG), + ) + } + + // Validation Error + if (displayValidationResult && validationResult is Invalid) { + Text( + modifier = Modifier.testTag(ERROR_TEXT_AT_HEADER_TEST_TAG), + text = validationResult.singleStringValidationMessage, + color = QuestionnaireTheme.colorScheme.error, + style = QuestionnaireTheme.typography.bodySmall, + ) + } + } +} + +@Composable +internal fun PrefixQuestionTitle( + prefixLocalizedText: AnnotatedString?, + questionLocalizedText: AnnotatedString, + readCustomStyleName: (StyleUrl) -> String?, +) { + Row(modifier = Modifier.fillMaxWidth()) { + if (!prefixLocalizedText.isNullOrBlank()) { + Text( + prefixLocalizedText, + style = QuestionnaireTheme.textStyles.questionText, + modifier = Modifier.testTag(PREFIX_HEADER_TAG), + ) + + // AndroidView( + // factory = { + // TextView(it).apply { + // id = R.id.prefix + // applyCustomOrDefaultStyle( + // context = it, + // view = this, + // customStyleName = + // readCustomStyleName( + // StyleUrl.PREFIX_TEXT_VIEW, + // ), + // defaultStyleResId = + // getStyleResIdFromAttribute(it, R.attr.questionnaireQuestionTextStyle), + // ) + // } + // }, + // update = { it.text = prefixLocalizedText }, + // ) + Spacer(modifier = Modifier.width(5.dp)) + } + + Text( + questionLocalizedText, + style = QuestionnaireTheme.textStyles.questionText, + modifier = Modifier.testTag(QUESTION_HEADER_TAG), + ) + // AndroidView( + // factory = { + // TextView(it).apply { + // id = R.id.question + // movementMethod = LinkMovementMethod.getInstance() + // applyCustomOrDefaultStyle( + // context = it, + // view = this, + // customStyleName = + // readCustomStyleName( + // StyleUrl.QUESTION_TEXT_VIEW, + // ), + // defaultStyleResId = + // getStyleResIdFromAttribute(it, R.attr.questionnaireQuestionTextStyle), + // ) + // } + // }, + // modifier = Modifier.weight(1f), + // update = { it.text = questionLocalizedText }, + // ) + } +} + +@Composable +internal fun Help( + hintLocalizedText: AnnotatedString?, + readCustomStyleName: (StyleUrl) -> String?, + isHelpCardInitiallyOpen: Boolean, + isHelpButtonVisible: Boolean, + helpButtonOnClick: (Boolean) -> Unit, + helpCardLocalizedText: AnnotatedString?, +) { + var isCardOpen by remember { mutableStateOf(isHelpCardInitiallyOpen) } + + Row( + modifier = Modifier.padding(top = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + hintLocalizedText?.let { + Text( + it, + style = QuestionnaireTheme.textStyles.subtitleText, + modifier = Modifier.testTag(HINT_HEADER_TAG), + ) + // AndroidView( + // modifier = Modifier.weight(0.7f), + // factory = { + // TextView(it).apply { + // id = R.id.hint + // movementMethod = LinkMovementMethod.getInstance() + // applyCustomOrDefaultStyle( + // context = it, + // view = this, + // customStyleName = + // readCustomStyleName( + // StyleUrl.SUBTITLE_TEXT_VIEW, + // ), + // defaultStyleResId = + // getStyleResIdFromAttribute(it, R.attr.questionnaireSubtitleTextStyle), + // ) + // } + // }, + // update = { it.text = hintLocalizedText }, + // ) + } + + if (isHelpButtonVisible) { + IconButton( + onClick = { + isCardOpen = !isCardOpen + helpButtonOnClick(isCardOpen) + }, + modifier = + Modifier.padding(2.dp) + .padding(start = 4.dp) + .testTag(HELP_BUTTON_TAG) + .weight(0.3f) + .size( + width = 24.dp, + height = 24.dp, + ), + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.Help, + contentDescription = stringResource(Res.string.help), + modifier = Modifier.size(48.dp), + ) + } + } + } + + if (isCardOpen) { + Card( + modifier = Modifier.padding(top = 16.dp).testTag(HELP_CARD_TAG), + colors = + CardDefaults.cardColors() + .copy(containerColor = QuestionnaireTheme.colorScheme.surfaceVariant), + ) { + Column { + Text( + text = stringResource(Res.string.help), + modifier = + Modifier.padding(horizontal = 16.dp) + .padding( + top = 16.dp, + bottom = 4.dp, + ), + style = QuestionnaireTheme.typography.titleSmall, + ) + + helpCardLocalizedText?.let { + Text( + it, + modifier = + Modifier.testTag(HELP_HEADER_TAG) + .padding(horizontal = QuestionnaireTheme.dimensions.helpTextMarginHorizontal) + .padding(bottom = QuestionnaireTheme.dimensions.helpTextMarginBottom), + style = QuestionnaireTheme.textStyles.helpText, + ) + } + // AndroidView( + // factory = { + // TextView(it).apply { + // id = R.id.helpText + // movementMethod = LinkMovementMethod.getInstance() + // + // QuestionItemDefaultStyle() + // .applyStyle( + // context, + // this, + // getStyleResIdFromAttribute(it, R.attr.questionnaireHelpTextStyle), + // ) + // } + // }, + // modifier = + // Modifier.padding(horizontal = + // dimensionResource(R.dimen.help_text_margin_horizontal)) + // .padding(bottom = dimensionResource(R.dimen.help_text_margin_bottom)), + // update = { it.text = helpCardLocalizedText }, + // ) + } + } + } +} diff --git a/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/views/components/MediaItem.kt b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/views/components/MediaItem.kt new file mode 100644 index 000000000..b2269c4f3 --- /dev/null +++ b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/views/components/MediaItem.kt @@ -0,0 +1,61 @@ +/* + * Copyright 2025-2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.ohs.fhir.datacapture.views.components + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.sizeIn +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.unit.dp +import com.google.fhir.model.r4.Attachment +import dev.ohs.fhir.datacapture.extensions.imageData +import org.jetbrains.compose.resources.decodeToImageBitmap + +@Composable +fun MediaItem(attachment: Attachment) { + var attachmentBitmap: ImageBitmap? by remember(attachment) { mutableStateOf(null) } + LaunchedEffect(attachmentBitmap) { + attachmentBitmap = attachment.imageData()?.decodeToImageBitmap() + } + attachmentBitmap?.let { ImageMediaItem(it, attachment.title?.value) } +} + +@Composable +fun ImageMediaItem(imageBitmap: ImageBitmap, imageName: String?) { + Image( + modifier = + Modifier.testTag("media-image") + .padding( + horizontal = 16.dp, + vertical = 8.dp, + ) + .sizeIn( + maxHeight = 200.dp, + maxWidth = 200.dp, + ), + bitmap = imageBitmap, + contentDescription = imageName, + ) +} diff --git a/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/views/components/MultiAutoCompleteTextItem.kt b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/views/components/MultiAutoCompleteTextItem.kt new file mode 100644 index 000000000..6055d28cc --- /dev/null +++ b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/views/components/MultiAutoCompleteTextItem.kt @@ -0,0 +1,192 @@ +/* + * Copyright 2025-2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.ohs.fhir.datacapture.views.components + +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuAnchorType +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.Icon +import androidx.compose.material3.InputChip +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.error +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.unit.dp +import dev.ohs.fhir.datacapture.generated.resources.Res +import dev.ohs.fhir.datacapture.generated.resources.ic_clear +import dev.ohs.fhir.datacapture.theme.QuestionnaireTheme +import org.jetbrains.compose.resources.painterResource + +internal const val MULTI_AUTO_COMPLETE_TEXT_FIELD_TAG = "multi_auto_complete_text_field" +internal const val MULTI_AUTO_COMPLETE_INPUT_CHIP_TAG = "multi_auto_complete_input_chip" + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun MultiAutoCompleteTextItem( + modifier: Modifier, + enabled: Boolean, + labelText: AnnotatedString? = null, + supportingText: String? = null, + isError: Boolean = false, + selectedOptions: List = emptyList(), + options: List, + onNewOptionSelected: (DropDownAnswerOption) -> Unit, + onOptionDeselected: (DropDownAnswerOption) -> Unit, +) { + var expanded by remember { mutableStateOf(false) } + var autoCompleteText by remember(options) { mutableStateOf(TextFieldValue("")) } + val filteredOptions = + remember(options, autoCompleteText) { + options.filter { it.displayString.contains(autoCompleteText.text, true) } + } + + // Track the height of the chip container to add padding to text field + var chipContainerHeight by remember { mutableIntStateOf(0) } + val density = LocalDensity.current + val chipMargin = QuestionnaireTheme.dimensions.autoCompleteChipMargin + val chipMarginBottom = QuestionnaireTheme.dimensions.autoCompleteChipMarginBottom + val textFieldContentPadding = QuestionnaireTheme.dimensions.autoCompleteTextFieldContentPadding + + val interactionSource = remember { MutableInteractionSource() } + val colors = OutlinedTextFieldDefaults.colors() + val contentPadding = + remember(chipContainerHeight, selectedOptions.size) { + PaddingValues( + start = textFieldContentPadding, + top = + if (selectedOptions.isNotEmpty()) { + with(density) { chipContainerHeight.toDp() } + textFieldContentPadding + } else { + textFieldContentPadding + }, + end = textFieldContentPadding, + bottom = textFieldContentPadding, + ) + } + + ExposedDropdownMenuBox( + modifier = modifier, + expanded = expanded, + onExpandedChange = { expanded = it }, + ) { + Box { + // Text field fills the parent and has content padding for chips + BasicTextField( + value = autoCompleteText, + onValueChange = { + autoCompleteText = it + if (!expanded && autoCompleteText.text.isNotBlank()) expanded = true + }, + modifier = + Modifier.fillMaxWidth() + .testTag(MULTI_AUTO_COMPLETE_TEXT_FIELD_TAG) + .semantics { if (isError) error(supportingText ?: "") } + .menuAnchor(ExposedDropdownMenuAnchorType.PrimaryEditable, enabled), + enabled = enabled, + textStyle = TextStyle.Default, + cursorBrush = SolidColor(colors.cursorColor), + interactionSource = interactionSource, + decorationBox = { innerTextField -> + OutlinedTextFieldDefaults.DecorationBox( + value = autoCompleteText.text, + innerTextField = innerTextField, + enabled = enabled, + singleLine = false, + visualTransformation = VisualTransformation.None, + interactionSource = interactionSource, + isError = isError, + label = labelText?.let { { Text(it) } }, + supportingText = supportingText?.let { { Text(it) } }, + colors = colors, + contentPadding = contentPadding, + container = { + OutlinedTextFieldDefaults.Container( + enabled = enabled, + isError = isError, + interactionSource = interactionSource, + colors = colors, + ) + }, + ) + }, + ) + + // Chips overlay at the top of the text field + if (selectedOptions.isNotEmpty()) { + FlowRow( + modifier = + Modifier.fillMaxWidth() + .padding(chipMargin) + .padding(bottom = chipMarginBottom) + .onSizeChanged { size -> chipContainerHeight = size.height }, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + selectedOptions.forEach { + InputChip( + selected = false, + modifier = Modifier.testTag(MULTI_AUTO_COMPLETE_INPUT_CHIP_TAG), + enabled = enabled, + onClick = { onOptionDeselected(it) }, + label = { Text(it.displayAnnotatedString()) }, + trailingIcon = { + Icon( + painterResource(Res.drawable.ic_clear), + contentDescription = "Remove ${it.displayString}", + ) + }, + ) + } + } + } + } + + if (filteredOptions.isNotEmpty()) { + ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + filteredOptions.forEach { option -> + DropDownAnswerMenuItem(enabled, option) { + autoCompleteText = TextFieldValue("") // Reset autoComplete text to empty + onNewOptionSelected(option) + expanded = false + } + } + } + } + } +} diff --git a/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/views/components/OptionDialogSelect.kt b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/views/components/OptionDialogSelect.kt new file mode 100644 index 000000000..9651cb7a9 --- /dev/null +++ b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/views/components/OptionDialogSelect.kt @@ -0,0 +1,410 @@ +/* + * Copyright 2025-2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.ohs.fhir.datacapture.views.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import com.google.fhir.model.r4.Questionnaire +import dev.ohs.fhir.datacapture.extensions.displayString +import dev.ohs.fhir.datacapture.extensions.elementValue +import dev.ohs.fhir.datacapture.extensions.itemAnswerOptionImage +import dev.ohs.fhir.datacapture.extensions.optionExclusive +import dev.ohs.fhir.datacapture.extensions.toAnnotatedString +import dev.ohs.fhir.datacapture.generated.resources.Res +import dev.ohs.fhir.datacapture.generated.resources.cancel +import dev.ohs.fhir.datacapture.generated.resources.delete +import dev.ohs.fhir.datacapture.generated.resources.delete_24px +import dev.ohs.fhir.datacapture.generated.resources.open_choice_other +import dev.ohs.fhir.datacapture.generated.resources.open_choice_other_add_another +import dev.ohs.fhir.datacapture.generated.resources.open_choice_other_hint +import dev.ohs.fhir.datacapture.generated.resources.save +import dev.ohs.fhir.datacapture.theme.QuestionnaireTheme +import kotlin.concurrent.atomics.AtomicInt +import kotlin.concurrent.atomics.ExperimentalAtomicApi +import kotlin.concurrent.atomics.fetchAndIncrement +import org.jetbrains.compose.resources.painterResource +import org.jetbrains.compose.resources.stringResource + +internal const val OPTION_CHOICE_LIST_TAG = "dialog_select_option_choice_list" +internal const val OPTION_CHOICE_TAG = "dialog_select_option_choice" +internal const val OTHER_OPTION_TEXT_FIELD_TAG = "other_option_edit_text_field" + +@Composable +internal fun OptionDialogSelect( + title: AnnotatedString, + multiSelect: Boolean, + otherOptionsAllowed: + Boolean, // Client had to specify that they want an open-choice control to use "Other" options + selectedOptions: SelectedOptions, + onDismiss: () -> Unit, + onConfirm: (SelectedOptions) -> Unit, +) { + var choiceOptions by + remember(multiSelect, selectedOptions.options) { + mutableStateOf(selectedOptions.options.map { OptionSelectRow.Option(it) }) + } + var otherOptionRowSelected by + remember(otherOptionsAllowed, selectedOptions.otherOptions) { + mutableStateOf(otherOptionsAllowed && selectedOptions.otherOptions.isNotEmpty()) + } + val otherOptionEditTexts = + remember(otherOptionsAllowed, selectedOptions.otherOptions) { + val list = selectedOptions.otherOptions.map { OptionSelectRow.OtherEditText.fromText(it) } + mutableStateListOf(*list.toTypedArray()) + } + val showAddAnother = + remember(otherOptionRowSelected, multiSelect) { otherOptionRowSelected && multiSelect } + val listState = rememberLazyListState() + + LaunchedEffect(otherOptionRowSelected, otherOptionEditTexts.size) { + if (otherOptionRowSelected) { + val listSize = + choiceOptions.size + + if (otherOptionsAllowed) { + 1 + } else { + 0 + } + + otherOptionEditTexts.size + + if (showAddAnother) 1 else 0 + listState.animateScrollToItem(listSize - 1) + } + } + + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties(dismissOnBackPress = false, dismissOnClickOutside = false), + ) { + Surface( + shape = MaterialTheme.shapes.extraLarge, + tonalElevation = 6.dp, + modifier = Modifier.fillMaxWidth(), + ) { + Column(modifier = Modifier.padding(QuestionnaireTheme.dimensions.dialogPadding)) { + Text( + text = title, + style = MaterialTheme.typography.headlineSmall, + modifier = Modifier.padding(QuestionnaireTheme.dimensions.dialogTitleMarginBottom), + ) + + LazyColumn( + state = listState, + modifier = + Modifier.fillMaxWidth() + .testTag(OPTION_CHOICE_LIST_TAG) + .weight(1f, fill = false) + .padding(top = QuestionnaireTheme.dimensions.dialogOptionScrollMarginTop), + verticalArrangement = + Arrangement.spacedBy(QuestionnaireTheme.dimensions.optionItemMarginVertical), + horizontalAlignment = Alignment.CenterHorizontally, + contentPadding = + PaddingValues(horizontal = QuestionnaireTheme.dimensions.optionItemMarginHorizontal), + ) { + itemsIndexed( + choiceOptions, + key = { _, row -> row.key() }, + contentType = { _, row -> row::class.simpleName }, + ) { index, optionSelectRow -> + val label = optionSelectRow.option.displayString.toAnnotatedString() + val image = optionSelectRow.option.item.itemAnswerOptionImage() + OptionChoice( + modifier = Modifier.fillMaxWidth(), + isMultiSelect = multiSelect, + label = label, + selected = optionSelectRow.option.selected, + image = image, + ) { selected -> + choiceOptions = + choiceOptions.mapIndexed { ind, option -> + when { + ind == index || option == optionSelectRow -> { + optionSelectRow.copy( + option = optionSelectRow.option.copy(selected = selected), + ) + } + selected && + multiSelect && + (optionSelectRow.option.item.optionExclusive || + option.option.item.optionExclusive) -> { + // if the selected answer option has optionExclusive extension, then deselect + // other + // answer options. + // or if the selected answer option does not have optionExclusive extension, + // then + // deselect optionExclusive answer option. + option.copy(option = option.option.copy(selected = false)) + } + !multiSelect -> { + // In single-select mode, we need to disable all of the other rows + option.copy(option = option.option.copy(selected = false)) + } + else -> { + option + } + } + } + + if (selected && (!multiSelect || optionSelectRow.option.item.optionExclusive)) { + otherOptionRowSelected = false + otherOptionEditTexts.clear() + } + } + } + + if (otherOptionsAllowed) { + item { + val label = AnnotatedString(stringResource(Res.string.open_choice_other)) + OptionChoice( + modifier = Modifier.fillMaxWidth(), + isMultiSelect = multiSelect, + label = label, + selected = otherOptionRowSelected, + ) { selected -> + otherOptionRowSelected = selected + when { + selected && !multiSelect -> { + choiceOptions = + choiceOptions.map { it.copy(option = it.option.copy(selected = false)) } + } + selected -> { + choiceOptions = + choiceOptions.map { + if (it.option.item.optionExclusive) { + it.copy(option = it.option.copy(selected = false)) + } else { + it + } + } + } + } + + if (selected && otherOptionEditTexts.isEmpty()) { + otherOptionEditTexts.add(OptionSelectRow.OtherEditText.fromText("")) + } + } + } + } + + if (otherOptionRowSelected) { + itemsIndexed( + otherOptionEditTexts, + key = { _, option -> option.key() }, + contentType = { _, _ -> OptionSelectRow.OtherEditText }, + ) { index, option -> + Row( + modifier = + Modifier.testTag(OTHER_OPTION_TEXT_FIELD_TAG) + .fillMaxWidth() + .padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedTextField( + value = option.currentText, + onValueChange = { newText -> + otherOptionEditTexts.removeAt(index) + otherOptionEditTexts.add(index, option.copy(currentText = newText)) + }, + modifier = Modifier.weight(1f), + placeholder = { Text(stringResource(Res.string.open_choice_other_hint)) }, + ) + + if (multiSelect) { + IconButton( + onClick = { otherOptionEditTexts.removeAt(index) }, + ) { + Icon( + painterResource(Res.drawable.delete_24px), + contentDescription = stringResource(Res.string.delete), + tint = MaterialTheme.colorScheme.primary, + ) + } + } + } + } + } + + if (showAddAnother) { + item { + Button( + onClick = { otherOptionEditTexts.add(OptionSelectRow.OtherEditText.fromText("")) }, + ) { + Text(stringResource(Res.string.open_choice_other_add_another)) + } + } + } + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + TextButton( + modifier = + Modifier.padding(QuestionnaireTheme.dimensions.dialogConfirmationButtonPadding), + onClick = onDismiss, + ) { + Text( + stringResource(Res.string.cancel), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + TextButton( + modifier = + Modifier.padding(QuestionnaireTheme.dimensions.dialogConfirmationButtonPadding), + onClick = { + onConfirm( + SelectedOptions( + options = choiceOptions.map { it.option }, + otherOptions = + otherOptionEditTexts.map { it.currentText }.filterNot { it.isBlank() }, + ), + ) + onDismiss() + }, + ) { + Text( + stringResource(Res.string.save), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + } + } +} + +@Composable +internal fun OptionChoice( + modifier: Modifier, + isMultiSelect: Boolean, + label: AnnotatedString, + selected: Boolean, + image: ImageBitmap? = null, + onSelectionChange: (Boolean) -> Unit, +) { + val testTagModifier = modifier.testTag(OPTION_CHOICE_TAG) + if (isMultiSelect) { + ChoiceCheckbox( + label = label, + checked = selected, + enabled = true, + image = image, + onCheckedChange = { checked -> onSelectionChange(checked) }, + modifier = testTagModifier, + ) + } else { + ChoiceRadioButton( + label = label, + selected = selected, + enabled = true, + image = image, + onClick = { onSelectionChange(!selected) }, + modifier = testTagModifier, + ) + } +} + +data class SelectedOptions( + val options: List, + val otherOptions: List, +) { + + @get:Composable + val selectedSummary: String + get() = (options.filter { it.selected }.map { it.displayString } + otherOptions).joinToString() +} + +/** Represents selectable options in the multi-select page. */ +data class OptionSelectOption( + val item: Questionnaire.Item.AnswerOption, + val selected: Boolean, +) { + @get:Composable + val displayString: String + get() = item.displayString() +} + +/** Sealed class representing different types of rows in the option select dialog. */ +internal sealed class OptionSelectRow { + /** A predefined option. */ + data class Option(val option: OptionSelectOption) : OptionSelectRow() + + /** "Other" option. Only shown if otherOptionsAllowed is true. */ + data class OtherRow(val selected: Boolean) : OptionSelectRow() + + /** Text boxes for user to enter "Other" options in. Only shown when [OtherRow] is selected. */ + data class OtherEditText(val id: Int, val startingText: String, var currentText: String) : + OptionSelectRow() { + + @OptIn(ExperimentalAtomicApi::class) + companion object { + fun fromText(text: String) = + OtherEditText( + id = idProvider.fetchAndIncrement(), + startingText = text, + currentText = text, + ) + + private val idProvider = AtomicInt(0) + } + } + + /** "Add Another" other field button. Only used in multi-select when [OtherRow] is selected. */ + object OtherAddAnother : OptionSelectRow() + + fun key() = + when (this) { + is Option -> "option_${option.item.elementValue}" + is OtherRow -> "other_row" + is OtherEditText -> "other_edit_$id" + OtherAddAnother -> "add_another" + } +} diff --git a/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/views/components/PageNavigationItem.kt b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/views/components/PageNavigationItem.kt new file mode 100644 index 000000000..6217c4b22 --- /dev/null +++ b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/views/components/PageNavigationItem.kt @@ -0,0 +1,135 @@ +/* + * Copyright 2023-2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.ohs.fhir.datacapture.views.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.unit.dp +import dev.ohs.fhir.datacapture.QuestionnaireNavigationUIState +import dev.ohs.fhir.datacapture.QuestionnaireNavigationViewUIState +import dev.ohs.fhir.datacapture.generated.resources.Res +import dev.ohs.fhir.datacapture.generated.resources.button_pagination_next +import dev.ohs.fhir.datacapture.generated.resources.button_pagination_previous +import dev.ohs.fhir.datacapture.generated.resources.button_review +import dev.ohs.fhir.datacapture.generated.resources.cancel_questionnaire +import dev.ohs.fhir.datacapture.generated.resources.submit_questionnaire +import dev.ohs.fhir.datacapture.theme.QuestionnaireTheme +import org.jetbrains.compose.resources.stringResource + +internal const val QUESTIONNAIRE_PAGE_NAVIGATION_BUTTON_TEST_TAG = "page_navigation_button" +internal const val QUESTIONNAIRE_BOTTOM_NAVIGATION_TEST_TAG = + "questionnaire_bottom_navigation_test_tag" + +@Composable +internal fun QuestionnaireBottomNavigation( + pageNavigationUIState: QuestionnaireNavigationUIState, + modifier: Modifier = Modifier, +) { + val navigationUIState = remember(pageNavigationUIState) { pageNavigationUIState } + + Column(modifier = modifier.testTag(QUESTIONNAIRE_BOTTOM_NAVIGATION_TEST_TAG).fillMaxWidth()) { + HorizontalDivider(color = QuestionnaireTheme.colorScheme.onSurface.copy(alpha = 0.15f)) + + Surface( + color = QuestionnaireTheme.colorScheme.surface, + modifier = Modifier.fillMaxWidth(), + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 16.dp), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, + ) { + // Cancel button (left-aligned) + if (navigationUIState.navCancel is QuestionnaireNavigationViewUIState.Enabled) { + val cancelNavigationViewState = + remember(navigationUIState.navCancel) { navigationUIState.navCancel } + TextButton( + onClick = cancelNavigationViewState.onClickAction, + modifier = modifier, + ) { + Text( + text = cancelNavigationViewState.labelText?.takeIf { it.isNotBlank() } + ?: stringResource(Res.string.cancel_questionnaire), + ) + } + } + + Spacer(modifier = Modifier.weight(1f)) + + // Right-aligned buttons + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + PageNavigationButton( + navigationViewState = navigationUIState.navPrevious, + defaultText = stringResource(Res.string.button_pagination_previous), + ) + + PageNavigationButton( + navigationViewState = navigationUIState.navNext, + defaultText = stringResource(Res.string.button_pagination_next), + ) + + PageNavigationButton( + navigationViewState = navigationUIState.navReview, + defaultText = stringResource(Res.string.button_review), + ) + + PageNavigationButton( + navigationViewState = navigationUIState.navSubmit, + defaultText = stringResource(Res.string.submit_questionnaire), + ) + } + } + } + } +} + +/** Individual navigation button that handles visibility and click actions. */ +@Composable +private fun PageNavigationButton( + navigationViewState: QuestionnaireNavigationViewUIState, + defaultText: String, + modifier: Modifier = Modifier, +) { + if (navigationViewState is QuestionnaireNavigationViewUIState.Enabled) { + val buttonText = navigationViewState.labelText?.takeIf { it.isNotBlank() } ?: defaultText + Button( + onClick = navigationViewState.onClickAction, + modifier = modifier.testTag(QUESTIONNAIRE_PAGE_NAVIGATION_BUTTON_TEST_TAG), + ) { + Text( + text = buttonText, + ) + } + } +} diff --git a/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/views/components/RepeatedGroupAddButtonItem.kt b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/views/components/RepeatedGroupAddButtonItem.kt new file mode 100644 index 000000000..a6775317f --- /dev/null +++ b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/views/components/RepeatedGroupAddButtonItem.kt @@ -0,0 +1,106 @@ +/* + * Copyright 2025-2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.ohs.fhir.datacapture.views.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.unit.dp +import com.google.fhir.model.r4.QuestionnaireResponse +import dev.ohs.fhir.datacapture.generated.resources.Res +import dev.ohs.fhir.datacapture.generated.resources.add_24px +import dev.ohs.fhir.datacapture.generated.resources.add_repeated_group_item +import dev.ohs.fhir.datacapture.theme.QuestionnaireTheme +import dev.ohs.fhir.datacapture.views.QuestionnaireViewItem +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.painterResource +import org.jetbrains.compose.resources.stringResource + +internal const val ADD_REPEATED_GROUP_BUTTON_TAG = "addRepeatedGroupButton" + +@Composable +internal fun RepeatedGroupAddButtonItem(questionnaireViewItem: QuestionnaireViewItem) { + val coroutineScope = rememberCoroutineScope { Dispatchers.Main } + val questionText = + remember(questionnaireViewItem) { questionnaireViewItem.questionText?.toString() ?: "" } + val isQuestionnaireViewItemRepeatedGroup = + remember(questionnaireViewItem.questionnaireItem.repeats) { + questionnaireViewItem.questionnaireItem.repeats?.value ?: false + } + val isEnabled = + remember(questionnaireViewItem.questionnaireItem.readOnly) { + !(questionnaireViewItem.questionnaireItem.readOnly?.value ?: false) + } + val color = + if (isEnabled) { + QuestionnaireTheme.colorScheme.primary + } else { + QuestionnaireTheme.colorScheme.outlineVariant.copy(alpha = 0.4f) + } + + val buttonText = stringResource(Res.string.add_repeated_group_item, questionText) + + if (isQuestionnaireViewItemRepeatedGroup) { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + OutlinedButton( + onClick = { + coroutineScope.launch { + questionnaireViewItem.addAnswer( + // Nested items will be added in answerChangedCallback in the QuestionnaireViewModel + QuestionnaireResponse.Item.Answer(), + ) + } + }, + enabled = isEnabled, + modifier = Modifier.testTag(ADD_REPEATED_GROUP_BUTTON_TAG), + shape = RoundedCornerShape(4.dp), + border = ButtonDefaults.outlinedButtonBorder(isEnabled).copy(brush = SolidColor(color)), + contentPadding = ButtonDefaults.ButtonWithIconContentPadding, + ) { + Icon( + painter = painterResource(Res.drawable.add_24px), + contentDescription = ADD_REPEATED_GROUP_BUTTON_TAG, + tint = color, + ) + Spacer(modifier = Modifier.width(ButtonDefaults.IconSpacing)) + Text( + text = buttonText, + color = color, + ) + } + } + } +} diff --git a/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/views/components/RepeatedGroupHeaderItem.kt b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/views/components/RepeatedGroupHeaderItem.kt new file mode 100644 index 000000000..d6831e893 --- /dev/null +++ b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/views/components/RepeatedGroupHeaderItem.kt @@ -0,0 +1,91 @@ +/* + * Copyright 2025-2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.ohs.fhir.datacapture.views.components + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.unit.dp +import dev.ohs.fhir.datacapture.QuestionnaireAdapterItem +import dev.ohs.fhir.datacapture.generated.resources.Res +import dev.ohs.fhir.datacapture.generated.resources.delete +import dev.ohs.fhir.datacapture.generated.resources.delete_24px +import dev.ohs.fhir.datacapture.generated.resources.repeated_group_title +import dev.ohs.fhir.datacapture.theme.QuestionnaireTheme +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.painterResource +import org.jetbrains.compose.resources.stringResource + +internal const val DELETE_REPEATED_GROUP_ITEM_BUTTON_TAG = "delete_repeated_group_item_button" +internal const val REPEATED_GROUP_INSTANCE_HEADER_TITLE_TAG = "repeated_group_instance_header_title" + +@Composable +internal fun RepeatedGroupHeaderItem( + repeatedGroupHeader: QuestionnaireAdapterItem.RepeatedGroupHeader, + enabled: Boolean = true, +) { + val coroutineScope = rememberCoroutineScope { Dispatchers.Main } + val indexNumber = remember(repeatedGroupHeader.index) { "${repeatedGroupHeader.index + 1}" } + val title = remember(repeatedGroupHeader.title) { repeatedGroupHeader.title } + val color = + if (enabled) QuestionnaireTheme.colorScheme.error else QuestionnaireTheme.colorScheme.onError + + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(Res.string.repeated_group_title, indexNumber, title), + modifier = Modifier.testTag(REPEATED_GROUP_INSTANCE_HEADER_TITLE_TAG).weight(1f), + style = QuestionnaireTheme.typography.titleMedium, + ) + + OutlinedButton( + modifier = Modifier.testTag(DELETE_REPEATED_GROUP_ITEM_BUTTON_TAG), + onClick = { coroutineScope.launch { repeatedGroupHeader.onDeleteClicked() } }, + shape = RoundedCornerShape(4.dp), + border = ButtonDefaults.outlinedButtonBorder().copy(brush = SolidColor(color)), + contentPadding = ButtonDefaults.ButtonWithIconContentPadding, + ) { + Icon( + painter = painterResource(Res.drawable.delete_24px), + contentDescription = DELETE_REPEATED_GROUP_ITEM_BUTTON_TAG, + tint = color, + ) + Spacer(modifier = Modifier.width(ButtonDefaults.IconSpacing)) + Text( + text = stringResource(Res.string.delete), + color = color, + ) + } + } +} diff --git a/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/views/components/SliderItem.kt b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/views/components/SliderItem.kt new file mode 100644 index 000000000..4b882f9eb --- /dev/null +++ b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/views/components/SliderItem.kt @@ -0,0 +1,62 @@ +/* + * Copyright 2025-2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.ohs.fhir.datacapture.views.components + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Slider +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import kotlin.math.roundToInt + +internal const val SLIDER_TAG = "slider" + +@Composable +internal fun SliderItem( + position: Float, + steps: Int, + valueRange: ClosedFloatingPointRange, + enabled: Boolean, + onPositionChanged: (Float) -> Unit, +) { + var sliderPosition by remember(position) { mutableFloatStateOf(position) } + + Column { + Slider( + value = sliderPosition, + onValueChange = { sliderPosition = it }, + onValueChangeFinished = { onPositionChanged(sliderPosition) }, + steps = steps, + valueRange = valueRange, + modifier = Modifier.fillMaxWidth().testTag(SLIDER_TAG), + enabled = enabled, + ) + Spacer(modifier = Modifier) + Text( + text = sliderPosition.roundToInt().toString(), + style = MaterialTheme.typography.titleMedium, + ) + } +} diff --git a/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/views/components/TimeFieldItem.kt b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/views/components/TimeFieldItem.kt new file mode 100644 index 000000000..6b5ab4b83 --- /dev/null +++ b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/views/components/TimeFieldItem.kt @@ -0,0 +1,160 @@ +/* + * Copyright 2025-2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.ohs.fhir.datacapture.views.components + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuAnchorType +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusDirection +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.error +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import dev.ohs.fhir.datacapture.generated.resources.Res +import dev.ohs.fhir.datacapture.generated.resources.gm_schedule_24 +import dev.ohs.fhir.datacapture.generated.resources.select_time +import dev.ohs.fhir.datacapture.generated.resources.time +import dev.ohs.fhir.datacapture.getLocalDateTimeFormatter +import kotlinx.datetime.LocalTime +import org.jetbrains.compose.resources.painterResource +import org.jetbrains.compose.resources.stringResource +import org.jetbrains.compose.ui.tooling.preview.Preview + +internal const val TIME_PICKER_INPUT_FIELD = "time_picker_text_field" + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun TimeFieldItem( + modifier: Modifier = Modifier, + timeSelectedDisplay: String?, + initialStartTime: LocalTime, + enabled: Boolean, + hint: String, + supportingHelperText: String?, + isError: Boolean, + onTimeChanged: (LocalTime) -> Unit, +) { + val focusManager = LocalFocusManager.current + val keyboardController = LocalSoftwareKeyboardController.current + val localDateTimeFormatter = getLocalDateTimeFormatter() + + var selectedTimeTextDisplay by + remember(timeSelectedDisplay) { mutableStateOf(timeSelectedDisplay ?: "") } + var timePickerDialogType by remember { mutableStateOf(TimeInputMode.CLOCK) } + var expanded by remember { mutableStateOf(false) } + + ExposedDropdownMenuBox( + modifier = modifier, + expanded = expanded, + onExpandedChange = { + if (it) { + timePickerDialogType = TimeInputMode.KEYBOARD + } + expanded = it + }, + ) { + OutlinedTextField( + value = selectedTimeTextDisplay, + onValueChange = {}, + singleLine = true, + label = { Text(hint) }, + modifier = + Modifier.fillMaxWidth() + .menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable, enabled) + .testTag(TIME_PICKER_INPUT_FIELD) + .onFocusChanged { + if (!it.isFocused) { + keyboardController?.hide() + } + } + .semantics { if (isError) error(supportingHelperText ?: "") }, + supportingText = { supportingHelperText?.let { Text(it) } }, + isError = isError, + trailingIcon = { + IconButton( + onClick = { + timePickerDialogType = TimeInputMode.CLOCK + expanded = true + }, + enabled = enabled, + ) { + Icon( + painterResource(Res.drawable.gm_schedule_24), + contentDescription = stringResource(Res.string.select_time), + ) + } + }, + readOnly = true, + enabled = enabled, + keyboardOptions = + KeyboardOptions( + autoCorrectEnabled = false, + keyboardType = KeyboardType.Number, + imeAction = ImeAction.Done, + ), + keyboardActions = + KeyboardActions( + onNext = { focusManager.moveFocus(FocusDirection.Down) }, + ), + ) + + if (expanded) { + TimePickerDialog( + type = timePickerDialogType, + initialSelectedHour = initialStartTime.hour, + initialSelectedMinute = initialStartTime.minute, + onDismiss = { expanded = false }, + ) { hour, min, + -> + val localTime = LocalTime(hour, min) + selectedTimeTextDisplay = localDateTimeFormatter.localizedTimeString(localTime) + onTimeChanged(localTime) + } + } + } +} + +@Composable +@Preview +internal fun PreviewTimePickerItem() { + TimeFieldItem( + Modifier, + null, + LocalTime(11, 38), + true, + stringResource(Res.string.time), + null, + false, + ) {} +} diff --git a/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/views/components/TimePickerDialog.kt b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/views/components/TimePickerDialog.kt new file mode 100644 index 000000000..5162f8eb0 --- /dev/null +++ b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/views/components/TimePickerDialog.kt @@ -0,0 +1,139 @@ +/* + * Copyright 2025-2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.ohs.fhir.datacapture.views.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TimeInput +import androidx.compose.material3.TimePicker +import androidx.compose.material3.rememberTimePickerState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import dev.ohs.fhir.datacapture.generated.resources.Res +import dev.ohs.fhir.datacapture.generated.resources.cancel +import dev.ohs.fhir.datacapture.generated.resources.ic_access_time +import dev.ohs.fhir.datacapture.generated.resources.ic_keyboard +import dev.ohs.fhir.datacapture.generated.resources.ok +import dev.ohs.fhir.datacapture.generated.resources.select_time +import dev.ohs.fhir.datacapture.generated.resources.switch_to_clock_input +import dev.ohs.fhir.datacapture.generated.resources.switch_to_text_input +import org.jetbrains.compose.resources.painterResource +import org.jetbrains.compose.resources.stringResource +import org.jetbrains.compose.ui.tooling.preview.Preview + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun TimePickerDialog( + type: TimeInputMode, + initialSelectedHour: Int = 0, + initialSelectedMinute: Int = 0, + onDismiss: () -> Unit, + onConfirm: (Int, Int) -> Unit, +) { + val timePickerState = + rememberTimePickerState( + initialHour = initialSelectedHour, + initialMinute = initialSelectedMinute, + ) + var inputType by remember(type) { mutableStateOf(type) } + + AlertDialog( + onDismissRequest = onDismiss, + confirmButton = { + TextButton( + onClick = { + onConfirm(timePickerState.hour, timePickerState.minute) + onDismiss() + }, + ) { + Text(stringResource(Res.string.ok)) + } + }, + dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(Res.string.cancel)) } }, + text = { + Column( + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + if (inputType == TimeInputMode.CLOCK) { + TimePicker(state = timePickerState) + } else { + TimeInput(state = timePickerState) + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Start, + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton( + onClick = { + inputType = + if (inputType == TimeInputMode.CLOCK) { + TimeInputMode.KEYBOARD + } else { + TimeInputMode.CLOCK + } + }, + ) { + val iconRes = + if (inputType == TimeInputMode.CLOCK) { + Res.drawable.ic_keyboard + } else { + Res.drawable.ic_access_time + } + Icon( + painterResource(iconRes), + contentDescription = + if (inputType == TimeInputMode.CLOCK) { + stringResource(Res.string.switch_to_text_input) + } else { + stringResource(Res.string.switch_to_clock_input) + }, + ) + } + } + } + }, + title = { Text(stringResource(Res.string.select_time)) }, + ) +} + +internal sealed interface TimeInputMode { + object KEYBOARD : TimeInputMode + + object CLOCK : TimeInputMode +} + +@Preview +@Composable +fun TimePickerDialogPreview() { + TimePickerDialog(onDismiss = {}, type = TimeInputMode.KEYBOARD, onConfirm = { _, _ -> }) +} diff --git a/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/views/components/Utils.kt b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/views/components/Utils.kt new file mode 100644 index 000000000..e02fee95e --- /dev/null +++ b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/views/components/Utils.kt @@ -0,0 +1,53 @@ +/* + * Copyright 2025-2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.ohs.fhir.datacapture.views.components + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import dev.ohs.fhir.datacapture.generated.resources.Res +import dev.ohs.fhir.datacapture.generated.resources.optional_helper_text +import dev.ohs.fhir.datacapture.generated.resources.required +import dev.ohs.fhir.datacapture.views.QuestionnaireViewItem +import org.jetbrains.compose.resources.stringResource + +/** + * Returns string identified by R.string.required if + * [QuestionnaireViewItem.questionViewTextConfiguration.showRequiredText] and + * [com.google.fhir.model.r4.Questionnaire.Item.required] is true, or R.string.optional_text if + * [QuestionnaireViewItem.questionViewTextConfiguration.showOptionalText] is true. + */ +@Composable +fun getRequiredOrOptionalText(questionnaireViewItem: QuestionnaireViewItem): String? { + val requiredTextString = stringResource(Res.string.required) + val optionalHelperTextString = stringResource(Res.string.optional_helper_text) + + return remember(questionnaireViewItem) { + when { + (questionnaireViewItem.questionnaireItem.required?.value == true && + questionnaireViewItem.questionViewTextConfiguration.showRequiredText) -> { + requiredTextString + } + (questionnaireViewItem.questionnaireItem.required?.value != true && + questionnaireViewItem.questionViewTextConfiguration.showOptionalText) -> { + optionalHelperTextString + } + else -> { + null + } + } + } +} diff --git a/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/views/factories/AttachmentViewFactory.kt b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/views/factories/AttachmentViewFactory.kt new file mode 100644 index 000000000..6980783ac --- /dev/null +++ b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/views/factories/AttachmentViewFactory.kt @@ -0,0 +1,425 @@ +/* + * Copyright 2022-2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.ohs.fhir.datacapture.views.factories + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.decodeToImageBitmap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.google.fhir.model.r4.Attachment +import com.google.fhir.model.r4.QuestionnaireResponse +import dev.ohs.fhir.datacapture.MediaCaptureResult +import dev.ohs.fhir.datacapture.MediaHandler +import dev.ohs.fhir.datacapture.extensions.MimeType +import dev.ohs.fhir.datacapture.extensions.data +import dev.ohs.fhir.datacapture.extensions.hasMimeType +import dev.ohs.fhir.datacapture.extensions.hasMimeTypeOnly +import dev.ohs.fhir.datacapture.extensions.itemMedia +import dev.ohs.fhir.datacapture.extensions.maxAllowedAttachmentSize +import dev.ohs.fhir.datacapture.extensions.mimeTypes +import dev.ohs.fhir.datacapture.generated.resources.Res +import dev.ohs.fhir.datacapture.generated.resources.cd_file_icon_preview +import dev.ohs.fhir.datacapture.generated.resources.cd_photo_preview +import dev.ohs.fhir.datacapture.generated.resources.delete +import dev.ohs.fhir.datacapture.generated.resources.ic_audio_file +import dev.ohs.fhir.datacapture.generated.resources.ic_camera +import dev.ohs.fhir.datacapture.generated.resources.ic_delete +import dev.ohs.fhir.datacapture.generated.resources.ic_document_file +import dev.ohs.fhir.datacapture.generated.resources.ic_file +import dev.ohs.fhir.datacapture.generated.resources.ic_image_file +import dev.ohs.fhir.datacapture.generated.resources.ic_video_file +import dev.ohs.fhir.datacapture.generated.resources.loading +import dev.ohs.fhir.datacapture.generated.resources.take_photo +import dev.ohs.fhir.datacapture.generated.resources.upload_audio +import dev.ohs.fhir.datacapture.generated.resources.upload_document +import dev.ohs.fhir.datacapture.generated.resources.upload_file +import dev.ohs.fhir.datacapture.generated.resources.upload_photo +import dev.ohs.fhir.datacapture.generated.resources.upload_video +import dev.ohs.fhir.datacapture.generated.resources.uploaded +import dev.ohs.fhir.datacapture.rememberMediaHandler +import dev.ohs.fhir.datacapture.theme.QuestionnaireTheme +import dev.ohs.fhir.datacapture.validation.Invalid +import dev.ohs.fhir.datacapture.views.QuestionnaireViewItem +import dev.ohs.fhir.datacapture.views.components.ErrorText +import dev.ohs.fhir.datacapture.views.components.Header +import dev.ohs.fhir.datacapture.views.components.MediaItem +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.DrawableResource +import org.jetbrains.compose.resources.StringResource +import org.jetbrains.compose.resources.painterResource +import org.jetbrains.compose.resources.stringResource + +private const val BUTTON_ICON_SPACING = 4 + +internal const val TAKE_PHOTO_BUTTON_TAG = "TakePhotoButton" +internal const val UPLOAD_FILE_BUTTON_TAG = "UploadFileButton" +internal const val ATTACHMENT_MEDIA_PREVIEW_TAG = "photo_preview" + +internal object AttachmentViewFactory : QuestionnaireItemViewFactory { + + @Composable + override fun Content(questionnaireViewItem: QuestionnaireViewItem) { + val coroutineScope = rememberCoroutineScope { Dispatchers.Main } + val validationResult = + remember(questionnaireViewItem.validationResult) { questionnaireViewItem.validationResult } + var errorMessage by + remember(validationResult) { + mutableStateOf((validationResult as? Invalid)?.singleStringValidationMessage) + } + val questionnaireItem = + remember(questionnaireViewItem.questionnaireItem) { questionnaireViewItem.questionnaireItem } + val readOnly = remember(questionnaireItem) { questionnaireItem.readOnly?.value ?: false } + val fileMimeTypes = remember(questionnaireItem) { questionnaireItem.mimeTypes.toTypedArray() } + var currentAttachment by + remember(questionnaireViewItem.answers) { + mutableStateOf(questionnaireViewItem.answers.singleOrNull()?.value?.asAttachment()?.value) + } + val maxSupportedFileSizeBytes = + remember(questionnaireItem) { questionnaireItem.maxAllowedAttachmentSize } + val attachmentMediaHandler = rememberMediaHandler(maxSupportedFileSizeBytes, fileMimeTypes) + val displayTakePhotoButton = + remember(questionnaireItem) { questionnaireItem.hasMimeType(MimeType.IMAGE.value) } + val isCameraAvailable = + remember(attachmentMediaHandler) { attachmentMediaHandler.isCameraSupported() } + val uploadButtonTextResId = + remember(questionnaireItem) { + when { + questionnaireItem.hasMimeTypeOnly(MimeType.AUDIO.value) -> Res.string.upload_audio + questionnaireItem.hasMimeTypeOnly(MimeType.DOCUMENT.value) -> Res.string.upload_document + questionnaireItem.hasMimeTypeOnly(MimeType.IMAGE.value) -> Res.string.upload_photo + questionnaireItem.hasMimeTypeOnly(MimeType.VIDEO.value) -> Res.string.upload_video + else -> Res.string.upload_file + } + } + val uploadButtonIconResId = + remember(questionnaireItem) { + when { + questionnaireItem.hasMimeTypeOnly(MimeType.AUDIO.value) -> Res.drawable.ic_audio_file + questionnaireItem.hasMimeTypeOnly(MimeType.DOCUMENT.value) -> + Res.drawable.ic_document_file + questionnaireItem.hasMimeTypeOnly(MimeType.IMAGE.value) -> Res.drawable.ic_image_file + questionnaireItem.hasMimeTypeOnly(MimeType.VIDEO.value) -> Res.drawable.ic_video_file + else -> Res.drawable.ic_file + } + } + + var displayUploadedText by + remember(questionnaireViewItem.questionnaireItem) { mutableStateOf(false) } + + Column( + modifier = + Modifier.padding( + horizontal = QuestionnaireTheme.dimensions.itemMarginHorizontal, + vertical = QuestionnaireTheme.dimensions.itemMarginVertical, + ), + ) { + Header(questionnaireViewItem, showRequiredOrOptionalText = true) + questionnaireViewItem.questionnaireItem.itemMedia?.let { MediaItem(it) } + + errorMessage?.takeIf { it.isNotBlank() }?.let { ErrorText(it) } + + Row( + modifier = Modifier.padding(top = QuestionnaireTheme.dimensions.headerMarginBottom), + horizontalArrangement = + Arrangement.spacedBy(QuestionnaireTheme.dimensions.attachmentActionButtonMarginEnd), + ) { + if (displayTakePhotoButton) { + TakePhotoButton( + enabled = isCameraAvailable && !readOnly, + mediaHandler = attachmentMediaHandler, + onFailure = { errorMessage = it }, + ) { attachment -> + coroutineScope.launch { + currentAttachment = attachment + errorMessage = null + + val answer = + QuestionnaireResponse.Item.Answer( + value = QuestionnaireResponse.Item.Answer.Value.Attachment(value = attachment), + ) + questionnaireViewItem.setAnswer(answer) + + displayUploadedText = true + } + } + } + + UploadFileButton( + enabled = !readOnly, + mediaHandler = attachmentMediaHandler, + uploadButtonIconResId = uploadButtonIconResId, + uploadButtonTextResId = uploadButtonTextResId, + mimeTypes = fileMimeTypes, + onFailure = { errorMessage = it }, + ) { + coroutineScope.launch { + currentAttachment = it + errorMessage = null + + val answer = + QuestionnaireResponse.Item.Answer( + value = QuestionnaireResponse.Item.Answer.Value.Attachment(value = it), + ) + questionnaireViewItem.setAnswer(answer) + + displayUploadedText = true + } + } + } + + if (displayUploadedText) { + Spacer( + modifier = Modifier.height(QuestionnaireTheme.dimensions.attachmentDividerMarginTop), + ) + HorizontalDivider() + Spacer( + modifier = + Modifier.height(QuestionnaireTheme.dimensions.attachmentUploadedLabelMarginTop), + ) + Text(stringResource(Res.string.uploaded), style = QuestionnaireTheme.typography.titleSmall) + } + + currentAttachment?.let { + Spacer(modifier = Modifier.height(8.dp)) + AttachmentPreview( + it, + deleteEnabled = !readOnly, + ) { + currentAttachment = null + displayUploadedText = false + coroutineScope.launch { questionnaireViewItem.clearAnswer() } + } + + Spacer( + modifier = + Modifier.height(QuestionnaireTheme.dimensions.attachmentPreviewDividerMarginTop), + ) + HorizontalDivider() + } + } + } +} + +@Composable +private fun TakePhotoButton( + enabled: Boolean, + mediaHandler: MediaHandler, + onFailure: (String) -> Unit, + onSuccess: (Attachment) -> Unit, +) { + val coroutineScope = rememberCoroutineScope() + var isLoading by remember { mutableStateOf(false) } + + OutlinedButton( + modifier = Modifier.testTag(TAKE_PHOTO_BUTTON_TAG), + onClick = { + if (!isLoading) { + isLoading = true + coroutineScope + .launch { + when (val result = mediaHandler.capturePhoto()) { + is MediaCaptureResult.Success -> onSuccess(result.attachment) + is MediaCaptureResult.Error -> onFailure(result.error) + } + } + .invokeOnCompletion { isLoading = false } + } + }, + enabled = enabled && !isLoading, + colors = + ButtonDefaults.outlinedButtonColors() + .copy(contentColor = QuestionnaireTheme.colorScheme.primary), + ) { + val takePhotoText = stringResource(Res.string.take_photo) + Icon( + painterResource(Res.drawable.ic_camera), + tint = QuestionnaireTheme.colorScheme.primary, + contentDescription = takePhotoText, + modifier = Modifier.size(QuestionnaireTheme.dimensions.attachmentActionButtonIconSize), + ) + Spacer(modifier = Modifier.width(BUTTON_ICON_SPACING.dp)) + Text(if (isLoading) stringResource(Res.string.loading) else takePhotoText) + } +} + +@Composable +private fun UploadFileButton( + enabled: Boolean, + mediaHandler: MediaHandler, + uploadButtonIconResId: DrawableResource, + uploadButtonTextResId: StringResource, + mimeTypes: Array, + onFailure: (String) -> Unit, + onSuccess: (Attachment) -> Unit, +) { + val coroutineScope = rememberCoroutineScope() + var isLoading by remember { mutableStateOf(false) } + + OutlinedButton( + modifier = Modifier.testTag(UPLOAD_FILE_BUTTON_TAG), + onClick = { + if (!isLoading) { + isLoading = true + coroutineScope + .launch { + when (val result = mediaHandler.selectFile(mimeTypes)) { + is MediaCaptureResult.Success -> onSuccess(result.attachment) + is MediaCaptureResult.Error -> onFailure(result.error) + } + } + .invokeOnCompletion { isLoading = false } + } + }, + enabled = enabled && !isLoading, + colors = + ButtonDefaults.outlinedButtonColors() + .copy(contentColor = QuestionnaireTheme.colorScheme.primary), + ) { + Icon( + painterResource(uploadButtonIconResId), + contentDescription = stringResource(uploadButtonTextResId), + tint = QuestionnaireTheme.colorScheme.primary, + modifier = Modifier.size(QuestionnaireTheme.dimensions.attachmentActionButtonIconSize), + ) + Spacer(modifier = Modifier.width(BUTTON_ICON_SPACING.dp)) + Text( + if (isLoading) stringResource(Res.string.loading) else stringResource(uploadButtonTextResId), + ) + } +} + +@Composable +private fun AttachmentPreview( + attachment: Attachment, + deleteEnabled: Boolean, + onDeleteClick: () -> Unit, +) { + Row( + modifier = Modifier.testTag(ATTACHMENT_MEDIA_PREVIEW_TAG).fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Row( + modifier = Modifier.weight(1f), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + val mimeType = + remember(attachment.contentType?.value) { + attachment.contentType?.value?.substringBefore("/") + } + + when (mimeType) { + MimeType.IMAGE.value -> { + val imageBitmap = + remember(attachment.data) { attachment.data?.data?.decodeToImageBitmap() } + imageBitmap?.let { + Image( + bitmap = it, + contentDescription = stringResource(Res.string.cd_photo_preview), + modifier = + Modifier.size( + QuestionnaireTheme.dimensions.attachmentPreviewPhotoWidth, + ) + .clip( + RoundedCornerShape(8.dp), + ), + contentScale = ContentScale.Crop, + ) + } + } + else -> { + val iconRes = + remember(mimeType) { + when (mimeType) { + MimeType.AUDIO.value -> Res.drawable.ic_audio_file + MimeType.DOCUMENT.value -> Res.drawable.ic_document_file + MimeType.VIDEO.value -> Res.drawable.ic_video_file + else -> Res.drawable.ic_file + } + } + Box( + modifier = + Modifier.size(QuestionnaireTheme.dimensions.attachmentPreviewPhotoWidth) + .background( + color = QuestionnaireTheme.colorScheme.primaryContainer, + shape = RoundedCornerShape(8.dp), + ), + contentAlignment = Alignment.Center, + ) { + Icon( + painterResource(iconRes), + contentDescription = stringResource(Res.string.cd_file_icon_preview), + modifier = + Modifier.padding(QuestionnaireTheme.dimensions.attachmentPreviewFileIconMargin), + ) + } + } + } + + Text( + text = attachment.title?.value ?: "", + style = QuestionnaireTheme.textStyles.attachmentPreviewTitle, + overflow = TextOverflow.Ellipsis, + maxLines = 1, + modifier = Modifier.weight(1f), + ) + } + + OutlinedButton( + onClick = { onDeleteClick.invoke() }, + enabled = deleteEnabled, + ) { + Icon( + painter = painterResource(Res.drawable.ic_delete), + tint = QuestionnaireTheme.colorScheme.error, + contentDescription = stringResource(Res.string.delete), + ) + Spacer(modifier = Modifier.width(BUTTON_ICON_SPACING.dp)) + Text(stringResource(Res.string.delete), color = QuestionnaireTheme.colorScheme.error) + } + } +} diff --git a/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/views/factories/AutoCompleteViewFactory.kt b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/views/factories/AutoCompleteViewFactory.kt new file mode 100644 index 000000000..aecaf3f60 --- /dev/null +++ b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/views/factories/AutoCompleteViewFactory.kt @@ -0,0 +1,141 @@ +/* + * Copyright 2022-2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.ohs.fhir.datacapture.views.factories + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import dev.ohs.fhir.datacapture.extensions.elementValue +import dev.ohs.fhir.datacapture.extensions.itemMedia +import dev.ohs.fhir.datacapture.extensions.toQuestionnaireResponseItemAnswer +import dev.ohs.fhir.datacapture.theme.QuestionnaireTheme +import dev.ohs.fhir.datacapture.validation.Invalid +import dev.ohs.fhir.datacapture.views.QuestionnaireViewItem +import dev.ohs.fhir.datacapture.views.components.DropDownAnswerOption +import dev.ohs.fhir.datacapture.views.components.Header +import dev.ohs.fhir.datacapture.views.components.MediaItem +import dev.ohs.fhir.datacapture.views.components.MultiAutoCompleteTextItem +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +internal object AutoCompleteViewFactory : QuestionnaireItemViewFactory { + @Composable + override fun Content(questionnaireViewItem: QuestionnaireViewItem) { + val coroutineScope = rememberCoroutineScope { Dispatchers.Main } + val canHaveMultipleAnswers = + remember(questionnaireViewItem.questionnaireItem) { + questionnaireViewItem.questionnaireItem.repeats?.value ?: false + } + val enabledAnswerOptions = + questionnaireViewItem.enabledAnswerOptions.map { DropDownAnswerOption.of(it) } + var selectedAnswerOptions by + remember(questionnaireViewItem.answers, enabledAnswerOptions) { + val answersElementSet = questionnaireViewItem.answers.map { it.elementValue }.toSet() + mutableStateOf( + enabledAnswerOptions.filter { it.elementValue in answersElementSet }, + ) + } + val errorTextMessage = + remember(questionnaireViewItem.validationResult) { + when (val validationResult = questionnaireViewItem.validationResult) { + is Invalid -> validationResult.singleStringValidationMessage.takeIf { it.isNotBlank() } + else -> null + } + } + val isReadOnly = + remember(questionnaireViewItem.questionnaireItem) { + questionnaireViewItem.questionnaireItem.readOnly?.value ?: false + } + + Column( + modifier = + Modifier.fillMaxWidth() + .padding( + horizontal = QuestionnaireTheme.dimensions.itemMarginHorizontal, + vertical = QuestionnaireTheme.dimensions.itemMarginVertical, + ), + ) { + Header(questionnaireViewItem, showRequiredOrOptionalText = true) + questionnaireViewItem.questionnaireItem.itemMedia?.let { MediaItem(it) } + + MultiAutoCompleteTextItem( + modifier = Modifier.fillMaxWidth(), + enabled = !isReadOnly, + supportingText = errorTextMessage, + isError = errorTextMessage.isNullOrBlank().not(), + options = enabledAnswerOptions, + selectedOptions = selectedAnswerOptions, + onNewOptionSelected = { answerOption -> + selectedAnswerOptions = + if (canHaveMultipleAnswers) { + if (answerOption in selectedAnswerOptions) { + selectedAnswerOptions + } else { + selectedAnswerOptions + answerOption + } + } else { + listOf(answerOption) + } + + val questionnaireResponseAnswer = + questionnaireViewItem.enabledAnswerOptions + .first { it.elementValue == answerOption.elementValue } + .toQuestionnaireResponseItemAnswer() + + val answerNotPresent = + questionnaireViewItem.answers.none { + it.elementValue == questionnaireResponseAnswer.elementValue + } + if (answerNotPresent) { + coroutineScope.launch { + if (canHaveMultipleAnswers) { + questionnaireViewItem.addAnswer(questionnaireResponseAnswer) + } else { + questionnaireViewItem.setAnswer(questionnaireResponseAnswer) + } + } + } + }, + onOptionDeselected = { option -> + selectedAnswerOptions = selectedAnswerOptions.filterNot { it == option } + + coroutineScope.launch { + if (canHaveMultipleAnswers) { + val deSelectedAnswerOption = + questionnaireViewItem.enabledAnswerOptions.first { + it.elementValue == option.elementValue + } + + questionnaireViewItem.removeAnswer( + deSelectedAnswerOption.toQuestionnaireResponseItemAnswer(), + ) + } else { + questionnaireViewItem.clearAnswer() + } + } + }, + ) + } + } +} diff --git a/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/views/factories/BooleanChoiceViewFactory.kt b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/views/factories/BooleanChoiceViewFactory.kt new file mode 100644 index 000000000..184230c98 --- /dev/null +++ b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/views/factories/BooleanChoiceViewFactory.kt @@ -0,0 +1,159 @@ +/* + * Copyright 2022-2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.ohs.fhir.datacapture.views.factories + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.AnnotatedString +import com.google.fhir.model.r4.QuestionnaireResponse +import dev.ohs.fhir.datacapture.extensions.ChoiceOrientationTypes +import dev.ohs.fhir.datacapture.extensions.FhirR4Boolean +import dev.ohs.fhir.datacapture.extensions.choiceOrientation +import dev.ohs.fhir.datacapture.extensions.itemMedia +import dev.ohs.fhir.datacapture.generated.resources.Res +import dev.ohs.fhir.datacapture.generated.resources.no +import dev.ohs.fhir.datacapture.generated.resources.yes +import dev.ohs.fhir.datacapture.theme.QuestionnaireTheme +import dev.ohs.fhir.datacapture.views.QuestionnaireViewItem +import dev.ohs.fhir.datacapture.views.components.ChoiceRadioButton +import dev.ohs.fhir.datacapture.views.components.Header +import dev.ohs.fhir.datacapture.views.components.MediaItem +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.stringResource + +internal const val YES_CHOICE_RADIO_BUTTON_TAG = "yes_radio_button" +internal const val NO_CHOICE_RADIO_BUTTON_TAG = "no_radio_button" + +internal object BooleanChoiceViewFactory : QuestionnaireItemViewFactory { + @Composable + override fun Content(questionnaireViewItem: QuestionnaireViewItem) { + val coroutineScope = rememberCoroutineScope { Dispatchers.Main } + val readOnly = + remember(questionnaireViewItem) { + questionnaireViewItem.questionnaireItem.readOnly?.value ?: false + } + val choiceOrientationType = + remember(questionnaireViewItem) { + questionnaireViewItem.questionnaireItem.choiceOrientation ?: ChoiceOrientationTypes.VERTICAL + } + val currentAnswer = + remember(questionnaireViewItem) { + questionnaireViewItem.answers.singleOrNull()?.value?.asBoolean()?.value?.value + } + var selectedChoiceState by remember(currentAnswer) { mutableStateOf(currentAnswer) } + + val onChoiceSelection: suspend (Boolean) -> Unit = { selected -> + if (selectedChoiceState != selected) { + selectedChoiceState = selected + questionnaireViewItem.setAnswer( + QuestionnaireResponse.Item.Answer( + value = + QuestionnaireResponse.Item.Answer.Value.Boolean( + value = FhirR4Boolean(value = selected), + ), + ), + ) + } else { + // clear selection + selectedChoiceState = null + questionnaireViewItem.clearAnswer() + } + } + + Column( + modifier = + Modifier.fillMaxWidth() + .padding( + horizontal = QuestionnaireTheme.dimensions.itemMarginHorizontal, + vertical = QuestionnaireTheme.dimensions.itemMarginVertical, + ), + ) { + Header( + questionnaireViewItem, + showRequiredOrOptionalText = true, + displayValidationResult = true, + ) + questionnaireViewItem.questionnaireItem.itemMedia?.let { MediaItem(it) } + + when (choiceOrientationType) { + ChoiceOrientationTypes.HORIZONTAL -> { + FlowRow( + modifier = Modifier.selectableGroup().fillMaxWidth(), + horizontalArrangement = + Arrangement.spacedBy(QuestionnaireTheme.dimensions.optionItemMarginHorizontal), + verticalArrangement = + Arrangement.spacedBy(QuestionnaireTheme.dimensions.optionItemMarginVertical), + ) { + ChoiceRadioButton( + label = AnnotatedString(stringResource(Res.string.yes)), + selected = selectedChoiceState == true, + enabled = !readOnly, + modifier = Modifier.weight(1f).testTag(YES_CHOICE_RADIO_BUTTON_TAG), + ) { + coroutineScope.launch { onChoiceSelection(true) } + } + + ChoiceRadioButton( + label = AnnotatedString(stringResource(Res.string.no)), + selected = selectedChoiceState == false, + enabled = !readOnly, + modifier = Modifier.weight(1f).testTag(NO_CHOICE_RADIO_BUTTON_TAG), + onClick = { coroutineScope.launch { onChoiceSelection(false) } }, + ) + } + } + ChoiceOrientationTypes.VERTICAL -> { + Column( + modifier = Modifier.selectableGroup().fillMaxWidth(), + verticalArrangement = + Arrangement.spacedBy(QuestionnaireTheme.dimensions.optionItemMarginVertical), + ) { + ChoiceRadioButton( + label = AnnotatedString(stringResource(Res.string.yes)), + selected = selectedChoiceState == true, + enabled = !readOnly, + modifier = Modifier.fillMaxWidth().testTag(YES_CHOICE_RADIO_BUTTON_TAG), + ) { + coroutineScope.launch { onChoiceSelection(true) } + } + + ChoiceRadioButton( + label = AnnotatedString(stringResource(Res.string.no)), + selected = selectedChoiceState == false, + enabled = !readOnly, + modifier = Modifier.fillMaxWidth().testTag(NO_CHOICE_RADIO_BUTTON_TAG), + onClick = { coroutineScope.launch { onChoiceSelection(false) } }, + ) + } + } + } + } + } +} diff --git a/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/views/factories/CheckBoxGroupViewFactory.kt b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/views/factories/CheckBoxGroupViewFactory.kt new file mode 100644 index 000000000..d421fc987 --- /dev/null +++ b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/views/factories/CheckBoxGroupViewFactory.kt @@ -0,0 +1,167 @@ +/* + * Copyright 2022-2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.ohs.fhir.datacapture.views.factories + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.AnnotatedString +import com.google.fhir.model.r4.Questionnaire +import dev.ohs.fhir.datacapture.extensions.ChoiceOrientationTypes +import dev.ohs.fhir.datacapture.extensions.choiceOrientation +import dev.ohs.fhir.datacapture.extensions.displayString +import dev.ohs.fhir.datacapture.extensions.itemAnswerOptionImage +import dev.ohs.fhir.datacapture.extensions.itemMedia +import dev.ohs.fhir.datacapture.extensions.optionExclusive +import dev.ohs.fhir.datacapture.extensions.toQuestionnaireResponseItemAnswer +import dev.ohs.fhir.datacapture.theme.QuestionnaireTheme +import dev.ohs.fhir.datacapture.views.QuestionnaireViewItem +import dev.ohs.fhir.datacapture.views.components.ChoiceCheckbox +import dev.ohs.fhir.datacapture.views.components.Header +import dev.ohs.fhir.datacapture.views.components.MediaItem +import dev.ohs.fhir.datacapture.views.isAnswerOptionSelected +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +internal const val CHECKBOX_OPTION_TAG = "checkbox_group_option" + +internal object CheckBoxGroupViewFactory : QuestionnaireItemViewFactory { + @Composable + override fun Content(questionnaireViewItem: QuestionnaireViewItem) { + val coroutineScope = rememberCoroutineScope { Dispatchers.Main } + val readOnly = + remember(questionnaireViewItem) { + questionnaireViewItem.questionnaireItem.readOnly?.value ?: false + } + val choiceOrientationType = + remember(questionnaireViewItem) { + questionnaireViewItem.questionnaireItem.choiceOrientation ?: ChoiceOrientationTypes.VERTICAL + } + val enabledAnswerOptions = + remember(questionnaireViewItem) { questionnaireViewItem.enabledAnswerOptions } + var selectedAnswerOptions by + remember(questionnaireViewItem) { + mutableStateOf( + enabledAnswerOptions.filter { questionnaireViewItem.isAnswerOptionSelected(it) }.toSet(), + ) + } + + val onAnswerOptionCheckedChange: suspend (Questionnaire.Item.AnswerOption, Boolean) -> Unit = + { answerOption, checked -> + when { + checked && answerOption.optionExclusive -> { + // If this answer option has optionExclusive extension, deselect other options + selectedAnswerOptions = setOf(answerOption) + questionnaireViewItem.setAnswer( + answerOption.toQuestionnaireResponseItemAnswer(), + ) + } + checked -> { + // Deselect any optionExclusive answer options + val exclusiveOptions = enabledAnswerOptions.filter { it.optionExclusive }.toSet() + selectedAnswerOptions = (selectedAnswerOptions - exclusiveOptions) + answerOption + + // Add the answer + val answers = + questionnaireViewItem.answers + answerOption.toQuestionnaireResponseItemAnswer() + // Remove exclusive options from answers + val newAnswers = + answers.filterNot { answer -> exclusiveOptions.any { it.value == answer.value } } + questionnaireViewItem.setAnswer(*newAnswers.toTypedArray()) + } + else -> { + // Remove the answer + selectedAnswerOptions = selectedAnswerOptions - answerOption + questionnaireViewItem.removeAnswer( + answerOption.toQuestionnaireResponseItemAnswer(), + ) + } + } + } + + Column( + modifier = + Modifier.fillMaxWidth() + .padding( + horizontal = QuestionnaireTheme.dimensions.itemMarginHorizontal, + vertical = QuestionnaireTheme.dimensions.itemMarginVertical, + ), + ) { + Header( + questionnaireViewItem, + showRequiredOrOptionalText = true, + displayValidationResult = true, + ) + questionnaireViewItem.questionnaireItem.itemMedia?.let { MediaItem(it) } + + when (choiceOrientationType) { + ChoiceOrientationTypes.HORIZONTAL -> { + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = + Arrangement.spacedBy(QuestionnaireTheme.dimensions.optionItemMarginHorizontal), + verticalArrangement = + Arrangement.spacedBy(QuestionnaireTheme.dimensions.optionItemMarginVertical), + ) { + enabledAnswerOptions.forEach { answerOption -> + val answerOptionDisplay = answerOption.displayString() + ChoiceCheckbox( + label = remember(answerOption) { AnnotatedString(answerOptionDisplay) }, + checked = answerOption in selectedAnswerOptions, + enabled = !readOnly, + modifier = Modifier.weight(1f).testTag(CHECKBOX_OPTION_TAG), + image = answerOption.itemAnswerOptionImage(), + ) { + coroutineScope.launch { onAnswerOptionCheckedChange(answerOption, it) } + } + } + } + } + ChoiceOrientationTypes.VERTICAL -> { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = + Arrangement.spacedBy(QuestionnaireTheme.dimensions.optionItemMarginVertical), + ) { + enabledAnswerOptions.forEach { answerOption -> + val answerOptionDisplay = answerOption.displayString() + ChoiceCheckbox( + label = remember(answerOption) { AnnotatedString(answerOptionDisplay) }, + checked = answerOption in selectedAnswerOptions, + enabled = !readOnly, + modifier = Modifier.fillMaxWidth().testTag(CHECKBOX_OPTION_TAG), + image = answerOption.itemAnswerOptionImage(), + ) { + coroutineScope.launch { onAnswerOptionCheckedChange(answerOption, it) } + } + } + } + } + } + } + } +} diff --git a/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/views/factories/DateTimeViewFactory.kt b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/views/factories/DateTimeViewFactory.kt new file mode 100644 index 000000000..c9d2a7907 --- /dev/null +++ b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/views/factories/DateTimeViewFactory.kt @@ -0,0 +1,263 @@ +/* + * Copyright 2022-2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.ohs.fhir.datacapture.views.factories + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.SelectableDates +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import com.google.fhir.model.r4.DateTime +import com.google.fhir.model.r4.FhirDateTime +import com.google.fhir.model.r4.QuestionnaireResponse +import dev.ohs.fhir.datacapture.extensions.canonicalizeDatePattern +import dev.ohs.fhir.datacapture.extensions.getDateSeparator +import dev.ohs.fhir.datacapture.extensions.itemMedia +import dev.ohs.fhir.datacapture.generated.resources.Res +import dev.ohs.fhir.datacapture.generated.resources.date_format_validation_error_msg +import dev.ohs.fhir.datacapture.generated.resources.required_text_and_new_line +import dev.ohs.fhir.datacapture.generated.resources.time +import dev.ohs.fhir.datacapture.getLocalDateTimeFormatter +import dev.ohs.fhir.datacapture.parseLocalDateOrNull +import dev.ohs.fhir.datacapture.theme.QuestionnaireTheme +import dev.ohs.fhir.datacapture.validation.Invalid +import dev.ohs.fhir.datacapture.views.QuestionnaireViewItem +import dev.ohs.fhir.datacapture.views.components.DateFieldItem +import dev.ohs.fhir.datacapture.views.components.DateInput +import dev.ohs.fhir.datacapture.views.components.DateInputFormat +import dev.ohs.fhir.datacapture.views.components.Header +import dev.ohs.fhir.datacapture.views.components.MediaItem +import dev.ohs.fhir.datacapture.views.components.TimeFieldItem +import dev.ohs.fhir.datacapture.views.components.getRequiredOrOptionalText +import kotlin.time.Clock +import kotlin.time.ExperimentalTime +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.LocalTime +import kotlinx.datetime.TimeZone +import kotlinx.datetime.atStartOfDayIn +import kotlinx.datetime.offsetAt +import kotlinx.datetime.toLocalDateTime +import org.jetbrains.compose.resources.stringResource + +@OptIn(ExperimentalTime::class) +internal object DateTimeViewFactory : QuestionnaireItemViewFactory { + + @Composable + override fun Content(questionnaireViewItem: QuestionnaireViewItem) { + val coroutineScope = rememberCoroutineScope { Dispatchers.Main } + val localDateTimeFormatter = getLocalDateTimeFormatter() + val itemReadOnly = + remember(questionnaireViewItem.questionnaireItem) { + questionnaireViewItem.questionnaireItem.readOnly?.value ?: false + } + val localDatePattern = remember { localDateTimeFormatter.localDateShortFormatPattern } + val datePatternSeparator = + remember(localDatePattern) { getDateSeparator(localDatePattern) ?: '/' } + val canonicalizedDatePattern = + remember(localDatePattern) { canonicalizeDatePattern(localDatePattern) } + val uiDatePatternText = + remember(canonicalizedDatePattern) { canonicalizedDatePattern.lowercase() } + val dateInputFormat = + remember(canonicalizedDatePattern, datePatternSeparator) { + DateInputFormat( + canonicalizedDatePattern, + datePatternSeparator, + ) + } + + var questionnaireItemViewItemAnswerLocalDateTime by + remember(questionnaireViewItem) { + mutableStateOf( + (questionnaireViewItem.answers.singleOrNull()?.value?.asDateTime()?.value?.value + as? FhirDateTime.DateTime) + ?.dateTime, + ) + } + + val questionnaireItemViewItemDate = + remember(questionnaireItemViewItemAnswerLocalDateTime) { + questionnaireItemViewItemAnswerLocalDateTime?.date + } + val questionnaireViewItemLocalTime = + remember(questionnaireItemViewItemAnswerLocalDateTime) { + questionnaireItemViewItemAnswerLocalDateTime?.time + } + val questionnaireItemAnswerDateInMillis = + remember(questionnaireItemViewItemDate) { + questionnaireItemViewItemDate?.atStartOfDayIn(TimeZone.UTC)?.toEpochMilliseconds() + } + val initialSelectedDateInMillis = + remember(questionnaireItemAnswerDateInMillis) { + questionnaireItemAnswerDateInMillis ?: Clock.System.now().toEpochMilliseconds() + } + val draftAnswer = + remember(questionnaireViewItem.draftAnswer) { questionnaireViewItem.draftAnswer as? String } + val dateInput = + remember(dateInputFormat, questionnaireItemViewItemDate, draftAnswer) { + questionnaireItemViewItemDate + ?.let { localDateTimeFormatter.format(it, dateInputFormat.pattern) } + ?.let { DateInput(it, questionnaireItemViewItemDate) } + ?: DateInput(display = draftAnswer ?: "", null) + } + + val questionnaireViewItemLocalTimeAnswerDisplay = + remember(questionnaireViewItemLocalTime) { + questionnaireViewItemLocalTime?.let { localDateTimeFormatter.localizedTimeString(it) } ?: "" + } + val initialTimeSelection = + remember(questionnaireViewItemLocalTime) { + questionnaireViewItemLocalTime + ?: Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).time + } + val timeInputEnabled = + remember(questionnaireItemViewItemDate) { + !itemReadOnly && questionnaireItemViewItemDate != null + } + + val selectableDates = remember { object : SelectableDates {} } + val invalidDraftDateErrorString = + stringResource( + Res.string.date_format_validation_error_msg, + canonicalizedDatePattern.lowercase(), + canonicalizedDatePattern.replace("dd", "31").replace("MM", "01").replace("yyyy", "2023"), + ) + val requiredTextNewLineStringResource = stringResource(Res.string.required_text_and_new_line) + val dateValidationMessage = + remember(draftAnswer, questionnaireViewItem.validationResult) { + val validationMessage = + when { + !draftAnswer.isNullOrBlank() -> { + // If the draft answer is set, this means the user has yet to type a parseable answer, + // so we display an error. + invalidDraftDateErrorString + } + questionnaireViewItem.validationResult is Invalid -> + questionnaireViewItem.validationResult.singleStringValidationMessage + else -> null + } + + validationMessage?.let { + if ( + questionnaireViewItem.questionnaireItem.required?.value == true && + questionnaireViewItem.questionViewTextConfiguration.showRequiredText + ) { + requiredTextNewLineStringResource + validationMessage + } else { + validationMessage + } + } + } + + Column( + modifier = + Modifier.padding( + horizontal = QuestionnaireTheme.dimensions.itemMarginHorizontal, + vertical = QuestionnaireTheme.dimensions.itemMarginVertical, + ), + ) { + Header(questionnaireViewItem) + questionnaireViewItem.questionnaireItem.itemMedia?.let { MediaItem(it) } + + Row(modifier = Modifier.fillMaxWidth()) { + DateFieldItem( + modifier = Modifier.weight(1f), + initialSelectedDateMillis = initialSelectedDateInMillis, + selectableDates = selectableDates, + dateInputFormat = dateInputFormat, + dateInput = dateInput, + labelText = uiDatePatternText, + helperText = dateValidationMessage.takeIf { !it.isNullOrBlank() } + ?: getRequiredOrOptionalText(questionnaireViewItem), + isError = !dateValidationMessage.isNullOrBlank(), + enabled = !itemReadOnly, + parseStringToLocalDate = { str, pattern -> + localDateTimeFormatter.parseLocalDateOrNull(str, pattern) + }, + onDateInputEntry = { + val (display, date) = it + if (date != null) { + val dateTime = + LocalDateTime( + date, + LocalTime(0, 0), + ) + coroutineScope.launch { + setQuestionnaireItemViewItemAnswer(questionnaireViewItem, dateTime) + } + questionnaireItemViewItemAnswerLocalDateTime = dateTime + } else { + coroutineScope.launch { questionnaireViewItem.setDraftAnswer(display) } + } + }, + ) + + Spacer(Modifier.width(QuestionnaireTheme.dimensions.datePickerAndTimePickerGap)) + + TimeFieldItem( + modifier = Modifier.weight(0.6f), + initialStartTime = initialTimeSelection, + timeSelectedDisplay = questionnaireViewItemLocalTimeAnswerDisplay, + enabled = timeInputEnabled, + hint = stringResource(Res.string.time), + supportingHelperText = "", + isError = false, + ) { + coroutineScope.launch { + val dateTime = + LocalDateTime( + questionnaireItemViewItemDate!!, + it, + ) + setQuestionnaireItemViewItemAnswer(questionnaireViewItem, dateTime) + } + } + } + } + } + + /** Set the answer in the [QuestionnaireResponse]. */ + private suspend fun setQuestionnaireItemViewItemAnswer( + questionnaireViewItem: QuestionnaireViewItem, + localDateTime: LocalDateTime, + ) = + questionnaireViewItem.setAnswer( + QuestionnaireResponse.Item.Answer( + value = + QuestionnaireResponse.Item.Answer.Value.DateTime( + value = + DateTime( + value = + FhirDateTime.DateTime( + localDateTime, + TimeZone.currentSystemDefault().offsetAt(Clock.System.now()), + ), + ), + ), + ), + ) +} diff --git a/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/views/factories/DateViewFactory.kt b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/views/factories/DateViewFactory.kt new file mode 100644 index 000000000..419a5e650 --- /dev/null +++ b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/views/factories/DateViewFactory.kt @@ -0,0 +1,271 @@ +/* + * Copyright 2022-2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.ohs.fhir.datacapture.views.factories + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.SelectableDates +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import com.google.fhir.model.r4.FhirDate +import com.google.fhir.model.r4.QuestionnaireResponse +import dev.ohs.fhir.datacapture.extensions.FhirR4DateType +import dev.ohs.fhir.datacapture.extensions.canonicalizeDatePattern +import dev.ohs.fhir.datacapture.extensions.entryFormat +import dev.ohs.fhir.datacapture.extensions.getDateSeparator +import dev.ohs.fhir.datacapture.extensions.itemMedia +import dev.ohs.fhir.datacapture.extensions.toLocalDate +import dev.ohs.fhir.datacapture.generated.resources.Res +import dev.ohs.fhir.datacapture.generated.resources.date_format_validation_error_msg +import dev.ohs.fhir.datacapture.generated.resources.required_text_and_new_line +import dev.ohs.fhir.datacapture.getLocalDateTimeFormatter +import dev.ohs.fhir.datacapture.isValidDateEntryFormat +import dev.ohs.fhir.datacapture.parseLocalDateOrNull +import dev.ohs.fhir.datacapture.theme.QuestionnaireTheme +import dev.ohs.fhir.datacapture.validation.Invalid +import dev.ohs.fhir.datacapture.views.QuestionnaireViewItem +import dev.ohs.fhir.datacapture.views.components.DateFieldItem +import dev.ohs.fhir.datacapture.views.components.DateInput +import dev.ohs.fhir.datacapture.views.components.DateInputFormat +import dev.ohs.fhir.datacapture.views.components.Header +import dev.ohs.fhir.datacapture.views.components.MediaItem +import dev.ohs.fhir.datacapture.views.components.getRequiredOrOptionalText +import kotlin.time.Clock +import kotlin.time.ExperimentalTime +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.datetime.LocalDate +import kotlinx.datetime.TimeZone +import kotlinx.datetime.atStartOfDayIn +import org.jetbrains.compose.resources.stringResource + +@OptIn(ExperimentalTime::class) +internal object DateViewFactory : QuestionnaireItemViewFactory { + + @Composable + override fun Content(questionnaireViewItem: QuestionnaireViewItem) { + val coroutineScope = rememberCoroutineScope { Dispatchers.Main } + val localDateTimeFormatter = getLocalDateTimeFormatter() + val isReadOnly = + remember(questionnaireViewItem) { + questionnaireViewItem.questionnaireItem.readOnly?.value ?: false + } + val dateEntryFormat = + remember(questionnaireViewItem) { + val entryFormat = questionnaireViewItem.questionnaireItem.entryFormat + with(localDateTimeFormatter) { + if (isValidDateEntryFormat(entryFormat?.let { canonicalizeDatePattern(it) })) { + entryFormat!! + } else { + localDateShortFormatPattern + } + } + } + val datePatternSeparator = + remember(dateEntryFormat) { getDateSeparator(dateEntryFormat) ?: '/' } + val canonicalizedDatePattern = + remember(dateEntryFormat) { canonicalizeDatePattern(dateEntryFormat) } + val uiDatePatternText = + remember(canonicalizedDatePattern) { canonicalizedDatePattern.lowercase() } + val dateInputFormat = + remember(canonicalizedDatePattern, datePatternSeparator) { + DateInputFormat( + canonicalizedDatePattern, + datePatternSeparator, + ) + } + var questionnaireItemAnswerLocalDate by + remember(questionnaireViewItem) { + mutableStateOf( + (questionnaireViewItem.answers.singleOrNull()?.value?.asDate()?.value?.value + as? FhirDate.Date) + ?.date, + ) + } + + val questionnaireItemAnswerDateInMillis = + remember(questionnaireItemAnswerLocalDate) { + questionnaireItemAnswerLocalDate?.atStartOfDayIn(TimeZone.UTC)?.toEpochMilliseconds() + } + val initialSelectedDateInMillis = + remember(questionnaireItemAnswerDateInMillis) { + questionnaireItemAnswerDateInMillis ?: Clock.System.now().toEpochMilliseconds() + } + val draftAnswer = + remember(questionnaireViewItem.draftAnswer) { questionnaireViewItem.draftAnswer as? String } + val dateInput = + remember(dateInputFormat, questionnaireItemAnswerLocalDate, draftAnswer) { + questionnaireItemAnswerLocalDate + ?.let { localDateTimeFormatter.format(it, dateInputFormat.pattern) } + ?.let { DateInput(it, questionnaireItemAnswerLocalDate) } + ?: DateInput(display = draftAnswer ?: "", null) + } + + val selectableDatesResult = + remember(questionnaireViewItem) { getSelectableDates(questionnaireViewItem) } + + val selectableDates = remember(selectableDatesResult) { selectableDatesResult.getOrNull() } + + val prohibitInput = remember(selectableDatesResult) { selectableDatesResult.isFailure } + + val invalidDraftDateErrorString = + stringResource( + Res.string.date_format_validation_error_msg, + canonicalizedDatePattern.lowercase(), + canonicalizedDatePattern.replace("dd", "31").replace("MM", "01").replace("yyyy", "2023"), + ) + val requiredTextNewLineStringResource = stringResource(Res.string.required_text_and_new_line) + val itemValidationMessage = + remember(draftAnswer, questionnaireViewItem.validationResult) { + val validationMessage = + when { + !draftAnswer.isNullOrBlank() -> { + // If the draft answer is set, this means the user has yet to type a parseable answer, + // so we display an error. + invalidDraftDateErrorString + } + questionnaireViewItem.validationResult is Invalid -> + questionnaireViewItem.validationResult.singleStringValidationMessage + else -> null + } + + validationMessage?.let { + if ( + questionnaireViewItem.questionnaireItem.required?.value == true && + questionnaireViewItem.questionViewTextConfiguration.showRequiredText + ) { + requiredTextNewLineStringResource + validationMessage + } else { + validationMessage + } + } + } + + val validationMessage = + remember(itemValidationMessage, selectableDatesResult) { + if (selectableDatesResult.isFailure) { + selectableDatesResult.exceptionOrNull()?.message + } else { + itemValidationMessage + } + } + + Column( + modifier = + Modifier.padding( + horizontal = QuestionnaireTheme.dimensions.itemMarginHorizontal, + vertical = QuestionnaireTheme.dimensions.itemMarginVertical, + ), + ) { + Header(questionnaireViewItem) + questionnaireViewItem.questionnaireItem.itemMedia?.let { MediaItem(it) } + + DateFieldItem( + modifier = Modifier.fillMaxWidth(), + initialSelectedDateMillis = initialSelectedDateInMillis, + selectableDates = selectableDates, + dateInputFormat = dateInputFormat, + dateInput = dateInput, + labelText = uiDatePatternText, + helperText = validationMessage.takeIf { !it.isNullOrBlank() } + ?: getRequiredOrOptionalText(questionnaireViewItem), + isError = !validationMessage.isNullOrBlank(), + enabled = !(isReadOnly || prohibitInput), + parseStringToLocalDate = { str, pattern -> + localDateTimeFormatter.parseLocalDateOrNull(str, pattern) + }, + onDateInputEntry = { + val (display, date) = it + coroutineScope.launch { + if (date != null) { + setQuestionnaireItemViewItemAnswer(questionnaireViewItem, date) + questionnaireItemAnswerLocalDate = date + } else { + // Each time the user types in a character, parse the string and if it can be parsed + // into a date, + // set the answer in the [QuestionnaireResponse], otherwise, set the draft answer. + val localDate = + localDateTimeFormatter.parseLocalDateOrNull(display, dateInputFormat.pattern) + if (localDate != null) { + setQuestionnaireItemViewItemAnswer(questionnaireViewItem, localDate) + } else { + questionnaireViewItem.setDraftAnswer(display) + } + } + } + }, + ) + } + } + + private fun getSelectableDates( + questionnaireViewItem: QuestionnaireViewItem, + ): Result { + val min = + (questionnaireViewItem.minAnswerValue?.asDate()?.value?.value as? FhirDate.Date) + ?.date + ?.atStartOfDayIn(TimeZone.UTC) + ?.toEpochMilliseconds() + val max = + (questionnaireViewItem.maxAnswerValue?.asDate()?.value?.value as? FhirDate.Date) + ?.date + ?.atStartOfDayIn(TimeZone.UTC) + ?.toEpochMilliseconds() + + return if (min != null && max != null && min > max) { + Result.failure(IllegalArgumentException("minValue cannot be greater than maxValue")) + } else { + Result.success(selectableDates(min, max)) + } + } + + /** Set the answer in the [QuestionnaireResponse]. */ + private suspend fun setQuestionnaireItemViewItemAnswer( + questionnaireViewItem: QuestionnaireViewItem, + localDate: LocalDate, + ) = + questionnaireViewItem.setAnswer( + QuestionnaireResponse.Item.Answer( + value = + QuestionnaireResponse.Item.Answer.Value.Date( + value = FhirR4DateType(value = FhirDate.Date(date = localDate)), + ), + ), + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +internal fun selectableDates(minDateMillis: Long?, maxDateMillis: Long?) = + object : SelectableDates { + override fun isSelectableDate(utcTimeMillis: Long) = + (minDateMillis == null || utcTimeMillis >= minDateMillis) && + (maxDateMillis == null || utcTimeMillis <= maxDateMillis) + + private fun getYear(timeInMillis: Long) = timeInMillis.toLocalDate().year + + override fun isSelectableYear(year: Int): Boolean { + return (minDateMillis == null || year >= getYear(minDateMillis)) && + (maxDateMillis == null || year <= getYear(maxDateMillis)) + } + } diff --git a/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/views/factories/DialogSelectViewFactory.kt b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/views/factories/DialogSelectViewFactory.kt new file mode 100644 index 000000000..adc1ffe9e --- /dev/null +++ b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/views/factories/DialogSelectViewFactory.kt @@ -0,0 +1,223 @@ +/* + * Copyright 2023-2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.ohs.fhir.datacapture.views.factories + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuAnchorType +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.error +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.AnnotatedString +import com.google.fhir.model.r4.Questionnaire +import com.google.fhir.model.r4.QuestionnaireResponse +import dev.ohs.fhir.datacapture.extensions.FhirR4String +import dev.ohs.fhir.datacapture.extensions.ItemControlTypes +import dev.ohs.fhir.datacapture.extensions.itemControl +import dev.ohs.fhir.datacapture.extensions.itemMedia +import dev.ohs.fhir.datacapture.extensions.localizedFlyoverAnnotatedString +import dev.ohs.fhir.datacapture.extensions.toQuestionnaireResponseItemAnswer +import dev.ohs.fhir.datacapture.generated.resources.Res +import dev.ohs.fhir.datacapture.generated.resources.required_text_and_new_line +import dev.ohs.fhir.datacapture.theme.QuestionnaireTheme +import dev.ohs.fhir.datacapture.validation.Invalid +import dev.ohs.fhir.datacapture.validation.NotValidated +import dev.ohs.fhir.datacapture.validation.Valid +import dev.ohs.fhir.datacapture.views.QuestionnaireViewItem +import dev.ohs.fhir.datacapture.views.components.Header +import dev.ohs.fhir.datacapture.views.components.MediaItem +import dev.ohs.fhir.datacapture.views.components.OptionDialogSelect +import dev.ohs.fhir.datacapture.views.components.OptionSelectOption +import dev.ohs.fhir.datacapture.views.components.SelectedOptions +import dev.ohs.fhir.datacapture.views.components.getRequiredOrOptionalText +import dev.ohs.fhir.datacapture.views.isAnswerOptionSelected +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.stringResource + +internal const val MULTI_SELECT_TEXT_FIELD_TAG = "multi_select_summary_holder" + +internal object DialogSelectViewFactory : QuestionnaireItemViewFactory { + @OptIn(ExperimentalMaterial3Api::class) + @Composable + override fun Content(questionnaireViewItem: QuestionnaireViewItem) { + val coroutineScope = rememberCoroutineScope { Dispatchers.Main } + val readOnly = + remember(questionnaireViewItem) { + questionnaireViewItem.questionnaireItem.readOnly?.value ?: false + } + val hintLabelText = + remember(questionnaireViewItem) { + questionnaireViewItem.enabledDisplayItems.localizedFlyoverAnnotatedString + } + val requiredTextAndNewLineString = stringResource(Res.string.required_text_and_new_line) + + val validationResultMessage = + remember(questionnaireViewItem.validationResult) { + when (val validationResult = questionnaireViewItem.validationResult) { + is NotValidated, + Valid, -> null + is Invalid -> { + if ( + questionnaireViewItem.questionnaireItem.required?.value == true && + questionnaireViewItem.questionViewTextConfiguration.showRequiredText + ) { + "$requiredTextAndNewLineString${validationResult.singleStringValidationMessage}" + } else { + validationResult.singleStringValidationMessage + } + } + } + } + + val hasValidationError = + remember(validationResultMessage) { !validationResultMessage.isNullOrBlank() } + val requiredOrOptionalTextString = getRequiredOrOptionalText(questionnaireViewItem) + val supportingHelperText = + remember(questionnaireViewItem) { + if (hasValidationError) { + validationResultMessage + } else { + requiredOrOptionalTextString + } + } + var selectedOptions by + remember(questionnaireViewItem) { + mutableStateOf(questionnaireViewItem.extractInitialOptions()) + } + val dialogTitle = + remember(questionnaireViewItem) { + questionnaireViewItem.questionText ?: hintLabelText ?: AnnotatedString("") + } + val isMultiSelect = + remember(questionnaireViewItem) { + questionnaireViewItem.questionnaireItem.repeats?.value ?: false + } + val allowOtherOptions = + remember(questionnaireViewItem) { + questionnaireViewItem.questionnaireItem.itemControl == ItemControlTypes.OPEN_CHOICE + } + + Column( + modifier = + Modifier.fillMaxWidth() + .padding( + horizontal = QuestionnaireTheme.dimensions.itemMarginHorizontal, + vertical = QuestionnaireTheme.dimensions.itemMarginVertical, + ), + ) { + Header( + questionnaireViewItem, + ) + questionnaireViewItem.questionnaireItem.itemMedia?.let { MediaItem(it) } + + var expanded by remember { mutableStateOf(false) } + ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = it }) { + OutlinedTextField( + value = selectedOptions.selectedSummary, + onValueChange = {}, + readOnly = true, + modifier = + Modifier.fillMaxWidth() + .testTag(MULTI_SELECT_TEXT_FIELD_TAG) + .semantics { if (hasValidationError) error(supportingHelperText ?: "") } + .menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable, !readOnly), + label = { hintLabelText?.let { Text(it) } }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, + supportingText = { + if (!supportingHelperText.isNullOrBlank()) { + Text(supportingHelperText) + } + }, + isError = hasValidationError, + enabled = !readOnly, + ) + + if (expanded) { + OptionDialogSelect( + title = dialogTitle, + multiSelect = isMultiSelect, + otherOptionsAllowed = allowOtherOptions, + selectedOptions = selectedOptions, + onDismiss = { expanded = false }, + onConfirm = { newOptions -> + selectedOptions = newOptions + coroutineScope.launch { + val optionAnswers = + newOptions.options + .filter { it.selected } + .map { it.item.toQuestionnaireResponseItemAnswer() } + val otherOptionAnswers = + newOptions.otherOptions.map { + QuestionnaireResponse.Item.Answer( + value = + QuestionnaireResponse.Item.Answer.Value.String( + value = FhirR4String(value = it), + ), + ) + } + + val answersArray = (optionAnswers + otherOptionAnswers).toTypedArray() + + questionnaireViewItem.setAnswer(*answersArray) + } + }, + ) + } + } + } + } +} + +private fun QuestionnaireViewItem.extractInitialOptions(): SelectedOptions { + val options = + enabledAnswerOptions.map { answerOption -> + OptionSelectOption( + item = answerOption, + selected = isAnswerOptionSelected(answerOption), + ) + } + + val optionsStringValues = + options.mapNotNull { + (it.item.value as? Questionnaire.Item.AnswerOption.Value.String)?.value?.value + } + val otherOptions = + answers + // All of the Other options will be encoded as String value types + .mapNotNull { it.value?.asString()?.value?.value } + // We should also make sure that these values aren't present in the predefined options + .filter { value -> value !in optionsStringValues } + + return SelectedOptions( + options = options, + otherOptions = otherOptions, + ) +} diff --git a/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/views/factories/DisplayViewFactory.kt b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/views/factories/DisplayViewFactory.kt new file mode 100644 index 000000000..74dee9d6b --- /dev/null +++ b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/views/factories/DisplayViewFactory.kt @@ -0,0 +1,46 @@ +/* + * Copyright 2022-2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.ohs.fhir.datacapture.views.factories + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import dev.ohs.fhir.datacapture.extensions.itemMedia +import dev.ohs.fhir.datacapture.theme.QuestionnaireTheme +import dev.ohs.fhir.datacapture.views.QuestionnaireViewItem +import dev.ohs.fhir.datacapture.views.components.Header +import dev.ohs.fhir.datacapture.views.components.MediaItem + +internal object DisplayViewFactory : QuestionnaireItemViewFactory { + + @Composable + override fun Content(questionnaireViewItem: QuestionnaireViewItem) { + Column( + modifier = + Modifier.fillMaxWidth() + .padding( + horizontal = QuestionnaireTheme.dimensions.itemMarginHorizontal, + vertical = QuestionnaireTheme.dimensions.itemMarginVertical, + ), + ) { + Header(questionnaireViewItem) + questionnaireViewItem.questionnaireItem.itemMedia?.let { MediaItem(it) } + } + } +} diff --git a/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/views/factories/DropDownViewFactory.kt b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/views/factories/DropDownViewFactory.kt new file mode 100644 index 000000000..a8aa4eeeb --- /dev/null +++ b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/views/factories/DropDownViewFactory.kt @@ -0,0 +1,142 @@ +/* + * Copyright 2022-2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.ohs.fhir.datacapture.views.factories + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import dev.ohs.fhir.datacapture.extensions.FhirR4String +import dev.ohs.fhir.datacapture.extensions.elementValue +import dev.ohs.fhir.datacapture.extensions.itemMedia +import dev.ohs.fhir.datacapture.extensions.localizedFlyoverAnnotatedString +import dev.ohs.fhir.datacapture.extensions.toQuestionnaireResponseItemAnswer +import dev.ohs.fhir.datacapture.generated.resources.Res +import dev.ohs.fhir.datacapture.generated.resources.hyphen +import dev.ohs.fhir.datacapture.generated.resources.required_text_and_new_line +import dev.ohs.fhir.datacapture.theme.QuestionnaireTheme +import dev.ohs.fhir.datacapture.validation.Invalid +import dev.ohs.fhir.datacapture.views.QuestionnaireViewItem +import dev.ohs.fhir.datacapture.views.components.AutoCompleteDropDownItem +import dev.ohs.fhir.datacapture.views.components.DropDownAnswerOption +import dev.ohs.fhir.datacapture.views.components.Header +import dev.ohs.fhir.datacapture.views.components.MediaItem +import dev.ohs.fhir.datacapture.views.components.getRequiredOrOptionalText +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.stringResource + +internal object DropDownViewFactory : QuestionnaireItemViewFactory { + @Composable + override fun Content(questionnaireViewItem: QuestionnaireViewItem) { + val coroutineScope = rememberCoroutineScope { Dispatchers.Main } + val hyphen = stringResource(Res.string.hyphen) + val isQuestionnaireItemReadOnly = + remember(questionnaireViewItem.questionnaireItem) { + questionnaireViewItem.questionnaireItem.readOnly?.value ?: false + } + val flyOverText = + remember(questionnaireViewItem.enabledDisplayItems) { + questionnaireViewItem.enabledDisplayItems.localizedFlyoverAnnotatedString + } + val requiredOptionalText = getRequiredOrOptionalText(questionnaireViewItem) + val questionnaireItemAnswerDropDownOptions = + questionnaireViewItem.enabledAnswerOptions.map { DropDownAnswerOption.of(it) } + + val requiredTextAndNewLineString = stringResource(Res.string.required_text_and_new_line) + + val validationErrorMessage = + remember(questionnaireViewItem.validationResult) { + when (val validationResult = questionnaireViewItem.validationResult) { + is Invalid -> { + if ( + questionnaireViewItem.questionnaireItem.required?.value == true && + questionnaireViewItem.questionViewTextConfiguration.showRequiredText + ) { + "$requiredTextAndNewLineString${validationResult.singleStringValidationMessage}" + } else { + validationResult.singleStringValidationMessage + } + } + else -> "" + } + } + val showClearInput = + remember(questionnaireViewItem.answers.toString()) { + questionnaireViewItem.answers.isNotEmpty() + } + + val dropDownOptions = + remember(questionnaireItemAnswerDropDownOptions) { + listOf( + DropDownAnswerOption(elementValue = FhirR4String(value = hyphen), hyphen, null), + *questionnaireItemAnswerDropDownOptions.toTypedArray(), + ) + } + val selectedAnswerIdentifier = + remember(questionnaireViewItem.answers.toString()) { + questionnaireViewItem.answers.singleOrNull()?.elementValue + } + val selectedOption = + remember(dropDownOptions, selectedAnswerIdentifier) { + questionnaireItemAnswerDropDownOptions.firstOrNull { + it.elementValue == selectedAnswerIdentifier + } + } + + Column( + modifier = + Modifier.fillMaxWidth() + .padding( + horizontal = QuestionnaireTheme.dimensions.itemMarginHorizontal, + vertical = QuestionnaireTheme.dimensions.itemMarginVertical, + ), + ) { + Header(questionnaireViewItem) + questionnaireViewItem.questionnaireItem.itemMedia?.let { MediaItem(it) } + + AutoCompleteDropDownItem( + modifier = Modifier.fillMaxWidth(), + enabled = !isQuestionnaireItemReadOnly, + labelText = flyOverText, + supportingText = validationErrorMessage.ifBlank { requiredOptionalText }, + isError = validationErrorMessage.isNotBlank(), + showClearIcon = showClearInput, + selectedOption = selectedOption, + options = dropDownOptions, + ) { answerOption -> + val selectedAnswer = + questionnaireViewItem.enabledAnswerOptions.firstOrNull { + it.elementValue == answerOption?.elementValue + } + + coroutineScope.launch { + if (selectedAnswer != null) { + questionnaireViewItem.setAnswer( + selectedAnswer.toQuestionnaireResponseItemAnswer(), + ) + } else { + questionnaireViewItem.clearAnswer() + } + } + } + } + } +} diff --git a/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/views/factories/EditTextDecimalViewFactory.kt b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/views/factories/EditTextDecimalViewFactory.kt new file mode 100644 index 000000000..6eaef36b7 --- /dev/null +++ b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/views/factories/EditTextDecimalViewFactory.kt @@ -0,0 +1,54 @@ +/* + * Copyright 2022-2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.ohs.fhir.datacapture.views.factories + +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import com.google.fhir.model.r4.Decimal +import com.google.fhir.model.r4.QuestionnaireResponse +import com.ionspin.kotlin.bignum.decimal.toBigDecimal +import dev.ohs.fhir.datacapture.generated.resources.Res +import dev.ohs.fhir.datacapture.generated.resources.decimal_format_validation_error_msg + +internal val EditTextDecimalViewFactory = + EditTextViewFactoryDelegate( + KeyboardOptions(keyboardType = KeyboardType.Decimal, imeAction = ImeAction.Done), + uiInputText = { + val questionnaireItemViewItemDecimalAnswer = it.answers.singleOrNull()?.value?.asDecimal() + val draftAnswer = it.draftAnswer?.toString() + + when { + questionnaireItemViewItemDecimalAnswer == null && draftAnswer.isNullOrEmpty() -> "" + questionnaireItemViewItemDecimalAnswer != null -> + questionnaireItemViewItemDecimalAnswer.value.value?.toStringExpanded() + else -> draftAnswer + } + }, + handleInput = { inputText, questionnaireViewItem -> + inputText.toDoubleOrNull()?.let { + questionnaireViewItem.setAnswer( + QuestionnaireResponse.Item.Answer( + value = + QuestionnaireResponse.Item.Answer.Value.Decimal(Decimal(value = it.toBigDecimal())), + ), + ) + } + ?: questionnaireViewItem.setDraftAnswer(inputText) + }, + validationMessageStringRes = Res.string.decimal_format_validation_error_msg, + ) diff --git a/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/views/factories/EditTextIntegerViewFactory.kt b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/views/factories/EditTextIntegerViewFactory.kt new file mode 100644 index 000000000..16a94ebf4 --- /dev/null +++ b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/views/factories/EditTextIntegerViewFactory.kt @@ -0,0 +1,62 @@ +/* + * Copyright 2022-2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.ohs.fhir.datacapture.views.factories + +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import com.google.fhir.model.r4.Integer as FhirInteger +import com.google.fhir.model.r4.QuestionnaireResponse +import dev.ohs.fhir.datacapture.NumberFormatter +import dev.ohs.fhir.datacapture.generated.resources.Res +import dev.ohs.fhir.datacapture.generated.resources.integer_format_validation_error_msg + +internal val EditTextIntegerViewFactory = + EditTextViewFactoryDelegate( + KeyboardOptions(keyboardType = KeyboardType.Number, imeAction = ImeAction.Done), + uiInputText = { + val answer = it.answers.singleOrNull()?.value?.asInteger()?.value?.value?.toString() + val draftAnswer = it.draftAnswer?.toString() + when { + answer.isNullOrEmpty() && draftAnswer.isNullOrEmpty() -> "" + answer?.toIntOrNull() != null -> answer + else -> draftAnswer + } + }, + handleInput = { inputText, questionnaireViewItem -> + if (inputText.isEmpty()) { + questionnaireViewItem.clearAnswer() + } else if (inputText.toIntOrNull() != null) { + questionnaireViewItem.setAnswer( + QuestionnaireResponse.Item.Answer( + value = + QuestionnaireResponse.Item.Answer.Value.Integer( + FhirInteger(value = inputText.toInt()), + ), + ), + ) + } else { + questionnaireViewItem.setDraftAnswer(inputText) + } + }, + validationMessageStringRes = Res.string.integer_format_validation_error_msg, + validationMessageStringResArgs = + arrayOf( + NumberFormatter.formatInteger(Int.MIN_VALUE), + NumberFormatter.formatInteger(Int.MAX_VALUE), + ), + ) diff --git a/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/views/factories/EditTextMultiLineViewFactory.kt b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/views/factories/EditTextMultiLineViewFactory.kt new file mode 100644 index 000000000..461a248ac --- /dev/null +++ b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/views/factories/EditTextMultiLineViewFactory.kt @@ -0,0 +1,19 @@ +/* + * Copyright 2022-2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.ohs.fhir.datacapture.views.factories + +internal val EditTextMultiLineViewFactory = createEditTextStringViewHolderDelegate(multiLine = true) diff --git a/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/views/factories/EditTextPhoneNumberViewFactory.kt b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/views/factories/EditTextPhoneNumberViewFactory.kt new file mode 100644 index 000000000..ceec6a23c --- /dev/null +++ b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/views/factories/EditTextPhoneNumberViewFactory.kt @@ -0,0 +1,51 @@ +/* + * Copyright 2022-2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.ohs.fhir.datacapture.views.factories + +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import com.google.fhir.model.r4.QuestionnaireResponse +import dev.ohs.fhir.datacapture.extensions.FhirR4String +import dev.ohs.fhir.datacapture.generated.resources.Res +import dev.ohs.fhir.datacapture.generated.resources.decimal_format_validation_error_msg + +internal val EditTextPhoneNumberViewFactory = + EditTextViewFactoryDelegate( + KeyboardOptions(keyboardType = KeyboardType.Phone, imeAction = ImeAction.Done), + uiInputText = { it.answers.singleOrNull()?.value?.asString()?.value?.value ?: "" }, + handleInput = { inputText, questionnaireViewItem -> + val input = + inputText.let { + if (it.isEmpty()) { + null + } else { + QuestionnaireResponse.Item.Answer( + value = + QuestionnaireResponse.Item.Answer.Value.String(value = FhirR4String(value = it)), + ) + } + } + + if (input != null) { + questionnaireViewItem.setAnswer(input) + } else { + questionnaireViewItem.clearAnswer() + } + }, + validationMessageStringRes = Res.string.decimal_format_validation_error_msg, + ) diff --git a/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/views/factories/EditTextSingleLineViewFactory.kt b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/views/factories/EditTextSingleLineViewFactory.kt new file mode 100644 index 000000000..9e27da060 --- /dev/null +++ b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/views/factories/EditTextSingleLineViewFactory.kt @@ -0,0 +1,19 @@ +/* + * Copyright 2022-2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.ohs.fhir.datacapture.views.factories + +internal val EditTextSingleLineViewFactory = createEditTextStringViewHolderDelegate() diff --git a/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/views/factories/EditTextStringDelegateFactory.kt b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/views/factories/EditTextStringDelegateFactory.kt new file mode 100644 index 000000000..0fff55aa1 --- /dev/null +++ b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/views/factories/EditTextStringDelegateFactory.kt @@ -0,0 +1,55 @@ +/* + * Copyright 2022-2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.ohs.fhir.datacapture.views.factories + +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.KeyboardType +import com.google.fhir.model.r4.QuestionnaireResponse +import com.google.fhir.model.r4.String as FhirString +import dev.ohs.fhir.datacapture.generated.resources.Res +import dev.ohs.fhir.datacapture.generated.resources.required_text_and_new_line + +/** + * Implementation of [EditTextViewFactoryDelegate] used in [EditTextSingleLineViewFactory] and + * [EditTextMultiLineViewFactory]. + * + * Any `ViewHolder` containing a `EditText` view that collects text data should use this class. + */ +internal fun createEditTextStringViewHolderDelegate(multiLine: Boolean = false) = + EditTextViewFactoryDelegate( + KeyboardOptions( + keyboardType = KeyboardType.Text, + capitalization = KeyboardCapitalization.Sentences, + imeAction = ImeAction.Done, + ), + uiInputText = { it.answers.singleOrNull()?.value?.asString()?.value?.value ?: "" }, + handleInput = { inputText, questionnaireViewItem -> + if (inputText.isEmpty()) { + questionnaireViewItem.clearAnswer() + } else { + questionnaireViewItem.setAnswer( + QuestionnaireResponse.Item.Answer( + value = QuestionnaireResponse.Item.Answer.Value.String(FhirString(value = inputText)), + ), + ) + } + }, + isMultiLine = multiLine, + validationMessageStringRes = Res.string.required_text_and_new_line, + ) diff --git a/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/views/factories/EditTextViewFactoryDelegate.kt b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/views/factories/EditTextViewFactoryDelegate.kt new file mode 100644 index 000000000..f4814ed5d --- /dev/null +++ b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/views/factories/EditTextViewFactoryDelegate.kt @@ -0,0 +1,127 @@ +/* + * Copyright 2022-2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.ohs.fhir.datacapture.views.factories + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import dev.ohs.fhir.datacapture.extensions.itemMedia +import dev.ohs.fhir.datacapture.extensions.localizedFlyoverAnnotatedString +import dev.ohs.fhir.datacapture.extensions.unit +import dev.ohs.fhir.datacapture.generated.resources.Res +import dev.ohs.fhir.datacapture.generated.resources.required_text_and_new_line +import dev.ohs.fhir.datacapture.validation.Invalid +import dev.ohs.fhir.datacapture.views.QuestionnaireViewItem +import dev.ohs.fhir.datacapture.views.components.EditTextFieldItem +import dev.ohs.fhir.datacapture.views.components.EditTextFieldState +import dev.ohs.fhir.datacapture.views.components.Header +import dev.ohs.fhir.datacapture.views.components.MediaItem +import dev.ohs.fhir.datacapture.views.components.UnitText +import dev.ohs.fhir.datacapture.views.components.getRequiredOrOptionalText +import org.jetbrains.compose.resources.StringResource +import org.jetbrains.compose.resources.stringResource + +class EditTextViewFactoryDelegate( + private val keyboardOptions: KeyboardOptions, + private val uiInputText: (QuestionnaireViewItem) -> String?, + private val handleInput: suspend (String, QuestionnaireViewItem) -> Unit, + private val isMultiLine: Boolean = false, + private val validationMessageStringRes: StringResource, + private val validationMessageStringResArgs: Array = emptyArray(), +) : QuestionnaireItemViewFactory { + + @Composable + override fun Content(questionnaireViewItem: QuestionnaireViewItem) { + val text = uiInputText(questionnaireViewItem) ?: "" + val coroutineScope = rememberCoroutineScope() + val requiredOptionalText = getRequiredOrOptionalText(questionnaireViewItem) + val validationMessage = getValidationErrorMessage(questionnaireViewItem) + val composeViewQuestionnaireState = + remember(questionnaireViewItem) { + EditTextFieldState( + initialInputText = text, + handleTextInputChange = { handleInput(it, questionnaireViewItem) }, + coroutineScope = coroutineScope, + hint = questionnaireViewItem.enabledDisplayItems.localizedFlyoverAnnotatedString, + helperText = validationMessage.takeIf { !it.isNullOrBlank() } ?: requiredOptionalText, + isError = !validationMessage.isNullOrBlank(), + isReadOnly = questionnaireViewItem.questionnaireItem.readOnly?.value == true, + keyboardOptions = keyboardOptions, + isMultiLine = isMultiLine, + ) + } + val unit = + remember(questionnaireViewItem) { questionnaireViewItem.questionnaireItem.unit?.code } + + val questionnaireItem = questionnaireViewItem.questionnaireItem + + Column( + modifier = + Modifier.padding( + horizontal = 16.dp, + vertical = 16.dp, + ), + ) { + Header(questionnaireViewItem) + + questionnaireItem.itemMedia?.let { MediaItem(it) } + + Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + EditTextFieldItem(modifier = Modifier.weight(1f), composeViewQuestionnaireState) + + if (!unit?.value.isNullOrBlank()) { + UnitText(unitString = unit.value!!) + } + } + } + } + + /** + * Returns the validation error message. If Questionnaire.Item.required is true, the error message + * starts with `Required` text and the rest of the error message is placed on the next line. + */ + @Composable + private fun getValidationErrorMessage(questionnaireViewItem: QuestionnaireViewItem): String? { + return if (questionnaireViewItem.draftAnswer != null) { + stringResource(validationMessageStringRes, *validationMessageStringResArgs) + } else { + when (val validationResult = questionnaireViewItem.validationResult) { + is Invalid -> { + validationResult.singleStringValidationMessage.let { + if ( + questionnaireViewItem.questionnaireItem.required?.value == true && + questionnaireViewItem.questionViewTextConfiguration.showRequiredText + ) { + stringResource(Res.string.required_text_and_new_line) + it + } else { + it + } + } + } + else -> null + } + } + } +} diff --git a/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/views/factories/GroupViewFactory.kt b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/views/factories/GroupViewFactory.kt new file mode 100644 index 000000000..46d4363a0 --- /dev/null +++ b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/views/factories/GroupViewFactory.kt @@ -0,0 +1,53 @@ +/* + * Copyright 2022-2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.ohs.fhir.datacapture.views.factories + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import dev.ohs.fhir.datacapture.extensions.itemMedia +import dev.ohs.fhir.datacapture.validation.Invalid +import dev.ohs.fhir.datacapture.views.QuestionnaireViewItem +import dev.ohs.fhir.datacapture.views.components.ErrorText +import dev.ohs.fhir.datacapture.views.components.Header +import dev.ohs.fhir.datacapture.views.components.MediaItem + +internal object GroupViewFactory : QuestionnaireItemViewFactory { + @Composable + override fun Content(questionnaireViewItem: QuestionnaireViewItem) { + val validationMessage = + remember(questionnaireViewItem.validationResult) { + (questionnaireViewItem.validationResult as? Invalid)?.singleStringValidationMessage ?: "" + } + + Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp)) { + Header(questionnaireViewItem) + if (validationMessage.isNotBlank()) { + Spacer(modifier = Modifier.height(8.dp)) + ErrorText(validationMessage) + } + Spacer(modifier = Modifier) + questionnaireViewItem.questionnaireItem.itemMedia?.let { MediaItem(it) } + } + } +} diff --git a/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/views/factories/QuantityViewFactory.kt b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/views/factories/QuantityViewFactory.kt new file mode 100644 index 000000000..1797928dc --- /dev/null +++ b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/views/factories/QuantityViewFactory.kt @@ -0,0 +1,234 @@ +/* + * Copyright 2022-2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.ohs.fhir.datacapture.views.factories + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import com.google.fhir.model.r4.Coding +import com.google.fhir.model.r4.Decimal +import com.google.fhir.model.r4.Quantity +import com.google.fhir.model.r4.QuestionnaireResponse +import com.ionspin.kotlin.bignum.decimal.BigDecimal +import dev.ohs.fhir.datacapture.extensions.hasCode +import dev.ohs.fhir.datacapture.extensions.hasDisplay +import dev.ohs.fhir.datacapture.extensions.itemMedia +import dev.ohs.fhir.datacapture.extensions.localizedFlyoverAnnotatedString +import dev.ohs.fhir.datacapture.extensions.toBigDecimalOrNull +import dev.ohs.fhir.datacapture.extensions.toCoding +import dev.ohs.fhir.datacapture.extensions.unitOption +import dev.ohs.fhir.datacapture.theme.QuestionnaireTheme +import dev.ohs.fhir.datacapture.validation.Invalid +import dev.ohs.fhir.datacapture.views.QuestionnaireViewItem +import dev.ohs.fhir.datacapture.views.components.DropDownAnswerOption +import dev.ohs.fhir.datacapture.views.components.DropDownItem +import dev.ohs.fhir.datacapture.views.components.EditTextFieldItem +import dev.ohs.fhir.datacapture.views.components.EditTextFieldState +import dev.ohs.fhir.datacapture.views.components.Header +import dev.ohs.fhir.datacapture.views.components.MediaItem +import dev.ohs.fhir.datacapture.views.components.getRequiredOrOptionalText +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +internal object QuantityViewFactory : QuestionnaireItemViewFactory { + @Composable + override fun Content(questionnaireViewItem: QuestionnaireViewItem) { + val coroutineScope = rememberCoroutineScope { Dispatchers.Main } + val text = remember(questionnaireViewItem) { uiInputText(questionnaireViewItem) } + val isReadOnly = + remember(questionnaireViewItem) { + questionnaireViewItem.questionnaireItem.readOnly?.value ?: false + } + val requiredOptionalText = getRequiredOrOptionalText(questionnaireViewItem) + val unitOptions = + remember(questionnaireViewItem) { questionnaireViewItem.questionnaireItem.unitOption } + val dropDownOptions = + remember(unitOptions) { unitOptions.mapNotNull { it.toDropDownAnswerOption() } } + val selectedOption = + remember(questionnaireViewItem) { + unitTextCoding(questionnaireViewItem)?.toDropDownAnswerOption() + ?: dropDownOptions.singleOrNull() // Select if it has only one option + } + + var quantity by + remember(questionnaireViewItem) { + mutableStateOf(UiQuantity(text, selectedOption?.findCoding(unitOptions))) + } + + val validationUiMessage = + remember(questionnaireViewItem.validationResult) { + when (val validationResult = questionnaireViewItem.validationResult) { + is Invalid -> validationResult.singleStringValidationMessage + else -> null + } + } + + LaunchedEffect(quantity) { + coroutineScope.launch { handleInput(questionnaireViewItem, quantity) } + } + + val composeViewQuestionnaireState = + remember(questionnaireViewItem) { + EditTextFieldState( + initialInputText = text, + handleTextInputChange = { quantity = UiQuantity(it, quantity.unitDropDown) }, + coroutineScope = coroutineScope, + hint = questionnaireViewItem.enabledDisplayItems.localizedFlyoverAnnotatedString, + helperText = validationUiMessage.takeIf { !it.isNullOrBlank() } ?: requiredOptionalText, + isError = !validationUiMessage.isNullOrBlank(), + isReadOnly = isReadOnly, + keyboardOptions = + KeyboardOptions(keyboardType = KeyboardType.Decimal, imeAction = ImeAction.Done), + isMultiLine = false, + ) + } + + Column( + modifier = + Modifier.fillMaxWidth() + .padding( + horizontal = QuestionnaireTheme.dimensions.itemMarginHorizontal, + vertical = QuestionnaireTheme.dimensions.itemMarginVertical, + ), + ) { + Header(questionnaireViewItem) + questionnaireViewItem.questionnaireItem.itemMedia?.let { MediaItem(it) } + + Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.Top) { + EditTextFieldItem( + modifier = Modifier.weight(1f), + textFieldState = composeViewQuestionnaireState, + ) + Spacer(modifier = Modifier.width(QuestionnaireTheme.dimensions.itemMarginHorizontal)) + DropDownItem( + modifier = Modifier.weight(1f), + enabled = !isReadOnly, + selectedOption = selectedOption, + isError = !validationUiMessage.isNullOrBlank(), + options = dropDownOptions, + ) { answerOption -> + quantity = UiQuantity(quantity.value, answerOption?.findCoding(unitOptions)) + } + } + } + } + + private suspend fun handleInput( + questionnaireViewItem: QuestionnaireViewItem, + input: UiQuantity, + ) { + var decimal: BigDecimal? = null + var unit: Coding? = null + + // Read decimal value and unit from complete answer + questionnaireViewItem.answers.singleOrNull()?.let { + val quantity = it.value?.asQuantity()?.value + decimal = quantity?.value?.value + unit = quantity?.toCoding() + } + + // Read decimal value and unit from partial answer + questionnaireViewItem.draftAnswer?.let { + when (it) { + is BigDecimal -> decimal = it + is Coding -> unit = it + } + } + + // Update decimal value and unit + input.value?.let { decimal = it.toBigDecimalOrNull() } + input.unitDropDown?.let { unit = it } + + when { + decimal == null && unit == null -> { + questionnaireViewItem.clearAnswer() + } + decimal == null -> { + questionnaireViewItem.setDraftAnswer(unit) + } + unit == null -> { + questionnaireViewItem.setDraftAnswer(decimal) + } + else -> { + questionnaireViewItem.setAnswer( + QuestionnaireResponse.Item.Answer( + value = + QuestionnaireResponse.Item.Answer.Value.Quantity( + Quantity( + value = Decimal(value = decimal), + unit = unit.display, + code = unit.code, + system = unit.system, + ), + ), + ), + ) + } + } + } + + private fun uiInputText(questionnaireViewItem: QuestionnaireViewItem): String = + questionnaireViewItem.answers + .singleOrNull() + ?.value + ?.asQuantity() + ?.value + ?.value + ?.value + ?.toStringExpanded() + ?: questionnaireViewItem.draftAnswer?.let { if (it is BigDecimal) it.toString() else "" } + ?: "" + + private fun unitTextCoding(questionnaireViewItem: QuestionnaireViewItem) = + questionnaireViewItem.answers.singleOrNull()?.value?.asQuantity()?.value?.toCoding() + ?: questionnaireViewItem.draftAnswer?.let { it as? Coding } + ?: questionnaireViewItem.questionnaireItem.initial + .firstOrNull() + ?.value + ?.asQuantity() + ?.value + ?.toCoding() + + private fun Coding.toDropDownAnswerOption() = + takeIf { it.hasCode() || it.hasDisplay() } + ?.let { + DropDownAnswerOption( + elementValue = it, + displayString = it.display?.value ?: "", + ) + } + + private fun DropDownAnswerOption.findCoding(options: List) = + options.find { elementValue == it } +} + +private data class UiQuantity(val value: String?, val unitDropDown: Coding?) diff --git a/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/views/factories/QuestionnaireItemViewFactory.kt b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/views/factories/QuestionnaireItemViewFactory.kt new file mode 100644 index 000000000..5c42fe00a --- /dev/null +++ b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/views/factories/QuestionnaireItemViewFactory.kt @@ -0,0 +1,24 @@ +/* + * Copyright 2022-2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.ohs.fhir.datacapture.views.factories + +import androidx.compose.runtime.Composable +import dev.ohs.fhir.datacapture.views.QuestionnaireViewItem + +interface QuestionnaireItemViewFactory { + @Composable fun Content(questionnaireViewItem: QuestionnaireViewItem) +} diff --git a/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/views/factories/RadioGroupViewFactory.kt b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/views/factories/RadioGroupViewFactory.kt new file mode 100644 index 000000000..d393c4a7c --- /dev/null +++ b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/views/factories/RadioGroupViewFactory.kt @@ -0,0 +1,147 @@ +/* + * Copyright 2022-2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.ohs.fhir.datacapture.views.factories + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.AnnotatedString +import com.google.fhir.model.r4.Questionnaire +import dev.ohs.fhir.datacapture.extensions.ChoiceOrientationTypes +import dev.ohs.fhir.datacapture.extensions.choiceOrientation +import dev.ohs.fhir.datacapture.extensions.displayString +import dev.ohs.fhir.datacapture.extensions.itemAnswerOptionImage +import dev.ohs.fhir.datacapture.extensions.itemMedia +import dev.ohs.fhir.datacapture.extensions.toQuestionnaireResponseItemAnswer +import dev.ohs.fhir.datacapture.theme.QuestionnaireTheme +import dev.ohs.fhir.datacapture.views.QuestionnaireViewItem +import dev.ohs.fhir.datacapture.views.components.ChoiceRadioButton +import dev.ohs.fhir.datacapture.views.components.Header +import dev.ohs.fhir.datacapture.views.components.MediaItem +import dev.ohs.fhir.datacapture.views.isAnswerOptionSelected +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +internal const val RADIO_OPTION_TAG = "radio_group_option" + +internal object RadioGroupViewFactory : QuestionnaireItemViewFactory { + @Composable + override fun Content(questionnaireViewItem: QuestionnaireViewItem) { + val coroutineScope = rememberCoroutineScope { Dispatchers.Main } + val readOnly = + remember(questionnaireViewItem) { + questionnaireViewItem.questionnaireItem.readOnly?.value ?: false + } + val choiceOrientationType = + remember(questionnaireViewItem) { + questionnaireViewItem.questionnaireItem.choiceOrientation ?: ChoiceOrientationTypes.VERTICAL + } + val enabledAnswerOptions = + remember(questionnaireViewItem) { questionnaireViewItem.enabledAnswerOptions } + var selectedAnswerOption by + remember(questionnaireViewItem) { + mutableStateOf( + enabledAnswerOptions.singleOrNull { questionnaireViewItem.isAnswerOptionSelected(it) }, + ) + } + val onAnswerOptionChoiceChange: suspend (Questionnaire.Item.AnswerOption) -> Unit = + { answerOption -> + if (selectedAnswerOption != answerOption) { + selectedAnswerOption = answerOption + questionnaireViewItem.setAnswer( + answerOption.toQuestionnaireResponseItemAnswer(), + ) + } else { + // Deselect an answerOption + selectedAnswerOption = null + questionnaireViewItem.clearAnswer() + } + } + + Column( + modifier = + Modifier.fillMaxWidth() + .padding( + horizontal = QuestionnaireTheme.dimensions.itemMarginHorizontal, + vertical = QuestionnaireTheme.dimensions.itemMarginVertical, + ), + ) { + Header( + questionnaireViewItem, + showRequiredOrOptionalText = true, + displayValidationResult = true, + ) + questionnaireViewItem.questionnaireItem.itemMedia?.let { MediaItem(it) } + + when (choiceOrientationType) { + ChoiceOrientationTypes.HORIZONTAL -> { + FlowRow( + modifier = Modifier.selectableGroup().fillMaxWidth(), + horizontalArrangement = + Arrangement.spacedBy(QuestionnaireTheme.dimensions.optionItemMarginHorizontal), + verticalArrangement = + Arrangement.spacedBy(QuestionnaireTheme.dimensions.optionItemMarginVertical), + ) { + enabledAnswerOptions.forEach { + val answerOptionDisplay = it.displayString() + ChoiceRadioButton( + label = remember(it) { AnnotatedString(answerOptionDisplay) }, + selected = it == selectedAnswerOption, + enabled = !readOnly, + modifier = Modifier.weight(1f).testTag(RADIO_OPTION_TAG), + image = it.itemAnswerOptionImage(), + ) { + coroutineScope.launch { onAnswerOptionChoiceChange(it) } + } + } + } + } + ChoiceOrientationTypes.VERTICAL -> { + Column( + modifier = Modifier.selectableGroup().fillMaxWidth(), + verticalArrangement = + Arrangement.spacedBy(QuestionnaireTheme.dimensions.optionItemMarginVertical), + ) { + enabledAnswerOptions.forEach { + val answerOptionDisplay = it.displayString() + ChoiceRadioButton( + label = remember(it) { AnnotatedString(answerOptionDisplay) }, + selected = it == selectedAnswerOption, + enabled = !readOnly, + modifier = Modifier.fillMaxWidth().testTag(RADIO_OPTION_TAG), + image = it.itemAnswerOptionImage(), + ) { + coroutineScope.launch { onAnswerOptionChoiceChange(it) } + } + } + } + } + } + } + } +} diff --git a/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/views/factories/SliderViewFactory.kt b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/views/factories/SliderViewFactory.kt new file mode 100644 index 000000000..b6fbdf0c7 --- /dev/null +++ b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/views/factories/SliderViewFactory.kt @@ -0,0 +1,129 @@ +/* + * Copyright 2022-2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.ohs.fhir.datacapture.views.factories + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import com.google.fhir.model.r4.Extension +import com.google.fhir.model.r4.QuestionnaireResponse +import dev.ohs.fhir.datacapture.extensions.FhirR4Integer +import dev.ohs.fhir.datacapture.extensions.itemMedia +import dev.ohs.fhir.datacapture.extensions.sliderStepValue +import dev.ohs.fhir.datacapture.generated.resources.Res +import dev.ohs.fhir.datacapture.generated.resources.min_value_less_than_max_value_validation_error_msg +import dev.ohs.fhir.datacapture.theme.QuestionnaireTheme +import dev.ohs.fhir.datacapture.validation.Invalid +import dev.ohs.fhir.datacapture.validation.NotValidated +import dev.ohs.fhir.datacapture.validation.Valid +import dev.ohs.fhir.datacapture.validation.ValidationResult +import dev.ohs.fhir.datacapture.views.QuestionnaireViewItem +import dev.ohs.fhir.datacapture.views.components.ErrorText +import dev.ohs.fhir.datacapture.views.components.Header +import dev.ohs.fhir.datacapture.views.components.MediaItem +import dev.ohs.fhir.datacapture.views.components.SliderItem +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.stringResource + +internal object SliderViewFactory : QuestionnaireItemViewFactory { + private const val SLIDER_DEFAULT_STEP_SIZE = 1 + private const val SLIDER_DEFAULT_VALUE_FROM = 0.0F + private const val SLIDER_DEFAULT_VALUE_TO = 100.0F + + @Composable + override fun Content(questionnaireViewItem: QuestionnaireViewItem) { + val validationMessage = + remember(questionnaireViewItem) { + displayValidationResult(questionnaireViewItem.validationResult) + } + val readOnly = + remember(questionnaireViewItem) { + questionnaireViewItem.questionnaireItem.readOnly?.value ?: false + } + val answer = remember(questionnaireViewItem) { questionnaireViewItem.answers.singleOrNull() } + val minValue = + remember(answer) { + getFloatValue(questionnaireViewItem.minAnswerValue, ifNull = SLIDER_DEFAULT_VALUE_FROM) + } + val maxValue = + remember(answer) { + getFloatValue(questionnaireViewItem.maxAnswerValue, ifNull = SLIDER_DEFAULT_VALUE_TO) + } + + check(minValue < maxValue) { + stringResource( + Res.string.min_value_less_than_max_value_validation_error_msg, + minValue, + maxValue, + ) + } + val stepSize = + remember(questionnaireViewItem) { + questionnaireViewItem.questionnaireItem.sliderStepValue ?: SLIDER_DEFAULT_STEP_SIZE + } + val steps = + remember(stepSize, minValue, maxValue) { (maxValue - minValue).div(stepSize).toInt() - 1 } + val questionnaireViewItemAnswerValue = + remember(answer) { answer?.value?.asInteger()?.value?.value?.toFloat() ?: minValue } + val coroutineScope = rememberCoroutineScope { Dispatchers.Main } + + Column( + modifier = + Modifier.fillMaxWidth() + .padding( + horizontal = QuestionnaireTheme.dimensions.itemMarginHorizontal, + vertical = QuestionnaireTheme.dimensions.itemMarginVertical, + ), + ) { + Header(questionnaireViewItem, showRequiredOrOptionalText = true) + questionnaireViewItem.questionnaireItem.itemMedia?.let { MediaItem(it) } + SliderItem( + position = questionnaireViewItemAnswerValue, + steps = steps, + valueRange = minValue..maxValue, + enabled = !readOnly, + ) { + coroutineScope.launch { + questionnaireViewItem.setAnswer( + QuestionnaireResponse.Item.Answer( + value = + QuestionnaireResponse.Item.Answer.Value.Integer( + FhirR4Integer(value = it.toInt()), + ), + ), + ) + } + } + validationMessage?.let { ErrorText(it) } + } + } + + private fun displayValidationResult(validationResult: ValidationResult) = + when (validationResult) { + is NotValidated, + Valid, -> null + is Invalid -> validationResult.singleStringValidationMessage + } + + private fun getFloatValue(extensionValue: Extension.Value?, ifNull: Float) = + extensionValue?.asInteger()?.value?.value?.toFloat() ?: ifNull +} diff --git a/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/views/factories/TimeViewFactory.kt b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/views/factories/TimeViewFactory.kt new file mode 100644 index 000000000..6b1834746 --- /dev/null +++ b/datacapture/src/commonMain/kotlin/dev/ohs/fhir/datacapture/views/factories/TimeViewFactory.kt @@ -0,0 +1,133 @@ +/* + * Copyright 2024-2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.ohs.fhir.datacapture.views.factories + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import com.google.fhir.model.r4.QuestionnaireResponse +import com.google.fhir.model.r4.Time +import dev.ohs.fhir.datacapture.extensions.itemMedia +import dev.ohs.fhir.datacapture.generated.resources.Res +import dev.ohs.fhir.datacapture.generated.resources.required_text_and_new_line +import dev.ohs.fhir.datacapture.generated.resources.time +import dev.ohs.fhir.datacapture.getLocalDateTimeFormatter +import dev.ohs.fhir.datacapture.theme.QuestionnaireTheme +import dev.ohs.fhir.datacapture.validation.Invalid +import dev.ohs.fhir.datacapture.views.QuestionnaireViewItem +import dev.ohs.fhir.datacapture.views.components.Header +import dev.ohs.fhir.datacapture.views.components.MediaItem +import dev.ohs.fhir.datacapture.views.components.TimeFieldItem +import dev.ohs.fhir.datacapture.views.components.getRequiredOrOptionalText +import kotlin.time.Clock +import kotlin.time.ExperimentalTime +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.datetime.LocalTime +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toLocalDateTime +import org.jetbrains.compose.resources.stringResource + +@OptIn(ExperimentalTime::class) +object TimeViewFactory : QuestionnaireItemViewFactory { + + @Composable + override fun Content(questionnaireViewItem: QuestionnaireViewItem) { + val requiredTextNewLineStringResource = stringResource(Res.string.required_text_and_new_line) + val localDateTimeFormatter = getLocalDateTimeFormatter() + val validationMessage = + remember(questionnaireViewItem.validationResult) { + val validationMessage = + when (val validationResult = questionnaireViewItem.validationResult) { + is Invalid -> validationResult.singleStringValidationMessage + else -> null + } + + validationMessage?.let { + if ( + questionnaireViewItem.questionnaireItem.required?.value == true && + questionnaireViewItem.questionViewTextConfiguration.showRequiredText + ) { + requiredTextNewLineStringResource + validationMessage + } else { + validationMessage + } + } + } + val readOnly = + remember(questionnaireViewItem.questionnaireItem) { + questionnaireViewItem.questionnaireItem.readOnly?.value ?: false + } + val questionnaireViewItemLocalTimeAnswer = + remember(questionnaireViewItem) { + questionnaireViewItem.answers.singleOrNull()?.value?.asTime()?.value?.value + } + val initialTimeForSelection = + remember(questionnaireViewItemLocalTimeAnswer) { + questionnaireViewItemLocalTimeAnswer + ?: Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).time + } + val questionnaireViewItemLocalTimeAnswerDisplay = + remember(questionnaireViewItemLocalTimeAnswer) { + questionnaireViewItemLocalTimeAnswer?.let { localDateTimeFormatter.localizedTimeString(it) } + } + + val coroutineScope = rememberCoroutineScope { Dispatchers.Main } + + Column( + modifier = + Modifier.padding( + horizontal = QuestionnaireTheme.dimensions.itemMarginHorizontal, + vertical = QuestionnaireTheme.dimensions.itemMarginVertical, + ), + ) { + Header(questionnaireViewItem) + questionnaireViewItem.questionnaireItem.itemMedia?.let { MediaItem(it) } + TimeFieldItem( + modifier = Modifier.fillMaxWidth(), + initialStartTime = initialTimeForSelection, + timeSelectedDisplay = questionnaireViewItemLocalTimeAnswerDisplay, + enabled = !readOnly, + hint = stringResource(Res.string.time), + supportingHelperText = + if (!validationMessage.isNullOrBlank()) { + validationMessage + } else { + getRequiredOrOptionalText(questionnaireViewItem) + }, + isError = !validationMessage.isNullOrBlank(), + ) { + coroutineScope.launch { setQuestionnaireItemViewItemAnswer(questionnaireViewItem, it) } + } + } + } + + /** Set the answer in the [QuestionnaireResponse]. */ + private suspend fun setQuestionnaireItemViewItemAnswer( + questionnaireViewItem: QuestionnaireViewItem, + localDateTime: LocalTime, + ) = + questionnaireViewItem.setAnswer( + QuestionnaireResponse.Item.Answer( + value = QuestionnaireResponse.Item.Answer.Value.Time(value = Time(value = localDateTime)), + ), + ) +} diff --git a/datacapture/src/commonTest/composeResources/files/component_date_picker.json b/datacapture/src/commonTest/composeResources/files/component_date_picker.json new file mode 100644 index 000000000..c9998338c --- /dev/null +++ b/datacapture/src/commonTest/composeResources/files/component_date_picker.json @@ -0,0 +1,37 @@ +{ + "resourceType": "Questionnaire", + "status": "active", + "item": [ + { + "linkId": "1", + "text": "Enter a date", + "type": "date", + "extension": [ + { + "url": "http://hl7.org/fhir/StructureDefinition/entryFormat", + "valueString": "yyyy-mm-dd" + } + ], + "item": [ + { + "extension": [ + { + "url": "http://hl7.org/fhir/StructureDefinition/questionnaire-displayCategory", + "valueCodeableConcept": { + "coding": [ + { + "system": "http://hl7.org/fhir/questionnaire-display-category", + "code": "instructions" + } + ] + } + } + ], + "linkId": "1-most-recent", + "text": "Use keyboard entry or date picker", + "type": "display" + } + ] + } + ] +} diff --git a/datacapture/src/commonTest/composeResources/files/component_date_time_picker.json b/datacapture/src/commonTest/composeResources/files/component_date_time_picker.json new file mode 100644 index 000000000..1ccc31635 --- /dev/null +++ b/datacapture/src/commonTest/composeResources/files/component_date_time_picker.json @@ -0,0 +1,31 @@ +{ + "resourceType": "Questionnaire", + "status": "active", + "item": [ + { + "linkId": "1", + "text": "Schedule an appointment", + "type": "dateTime", + "item": [ + { + "extension": [ + { + "url": "http://hl7.org/fhir/StructureDefinition/questionnaire-displayCategory", + "valueCodeableConcept": { + "coding": [ + { + "system": "http://hl7.org/fhir/questionnaire-display-category", + "code": "instructions" + } + ] + } + } + ], + "linkId": "1-most-recent", + "text": "Select a date 4 weeks from now", + "type": "display" + } + ] + } + ] +} diff --git a/datacapture/src/commonTest/composeResources/files/component_multiple_repeated_group.json b/datacapture/src/commonTest/composeResources/files/component_multiple_repeated_group.json new file mode 100644 index 000000000..cced3c984 --- /dev/null +++ b/datacapture/src/commonTest/composeResources/files/component_multiple_repeated_group.json @@ -0,0 +1,38 @@ +{ + "resourceType": "Questionnaire", + "status": "active", + "item": [ + { + "linkId": "1", + "type": "group", + "text": "Repeated Group", + "repeats": true, + "item": [ + { + "linkId": "1-1", + "text": "Sample date question", + "type": "date", + "extension": [ + { + "url": "http://hl7.org/fhir/StructureDefinition/entryFormat", + "valueString": "yyyy-mm-dd" + } + ] + } + ] + }, + { + "linkId": "2", + "type": "group", + "text": "Decimal Repeated Group", + "repeats": true, + "item": [ + { + "linkId": "2-1", + "text": "Sample decimal question", + "type": "decimal" + } + ] + } + ] +} diff --git a/datacapture/src/commonTest/composeResources/files/component_non_repeated_group.json b/datacapture/src/commonTest/composeResources/files/component_non_repeated_group.json new file mode 100644 index 000000000..06793d19d --- /dev/null +++ b/datacapture/src/commonTest/composeResources/files/component_non_repeated_group.json @@ -0,0 +1,25 @@ +{ + "resourceType": "Questionnaire", + "status": "active", + "item": [ + { + "linkId": "1", + "type": "group", + "text": "Group", + "repeats": false, + "item": [ + { + "linkId": "1-1", + "text": "Sample date question", + "type": "date", + "extension": [ + { + "url": "http://hl7.org/fhir/StructureDefinition/entryFormat", + "valueString": "yyyy-mm-dd" + } + ] + } + ] + } + ] +} diff --git a/datacapture/src/commonTest/composeResources/files/component_repeated_group.json b/datacapture/src/commonTest/composeResources/files/component_repeated_group.json new file mode 100644 index 000000000..b52ef8fa2 --- /dev/null +++ b/datacapture/src/commonTest/composeResources/files/component_repeated_group.json @@ -0,0 +1,25 @@ +{ + "resourceType": "Questionnaire", + "status": "active", + "item": [ + { + "linkId": "1", + "type": "group", + "text": "Repeated Group", + "repeats": true, + "item": [ + { + "linkId": "1-1", + "text": "Sample date question", + "type": "date", + "extension": [ + { + "url": "http://hl7.org/fhir/StructureDefinition/entryFormat", + "valueString": "yyyy-mm-dd" + } + ] + } + ] + } + ] +} diff --git a/datacapture/src/commonTest/composeResources/files/layout_paginated.json b/datacapture/src/commonTest/composeResources/files/layout_paginated.json new file mode 100644 index 000000000..0c80eb703 --- /dev/null +++ b/datacapture/src/commonTest/composeResources/files/layout_paginated.json @@ -0,0 +1,61 @@ +{ + "resourceType": "Questionnaire", + "status": "active", + "item": [ + { + "linkId": "1", + "type": "group", + "extension": [ + { + "url": "http://hl7.org/fhir/StructureDefinition/questionnaire-itemControl", + "valueCodeableConcept": { + "coding": [ + { + "system": "http://hl7.org/fhir/questionnaire-item-control", + "code": "page", + "display": "Page" + } + ], + "text": "Page" + } + } + ], + "item": [ + { + "linkId": "1.1", + "type": "display", + "text": "Personal information", + "prefix": "1." + } + ] + }, + { + "linkId": "2", + "type": "group", + "extension": [ + { + "url": "http://hl7.org/fhir/StructureDefinition/questionnaire-itemControl", + "valueCodeableConcept": { + "coding": [ + { + "system": "http://hl7.org/fhir/questionnaire-item-control", + "code": "page", + "display": "Page" + } + ], + "text": "Page" + } + } + ], + "item": [ + { + "linkId": "2.1", + "type": "date", + "text": "Date of birth", + "prefix": "2.", + "required": true + } + ] + } + ] +} diff --git a/datacapture/src/commonTest/composeResources/files/paginated_questionnaire_with_dependent_answer.json b/datacapture/src/commonTest/composeResources/files/paginated_questionnaire_with_dependent_answer.json new file mode 100644 index 000000000..a6db4d45a --- /dev/null +++ b/datacapture/src/commonTest/composeResources/files/paginated_questionnaire_with_dependent_answer.json @@ -0,0 +1,71 @@ +{ + "resourceType": "Questionnaire", + "title": "Review Questionnaire", + "status": "active", + "item": [ + { + "extension": [ + { + "url": "http://hl7.org/fhir/StructureDefinition/questionnaire-itemControl", + "valueCodeableConcept": { + "coding": [ + { + "system": "http://hl7.org/fhir/questionnaire-item-control", + "code": "page", + "display": "Page 1" + } + ], + "text": "Page 1" + } + } + ], + "linkId": "1", + "type": "group", + "item": [ + { + "linkId": "1.1", + "text": "Are you Omar?", + "type": "boolean", + "initial": [ + { + "valueBoolean": false + } + ] + } + ] + }, + { + "extension": [ + { + "url": "http://hl7.org/fhir/StructureDefinition/questionnaire-itemControl", + "valueCodeableConcept": { + "coding": [ + { + "system": "http://hl7.org/fhir/questionnaire-item-control", + "code": "page", + "display": "Page 2" + } + ], + "text": "Page 2" + } + } + ], + "linkId": "2", + "type": "group", + "enableWhen": [ + { + "question": "1.1", + "operator": "=", + "answerBoolean": true + } + ], + "item": [ + { + "text": "What is your favourite animal?", + "linkId": "2.1", + "type": "string" + } + ] + } + ] +} diff --git a/datacapture/src/commonTest/composeResources/files/questionnaire_with_dynamic_question_text.json b/datacapture/src/commonTest/composeResources/files/questionnaire_with_dynamic_question_text.json new file mode 100644 index 000000000..f4c521742 --- /dev/null +++ b/datacapture/src/commonTest/composeResources/files/questionnaire_with_dynamic_question_text.json @@ -0,0 +1,57 @@ +{ + "resourceType": "Questionnaire", + "status": "active", + "item": [ + { + "text": "Choose an option below", + "type": "choice", + "extension": [ + { + "url": "http://hl7.org/fhir/StructureDefinition/questionnaire-itemControl", + "valueCodeableConcept": { + "coding": [ + { + "system": "http://hl7.org/fhir/questionnaire-item-control", + "code": "radio-button", + "display": "Radio Button" + } + ] + } + } + ], + "linkId": "1", + "required": true, + "answerOption": [ + { + "valueCoding": { + "code": "option1", + "display": "First Option" + } + }, + { + "valueCoding": { + "code": "option2", + "display": "Second Option" + } + } + ] + }, + { + "linkId": "2", + "required": true, + "text": "Option Date", + "_text": { + "extension": [ + { + "url": "http://hl7.org/fhir/StructureDefinition/cqf-expression", + "valueExpression": { + "language": "text/fhirpath", + "expression": "'Provide \"'+ %resource.descendants().where(linkId = '1').answer.value.display + '\" Date'" + } + } + ] + }, + "type": "date" + } + ] +} \ No newline at end of file diff --git a/datacapture/src/commonTest/composeResources/files/questionnaire_with_enabled_display_items.json b/datacapture/src/commonTest/composeResources/files/questionnaire_with_enabled_display_items.json new file mode 100644 index 000000000..21aaf273b --- /dev/null +++ b/datacapture/src/commonTest/composeResources/files/questionnaire_with_enabled_display_items.json @@ -0,0 +1,66 @@ +{ + "resourceType": "Questionnaire", + "id": "questionnaire.enabled.display", + "name": "Questionnaire Enabled Display", + "title": "Questionnaire Enabled Display", + "status": "active", + "item": [ + { + "linkId": "1", + "text": "Questionnaire Text", + "type": "boolean", + "item": [ + { + "extension": [ + { + "url": "http://hl7.org/fhir/StructureDefinition/questionnaire-displayCategory", + "valueCodeableConcept": { + "coding": [ + { + "system": "http://hl7.org/fhir/questionnaire-display-category", + "code": "instructions" + } + ] + } + }, + { + "url": "http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-enableWhenExpression", + "valueExpression": { + "language": "text/fhirpath", + "expression": "%resource.repeat(item).where(linkId='1' and answer.empty().not()).select(answer.value) = false" + } + } + ], + "linkId": "1.1", + "text": "Text when no is selected", + "type": "display" + }, + { + "extension": [ + { + "url": "http://hl7.org/fhir/StructureDefinition/questionnaire-displayCategory", + "valueCodeableConcept": { + "coding": [ + { + "system": "http://hl7.org/fhir/questionnaire-display-category", + "code": "instructions" + } + ] + } + }, + { + "url": "http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-enableWhenExpression", + "valueExpression": { + "language": "text/fhirpath", + "expression": "%resource.repeat(item).where(linkId='1' and answer.empty().not()).select(answer.value) = true" + } + } + ], + "linkId": "1.2", + "text": "Text when yes is selected", + "type": "display" + } + ] + } + ] +} diff --git a/datacapture/src/commonTest/composeResources/files/repeated_group_multiple_response.json b/datacapture/src/commonTest/composeResources/files/repeated_group_multiple_response.json new file mode 100644 index 000000000..2f316f412 --- /dev/null +++ b/datacapture/src/commonTest/composeResources/files/repeated_group_multiple_response.json @@ -0,0 +1,36 @@ +{ + "resourceType": "QuestionnaireResponse", + "status": "completed", + "item": [ + { + "linkId": "1", + "text": "Repeated Group", + "item": [ + { + "linkId": "1-1", + "text": "Sample date question", + "answer": [ + { + "valueDate": "2024-06-03" + } + ] + } + ] + }, + { + "linkId": "1", + "text": "Repeated Group", + "item": [ + { + "linkId": "1-1", + "text": "Sample date question", + "answer": [ + { + "valueDate": "2026-02-11" + } + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/datacapture/src/commonTest/composeResources/files/repeated_group_response.json b/datacapture/src/commonTest/composeResources/files/repeated_group_response.json new file mode 100644 index 000000000..d8e31e6c2 --- /dev/null +++ b/datacapture/src/commonTest/composeResources/files/repeated_group_response.json @@ -0,0 +1,21 @@ +{ + "resourceType": "QuestionnaireResponse", + "status": "completed", + "item": [ + { + "linkId": "1", + "text": "Repeated Group", + "item": [ + { + "linkId": "1-1", + "text": "Sample date question", + "answer": [ + { + "valueDate": "2024-06-03" + } + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/datacapture/src/commonTest/composeResources/files/text_questionnaire_decimal.json b/datacapture/src/commonTest/composeResources/files/text_questionnaire_decimal.json new file mode 100644 index 000000000..39fd55ef3 --- /dev/null +++ b/datacapture/src/commonTest/composeResources/files/text_questionnaire_decimal.json @@ -0,0 +1,11 @@ +{ + "resourceType": "Questionnaire", + "status": "active", + "item": [ + { + "linkId": "1", + "type": "decimal", + "text": "Enter a decimal" + } + ] +} diff --git a/datacapture/src/commonTest/composeResources/files/text_questionnaire_integer.json b/datacapture/src/commonTest/composeResources/files/text_questionnaire_integer.json new file mode 100644 index 000000000..db9eadc8c --- /dev/null +++ b/datacapture/src/commonTest/composeResources/files/text_questionnaire_integer.json @@ -0,0 +1,11 @@ +{ + "resourceType": "Questionnaire", + "status": "active", + "item": [ + { + "linkId": "1", + "type": "integer", + "text": "Enter an integer" + } + ] +} diff --git a/datacapture/src/commonTest/kotlin/dev/ohs/fhir/datacapture/UIQuestionnaireTest.kt b/datacapture/src/commonTest/kotlin/dev/ohs/fhir/datacapture/UIQuestionnaireTest.kt new file mode 100644 index 000000000..d3f79831b --- /dev/null +++ b/datacapture/src/commonTest/kotlin/dev/ohs/fhir/datacapture/UIQuestionnaireTest.kt @@ -0,0 +1,578 @@ +/* + * Copyright 2023-2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.ohs.fhir.datacapture + +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.remember +import androidx.compose.ui.semantics.ProgressBarRangeInfo +import androidx.compose.ui.test.ComposeUiTest +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertRangeInfoEquals +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.hasAnyAncestor +import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.printToLog +import androidx.compose.ui.test.runComposeUiTest +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.ViewModelStore +import androidx.lifecycle.ViewModelStoreOwner +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.testing.TestLifecycleOwner +import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner +import com.google.fhir.model.r4.Enumeration +import com.google.fhir.model.r4.FhirR4Json +import com.google.fhir.model.r4.Questionnaire +import com.google.fhir.model.r4.terminologies.PublicationStatus +import dev.ohs.fhir.datacapture.extensions.FhirR4String +import dev.ohs.fhir.datacapture.generated.resources.Res +import dev.ohs.fhir.datacapture.generated.resources.button_pagination_next +import dev.ohs.fhir.datacapture.generated.resources.button_pagination_previous +import dev.ohs.fhir.datacapture.generated.resources.button_review +import dev.ohs.fhir.datacapture.generated.resources.edit_button_text +import dev.ohs.fhir.datacapture.generated.resources.submit_questionnaire +import dev.ohs.fhir.datacapture.views.components.ADD_REPEATED_GROUP_BUTTON_TAG +import dev.ohs.fhir.datacapture.views.components.DELETE_REPEATED_GROUP_ITEM_BUTTON_TAG +import dev.ohs.fhir.datacapture.views.components.HINT_HEADER_TAG +import dev.ohs.fhir.datacapture.views.components.QUESTIONNAIRE_BOTTOM_NAVIGATION_TEST_TAG +import dev.ohs.fhir.datacapture.views.components.QUESTIONNAIRE_PAGE_NAVIGATION_BUTTON_TEST_TAG +import dev.ohs.fhir.datacapture.views.components.QUESTION_HEADER_TAG +import dev.ohs.fhir.datacapture.views.components.REPEATED_GROUP_INSTANCE_HEADER_TITLE_TAG +import dev.ohs.fhir.datacapture.views.factories.NO_CHOICE_RADIO_BUTTON_TAG +import dev.ohs.fhir.datacapture.views.factories.YES_CHOICE_RADIO_BUTTON_TAG +import kotlin.test.Test +import org.jetbrains.compose.resources.getString + +@OptIn(ExperimentalTestApi::class) +class UIQuestionnaireTest { + + private val fhirR4Json = FhirR4Json() + + @Test + fun shouldDisplayReviewButtonWhenNoMorePagesToDisplay() = runComposeUiTest { + setQuestionnaireContent("files/paginated_questionnaire_with_dependent_answer.json", true) + val reviewButtonText = getString(Res.string.button_review) + onNode(hasTestTag(QUESTIONNAIRE_PAGE_NAVIGATION_BUTTON_TEST_TAG) and hasText(reviewButtonText)) + .assertIsDisplayed() + + onNodeWithText("Yes").performClick() + + onNode(hasTestTag(QUESTIONNAIRE_PAGE_NAVIGATION_BUTTON_TEST_TAG) and hasText(reviewButtonText)) + .assertDoesNotExist() + + onNodeWithText("No").performClick() + + onNode(hasTestTag(QUESTIONNAIRE_PAGE_NAVIGATION_BUTTON_TEST_TAG) and hasText(reviewButtonText)) + .assertIsDisplayed() + } + + @Test + fun shouldHideNextButtonIfDisabled() = runComposeUiTest { + setQuestionnaireContent("files/layout_paginated.json", true) + + val nextButtonText = getString(Res.string.button_pagination_next) + onNode(hasTestTag(QUESTIONNAIRE_PAGE_NAVIGATION_BUTTON_TEST_TAG) and hasText(nextButtonText)) + .performClick() + + onNode(hasTestTag(QUESTIONNAIRE_PAGE_NAVIGATION_BUTTON_TEST_TAG) and hasText(nextButtonText)) + .assertDoesNotExist() + } + + @Test + fun shouldDisplayNextButtonIfEnabled() = runComposeUiTest { + setQuestionnaireContent("files/layout_paginated.json", true) + + val nextButtonText = getString(Res.string.button_pagination_next) + onNode(hasTestTag(QUESTIONNAIRE_PAGE_NAVIGATION_BUTTON_TEST_TAG) and hasText(nextButtonText)) + .assertIsDisplayed() + } + + @Test + fun displayItems_shouldGetEnabled_withAnswerChoice() = runComposeUiTest { + setQuestionnaireContent("files/questionnaire_with_enabled_display_items.json") + + onNodeWithTag(HINT_HEADER_TAG).assertDoesNotExist() + onNodeWithTag(YES_CHOICE_RADIO_BUTTON_TAG).performClick() + onNodeWithTag(HINT_HEADER_TAG).assertTextEquals("Text when yes is selected").assertIsDisplayed() + + onNodeWithTag(NO_CHOICE_RADIO_BUTTON_TAG).performClick() + onNodeWithTag(HINT_HEADER_TAG).assertIsDisplayed().assertTextEquals("Text when no is selected") + + onNodeWithTag(NO_CHOICE_RADIO_BUTTON_TAG).performClick() + onNodeWithTag(HINT_HEADER_TAG).assertDoesNotExist() + } + + @Test + fun cqfExpression_shouldSetText_withEvaluatedAnswer() = runComposeUiTest { + setQuestionnaireContent("files/questionnaire_with_dynamic_question_text.json") + + onRoot().printToLog("Anyting!!!") + onNode(hasTestTag(QUESTION_HEADER_TAG) and hasText("Option Date")).assertIsDisplayed() + onNode(hasTestTag(QUESTION_HEADER_TAG) and hasText("Provide \"First Option\" Date")) + .assertDoesNotExist() + + onNodeWithText("First Option").performClick() + + onNode(hasTestTag(QUESTION_HEADER_TAG) and hasText("Option Date")).assertDoesNotExist() + onNode(hasTestTag(QUESTION_HEADER_TAG) and hasText("Provide \"First Option\" Date")) + .assertIsDisplayed() + } + + @Test + fun progressBar_shouldBeVisible_withSinglePageQuestionnaire() = runComposeUiTest { + setQuestionnaireContent("files/text_questionnaire_integer.json") + onNodeWithTag(QUESTIONNAIRE_PROGRESS_INDICATOR_TEST_TAG) + .assertRangeInfoEquals(ProgressBarRangeInfo(1f, 0f..1f)) + } + + @Test + fun progressBar_shouldBeVisible_withPaginatedQuestionnaire() = runComposeUiTest { + setQuestionnaireContent("files/layout_paginated.json") + onNodeWithTag(QUESTIONNAIRE_PROGRESS_INDICATOR_TEST_TAG) + .assertRangeInfoEquals(ProgressBarRangeInfo(0.5f, 0f..1f)) + } + + @Test + fun progressBar_shouldProgress_onPaginationNext() = runComposeUiTest { + setQuestionnaireContent("files/layout_paginated.json") + + val nextButtonText = getString(Res.string.button_pagination_next) + onNode(hasTestTag(QUESTIONNAIRE_PAGE_NAVIGATION_BUTTON_TEST_TAG) and hasText(nextButtonText)) + .performClick() + + onNodeWithTag(QUESTIONNAIRE_PROGRESS_INDICATOR_TEST_TAG) + .assertRangeInfoEquals(ProgressBarRangeInfo(1f, 0f..1f)) + } + + @Test + fun progressBar_shouldBeGone_whenNavigatedToReviewScreen() = runComposeUiTest { + setQuestionnaireContent("files/text_questionnaire_integer.json", isReviewMode = true) + + val reviewButtonText = getString(Res.string.button_review) + onNode( + hasTestTag(QUESTIONNAIRE_PAGE_NAVIGATION_BUTTON_TEST_TAG) and hasText(reviewButtonText), + ) + .performClick() + + onNodeWithTag(QUESTIONNAIRE_PROGRESS_INDICATOR_TEST_TAG).assertDoesNotExist() + } + + @Test + fun progressBar_shouldBeVisible_whenNavigatedToEditScreenFromReview() = runComposeUiTest { + setQuestionnaireContent("files/text_questionnaire_integer.json", isReviewMode = true) + + val reviewButtonText = getString(Res.string.button_review) + onNode( + hasTestTag(QUESTIONNAIRE_PAGE_NAVIGATION_BUTTON_TEST_TAG) and hasText(reviewButtonText), + ) + .performClick() + + onNodeWithText(getString(Res.string.edit_button_text)).performClick() + + onNodeWithTag(QUESTIONNAIRE_PROGRESS_INDICATOR_TEST_TAG).assertIsDisplayed() + } + + @Test + fun test_add_item_button_does_not_exist_for_non_repeated_groups() = runComposeUiTest { + setQuestionnaireContent("files/component_non_repeated_group.json") + onNodeWithTag(ADD_REPEATED_GROUP_BUTTON_TAG).assertDoesNotExist() + } + + @Test + fun test_repeated_group_is_added() = runComposeUiTest { + setQuestionnaireContent("files/component_repeated_group.json") + onNodeWithTag(ADD_REPEATED_GROUP_BUTTON_TAG).performClick() + + onNodeWithTag(QUESTIONNAIRE_EDIT_LIST).assertExists().assertIsDisplayed() + + onNodeWithTag(REPEATED_GROUP_INSTANCE_HEADER_TITLE_TAG).assertIsDisplayed() + + onNodeWithTag(DELETE_REPEATED_GROUP_ITEM_BUTTON_TAG).assertIsDisplayed() + } + + @Test + fun test_repeated_group_adds_multiple_items() = runComposeUiTest { + setQuestionnaireContent("files/component_multiple_repeated_group.json") + onNode(hasTestTag(ADD_REPEATED_GROUP_BUTTON_TAG) and hasText("Add Repeated Group")) + .performClick() + onNodeWithTag(DELETE_REPEATED_GROUP_ITEM_BUTTON_TAG).assertIsDisplayed() + onNodeWithTag(REPEATED_GROUP_INSTANCE_HEADER_TITLE_TAG).assertIsDisplayed() + + onNode(hasTestTag(ADD_REPEATED_GROUP_BUTTON_TAG) and hasText("Add Decimal Repeated Group")) + .performClick() + onAllNodes(hasTestTag(DELETE_REPEATED_GROUP_ITEM_BUTTON_TAG)).assertCountEquals(2) + onAllNodes(hasTestTag(REPEATED_GROUP_INSTANCE_HEADER_TITLE_TAG)).assertCountEquals(2) + } + + @Test + fun test_repeated_group_is_deleted() = runComposeUiTest { + setQuestionnaireContent( + "files/component_repeated_group.json", + responseFileName = "files/repeated_group_response.json", + ) + + onNodeWithTag(QUESTIONNAIRE_EDIT_LIST).assertExists().assertIsDisplayed() + onNodeWithTag(REPEATED_GROUP_INSTANCE_HEADER_TITLE_TAG).assertIsDisplayed() + onNodeWithTag(DELETE_REPEATED_GROUP_ITEM_BUTTON_TAG).performClick() + onNodeWithTag(REPEATED_GROUP_INSTANCE_HEADER_TITLE_TAG).assertDoesNotExist() + } + + @Test + fun test_repeated_group_populates_multiple_answers() = runComposeUiTest { + setQuestionnaireContent( + "files/component_repeated_group.json", + responseFileName = "files/repeated_group_multiple_response.json", + ) + + onAllNodes(hasTestTag(REPEATED_GROUP_INSTANCE_HEADER_TITLE_TAG)).assertCountEquals(2) + onAllNodes(hasTestTag(DELETE_REPEATED_GROUP_ITEM_BUTTON_TAG)).assertCountEquals(2) + } + + @Test + fun shouldHideNextButtonOnLastPage() = runComposeUiTest { + val questionnaireJson = + """{ + "resourceType": "Questionnaire", + "status": "active", + "item": [ + { + "linkId": "1", + "type": "group", + "extension": [ + { + "url": "http://hl7.org/fhir/StructureDefinition/questionnaire-itemControl", + "valueCodeableConcept": { + "coding": [ + { + "system": "http://hl7.org/fhir/questionnaire-item-control", + "code": "page", + "display": "Page" + } + ], + "text": "Page" + } + } + ], + "item": [ + { + "linkId": "1.1", + "type": "display", + "text": "Item 1" + } + ] + }, + { + "linkId": "2", + "type": "group", + "extension": [ + { + "url": "http://hl7.org/fhir/StructureDefinition/questionnaire-itemControl", + "valueCodeableConcept": { + "coding": [ + { + "system": "http://hl7.org/fhir/questionnaire-item-control", + "code": "page", + "display": "Page" + } + ], + "text": "Page" + } + } + ], + "item": [ + { + "linkId": "2.1", + "type": "display", + "text": "Item 2" + } + ] + } + ] +} +""" + val questionnaire = fhirR4Json.decodeFromString(questionnaireJson) as Questionnaire + setQuestionnaireContent(questionnaire) + val nextButtonText = getString(Res.string.button_pagination_next) + onNode(hasTestTag(QUESTIONNAIRE_PAGE_NAVIGATION_BUTTON_TEST_TAG) and hasText(nextButtonText)) + .performClick() + onNode(hasTestTag(QUESTIONNAIRE_PAGE_NAVIGATION_BUTTON_TEST_TAG) and hasText(nextButtonText)) + .assertDoesNotExist() + } + + @Test + fun reviewPageShouldShowBothEditAndSubmitButton() = runComposeUiTest { + val questionnaire = + Questionnaire( + id = "a-questionnaire", + status = Enumeration(value = PublicationStatus.Active), + item = + listOf( + Questionnaire.Item( + linkId = FhirR4String(value = "a-link-id"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Boolean), + ), + ), + ) + setQuestionnaireContent(questionnaire, isReviewMode = true, showReviewPageFirst = true) + + onNodeWithText(getString(Res.string.edit_button_text)).assertIsDisplayed() + onNode( + hasTestTag(QUESTIONNAIRE_PAGE_NAVIGATION_BUTTON_TEST_TAG) and + hasText( + getString(Res.string.submit_questionnaire), + ), + ) + .assertIsDisplayed() + } + + @Test + fun questionnaireSubmitButtonTextShouldBeEditable() = runComposeUiTest { + val questionnaire = + Questionnaire( + id = "a-questionnaire", + status = Enumeration(value = PublicationStatus.Active), + item = + listOf( + Questionnaire.Item( + linkId = FhirR4String(value = "a-link-id"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Boolean), + ), + ), + ) + val customButtonText = "Apply" + setQuestionnaireContent(questionnaire, submitText = customButtonText) + onNode( + hasTestTag(QUESTIONNAIRE_PAGE_NAVIGATION_BUTTON_TEST_TAG) and hasText(customButtonText), + ) + .assertIsDisplayed() + } + + @Test + fun shouldHidePreviousButtonOnFirstPage() = runComposeUiTest { + val questionnaireJson = + """{ + "resourceType": "Questionnaire", + "status": "active", + "item": [ + { + "linkId": "1", + "type": "group", + "extension": [ + { + "url": "http://hl7.org/fhir/StructureDefinition/questionnaire-itemControl", + "valueCodeableConcept": { + "coding": [ + { + "system": "http://hl7.org/fhir/questionnaire-item-control", + "code": "page", + "display": "Page" + } + ], + "text": "Page" + } + } + ], + "item": [ + { + "linkId": "1.1", + "type": "display", + "text": "Item 1" + } + ] + }, + { + "linkId": "2", + "type": "group", + "extension": [ + { + "url": "http://hl7.org/fhir/StructureDefinition/questionnaire-itemControl", + "valueCodeableConcept": { + "coding": [ + { + "system": "http://hl7.org/fhir/questionnaire-item-control", + "code": "page", + "display": "Page" + } + ], + "text": "Page" + } + } + ], + "item": [ + { + "linkId": "2.1", + "type": "display", + "text": "Item 2" + } + ] + } + ] +} +""" + val questionnaire = fhirR4Json.decodeFromString(questionnaireJson) as Questionnaire + setQuestionnaireContent(questionnaire) + onNode( + hasTestTag(QUESTIONNAIRE_PAGE_NAVIGATION_BUTTON_TEST_TAG) and + hasText( + getString( + Res.string.button_pagination_previous, + ), + ), + ) + .assertDoesNotExist() + } + + @Test + fun showBottomNavigationContainerWhenSetShowNavigationInDefaultLongScrollIsSetToFalse() = + runComposeUiTest { + val questionnaire = + Questionnaire( + id = "a-questionnaire", + status = Enumeration(value = PublicationStatus.Active), + item = + listOf( + Questionnaire.Item( + linkId = FhirR4String(value = "a-link-id"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Boolean), + ), + ), + ) + setQuestionnaireContent(questionnaire, showNavigationLongScroll = false) + + onNode( + hasTestTag(QUESTIONNAIRE_BOTTOM_NAVIGATION_TEST_TAG) and + hasAnyAncestor(hasTestTag(QUESTIONNAIRE_EDIT_LIST)), + ) + .assertDoesNotExist() + + onNode( + hasTestTag(QUESTIONNAIRE_PAGE_NAVIGATION_BUTTON_TEST_TAG) and + hasText( + getString(Res.string.submit_questionnaire), + ), + ) + .assertIsDisplayed() + .assertIsEnabled() + } + + @Test + fun hideTheBottomNavigationContainerWhenSetShowNavigationInDefaultLongScrollIsSetToTrue() = + runComposeUiTest { + val questionnaire = + Questionnaire( + id = "a-questionnaire", + status = Enumeration(value = PublicationStatus.Active), + item = + listOf( + Questionnaire.Item( + linkId = FhirR4String(value = "a-link-id"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Boolean), + ), + ), + ) + setQuestionnaireContent(questionnaire, showNavigationLongScroll = true) + + onNode( + hasTestTag(QUESTIONNAIRE_BOTTOM_NAVIGATION_TEST_TAG) and + hasAnyAncestor(hasTestTag(QUESTIONNAIRE_EDIT_LIST)), + ) + .assertExists() + + onNode( + hasTestTag(QUESTIONNAIRE_PAGE_NAVIGATION_BUTTON_TEST_TAG) and + hasText( + getString(Res.string.submit_questionnaire), + ), + ) + .assertIsDisplayed() + .assertIsEnabled() + } + + private suspend fun ComposeUiTest.setQuestionnaireContent( + fileName: String, + isReviewMode: Boolean = false, + responseFileName: String? = null, + ) { + val questionnaireJsonString = Res.readBytes(fileName).decodeToString() + val questionnaireResponseJsonString = + responseFileName?.let { Res.readBytes(it).decodeToString() } + + val testLifecycleOwner = TestLifecycleOwner(Lifecycle.State.RESUMED) + setContent { + val viewModelStoreOwner = remember { + object : ViewModelStoreOwner { + override val viewModelStore = ViewModelStore() + } + } + + CompositionLocalProvider( + LocalLifecycleOwner provides testLifecycleOwner, + LocalViewModelStoreOwner provides viewModelStoreOwner, + ) { + Questionnaire( + questionnaireJson = questionnaireJsonString, + questionnaireResponseJson = questionnaireResponseJsonString, + showCancelButton = true, + showReviewPage = isReviewMode, + onSubmit = { _ -> }, + onCancel = {}, + ) + } + } + } + + private suspend fun ComposeUiTest.setQuestionnaireContent( + questionnaire: Questionnaire, + isReviewMode: Boolean = false, + showReviewPageFirst: Boolean = false, + showNavigationLongScroll: Boolean = false, + submitText: String? = null, + ) { + val questionnaireJsonString = fhirR4Json.encodeToString(questionnaire) + + val testLifecycleOwner = TestLifecycleOwner(Lifecycle.State.RESUMED) + setContent { + val viewModelStoreOwner = remember { + object : ViewModelStoreOwner { + override val viewModelStore = ViewModelStore() + } + } + + CompositionLocalProvider( + LocalLifecycleOwner provides testLifecycleOwner, + LocalViewModelStoreOwner provides viewModelStoreOwner, + ) { + Questionnaire( + questionnaireJson = questionnaireJsonString, + showReviewPage = isReviewMode, + showReviewPageFirst = showReviewPageFirst, + showNavigationLongScroll = showNavigationLongScroll, + submitButtonText = submitText, + onSubmit = { _ -> }, + onCancel = {}, + ) + } + } + } +} diff --git a/datacapture/src/commonTest/kotlin/dev/ohs/fhir/datacapture/enablement/EnablementEvaluatorTest.kt b/datacapture/src/commonTest/kotlin/dev/ohs/fhir/datacapture/enablement/EnablementEvaluatorTest.kt new file mode 100644 index 000000000..39230a1b6 --- /dev/null +++ b/datacapture/src/commonTest/kotlin/dev/ohs/fhir/datacapture/enablement/EnablementEvaluatorTest.kt @@ -0,0 +1,1106 @@ +/* + * Copyright 2022-2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.ohs.fhir.datacapture.enablement + +import com.google.fhir.model.r4.Attachment +import com.google.fhir.model.r4.Boolean +import com.google.fhir.model.r4.Code +import com.google.fhir.model.r4.Coding +import com.google.fhir.model.r4.Date +import com.google.fhir.model.r4.DateTime +import com.google.fhir.model.r4.Decimal +import com.google.fhir.model.r4.Enumeration +import com.google.fhir.model.r4.FhirR4Json +import com.google.fhir.model.r4.Integer +import com.google.fhir.model.r4.Quantity +import com.google.fhir.model.r4.Questionnaire +import com.google.fhir.model.r4.QuestionnaireResponse +import com.google.fhir.model.r4.Reference +import com.google.fhir.model.r4.String +import com.google.fhir.model.r4.Time +import com.google.fhir.model.r4.Uri +import com.google.fhir.model.r4.terminologies.PublicationStatus +import dev.ohs.fhir.datacapture.extensions.FhirR4Boolean +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertTrue +import kotlinx.coroutines.test.runTest + +class EnablementEvaluatorTest { + private val json = FhirR4Json() + + @Test + fun evaluate_noEnableWhen_shouldReturnTrue() { + assertTrue(evaluateEnableWhen()) + } + + @Test + fun evaluate_missingResponseItem_shouldReturnFalse() = runTest { + val questionnaireItemBuilder = + Questionnaire.Item.Builder( + linkId = String.Builder().apply { value = "q1" }, + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Boolean), + ) + .apply { + enableWhen = + mutableListOf( + Questionnaire.Item.EnableWhen.Builder( + question = String.Builder().apply { value = "q2" }, + answer = + Questionnaire.Item.EnableWhen.Answer.Boolean(value = Boolean(value = false)), + operator = Enumeration(value = Questionnaire.QuestionnaireItemOperator.Exists), + ) + .apply {}, + ) + } + val questionnaireBuilder = + Questionnaire.Builder(status = Enumeration(value = PublicationStatus.Active)).apply { + item = mutableListOf(questionnaireItemBuilder) + } + val questionnaireResponseItemBuilder = + QuestionnaireResponse.Item.Builder(linkId = String.Builder().apply { value = "q1" }) + val questionnaireResponseBuilder = + QuestionnaireResponse.Builder( + status = Enumeration(value = QuestionnaireResponse.QuestionnaireResponseStatus.Completed), + ) + .apply { item = mutableListOf(questionnaireResponseItemBuilder) } + + val result = + EnablementEvaluator(questionnaireBuilder.build(), questionnaireResponseBuilder.build()) + .evaluate(questionnaireItemBuilder.build(), questionnaireResponseItemBuilder.build()) + assertFalse(result) + } + + @Test + fun evaluate_expectAnswerExists_answerExists_shouldReturnTrue() { + assertTrue( + evaluateEnableWhen( + behavior = null, + EnableWhen( + operator = Questionnaire.QuestionnaireItemOperator.Exists, + expected = Boolean.Builder().apply { value = true }.build(), + actual = listOf(Integer.Builder().apply { value = 123 }.build()), + ), + ), + ) + } + + @Test + fun evaluate_expectAnswerExists_answerDoesNotExist_shouldReturnFalse() { + assertFalse( + evaluateEnableWhen( + behavior = null, + EnableWhen( + operator = Questionnaire.QuestionnaireItemOperator.Exists, + expected = Boolean.Builder().apply { value = true }.build(), + actual = listOf(), + ), + ), + ) + } + + @Test + fun evaluate_expectAnswerDoesNotExist_answerExists_shouldReturnFalse() { + assertFalse( + evaluateEnableWhen( + behavior = null, + EnableWhen( + operator = Questionnaire.QuestionnaireItemOperator.Exists, + expected = Boolean.Builder().apply { value = false }.build(), + actual = listOf(Integer.Builder().apply { value = 123 }.build()), + ), + ), + ) + } + + @Test + fun `evaluate() should evaluate enableWhenExpression`() = runTest { + val questionnaireJson = + """ + { + "resourceType": "Questionnaire", + "status": "active", + "item": [ + { + "linkId": "1", + "definition": "http://hl7.org/fhir/StructureDefinition/Patient#Patient.gender", + "type": "choice", + "text": "Gender" + }, + { + "extension": [ + { + "url": "http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-enableWhenExpression", + "valueExpression": { + "language": "text/fhirpath", + "expression": "%resource.repeat(item).where(linkId='1').answer.value.code ='female'" + } + } + ], + "linkId" : "2", + "text": "Have you had mammogram before?(enableWhenExpression = only when gender is female)", + "type": "choice", + "answerValueSet": "http://hl7.org/fhir/ValueSet/yesnodontknow" + } + ] + } + + """ + .trimIndent() + + val questionnaireResponseJson = + """ + { + "resourceType": "QuestionnaireResponse", + "status": "completed", + "item": [ + { + "linkId": "1", + "answer": [ + { + "valueCoding": { + "system": "http://hl7.org/fhir/administrative-gender", + "code": "female", + "display": "Female" + } + } + ] + }, + { + "linkId": "2" + } + ] + } + """ + .trimIndent() + + val questionnaire = json.decodeFromString(questionnaireJson) as Questionnaire + + val questionnaireItem: Questionnaire.Item? = + questionnaire.item.find { item -> item.linkId.value == "2" } + + val questionnaireResponse = + json.decodeFromString(questionnaireResponseJson) as QuestionnaireResponse + + assertNotNull(questionnaireItem) + assertTrue( + EnablementEvaluator(questionnaire, questionnaireResponse) + .evaluate( + questionnaireItem, + questionnaireResponse.item[1], + ), + ) + } + + @Test + fun `evaluate() should evaluate false enableWhenExpression`() = runTest { + val questionnaireJson = + """ + { + "resourceType": "Questionnaire", + "status": "active", + "item": [ + { + "linkId": "1", + "definition": "http://hl7.org/fhir/StructureDefinition/Patient#Patient.gender", + "type": "choice", + "text": "Gender" + }, + { + "extension": [ + { + "url": "http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-enableWhenExpression", + "valueExpression": { + "language": "text/fhirpath", + "expression": "%resource.repeat(item).where(linkId='1').answer.value.code ='female'" + } + } + ], + "linkId" : "2", + "text": "Have you had mammogram before?(enableWhenExpression = only when gender is female)", + "type": "choice", + "answerValueSet": "http://hl7.org/fhir/ValueSet/yesnodontknow" + } + ] + } + + """ + .trimIndent() + + val questionnaireResponseJson = + """ + { + "resourceType": "QuestionnaireResponse", + "status": "completed", + "item": [ + { + "linkId": "1", + "answer": [ + { + "valueCoding": { + "system": "http://hl7.org/fhir/administrative-gender", + "code": "male", + "display": "Male" + } + } + ] + }, + { + "linkId": "2" + } + ] + } + """ + .trimIndent() + + val questionnaire = json.decodeFromString(questionnaireJson) as Questionnaire + + val questionnaireItemComponent = questionnaire.item.find { it.linkId.value == "2" } + val questionnaireResponse = + json.decodeFromString(questionnaireResponseJson) as QuestionnaireResponse + assertNotNull(questionnaireItemComponent) + assertFalse( + EnablementEvaluator(questionnaire, questionnaireResponse) + .evaluate( + questionnaireItemComponent, + questionnaireResponse.item[1], + ), + ) + } + + @Test + fun `evaluate() should evaluate enableWhenExpression with %context fhirpath supplement literal`() = + runTest { + val questionnaireJson = + """ + { + "resourceType": "Questionnaire", + "status": "active", + "item": [ + { + "linkId": "1", + "definition": "http://hl7.org/fhir/StructureDefinition/Patient#Patient.gender", + "type": "choice", + "text": "Gender" + }, + { + "extension": [ + { + "url": "http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-enableWhenExpression", + "valueExpression": { + "language": "text/fhirpath", + "expression": "%resource.repeat(item).where(linkId='1').answer.value.code = %context.linkId" + } + } + ], + "linkId" : "female", + "text": "Have you had mammogram before?(enableWhenExpression = only when gender is female)", + "type": "choice", + "answerValueSet": "http://hl7.org/fhir/ValueSet/yesnodontknow" + } + ] + } + """ + .trimIndent() + + val questionnaireResponseJson = + """ + { + "resourceType": "QuestionnaireResponse", + "status": "completed", + "item": [ + { + "linkId": "1", + "answer": [ + { + "valueCoding": { + "system": "http://hl7.org/fhir/administrative-gender", + "code": "female", + "display": "Female" + } + } + ] + }, + { + "linkId": "female" + } + ] + } + """ + .trimIndent() + + val questionnaire = json.decodeFromString(questionnaireJson) as Questionnaire + + val questionnaireItem: Questionnaire.Item = + questionnaire.item.find { it.linkId.value == "female" }!! + + val questionnaireResponse = + json.decodeFromString(questionnaireResponseJson) as QuestionnaireResponse + + assertTrue( + EnablementEvaluator(questionnaire, questionnaireResponse) + .evaluate( + questionnaireItem, + questionnaireResponse.item[1], + ), + ) + } + + @Test + fun `evaluate() should evaluate enableWhenExpression with %questionnaire fhirpath supplement`() = + runTest { + val questionnaireJson = + """ + { + "resourceType": "Questionnaire", + "subjectType": [ "Practitioner" ], + "status": "active", + "item": [ + { + "extension": [ + { + "url": "http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-enableWhenExpression", + "valueExpression": { + "language": "text/fhirpath", + "expression": "%questionnaire.subjectType='Practitioner'" + } + } + ], + "linkId" : "contribution", + "text": "Contribution", + "type": "choice", + "answerValueSet": "http://hl7.org/fhir/ValueSet/yesnodontknow" + } + ] + } + """ + .trimIndent() + + val questionnaireResponseJson = + """ + { + "resourceType": "QuestionnaireResponse", + "status": "completed", + "item": [ + { + "linkId": "contribution", + "answer": [ + { + "valueCoding": { + "code": "yes", + "display": "Yes" + } + } + ] + } + ] + } + """ + .trimIndent() + + val questionnaire = json.decodeFromString(questionnaireJson) as Questionnaire + + val questionnaireResponse = + json.decodeFromString(questionnaireResponseJson) as QuestionnaireResponse + + assertTrue( + EnablementEvaluator(questionnaire, questionnaireResponse) + .evaluate( + questionnaire.item[0], + questionnaireResponse.item[0], + ), + ) + } + + @Test + fun `evaluate() should evaluate enableWhenExpression with %qItem fhirpath supplement`() = + runTest { + val questionnaireJson = + """ + { + "resourceType": "Questionnaire", + "subjectType": [ "Practitioner" ], + "status": "active", + "item": [ + { + "extension": [ + { + "url": "http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-enableWhenExpression", + "valueExpression": { + "language": "text/fhirpath", + "expression": "%qItem.text = 'Contribution'" + } + } + ], + "linkId" : "contribution", + "text": "Contribution", + "type": "choice", + "answerValueSet": "http://hl7.org/fhir/ValueSet/yesnodontknow" + } + ] + } + """ + .trimIndent() + + val questionnaireResponseJson = + """ + { + "resourceType": "QuestionnaireResponse", + "status": "completed", + "item": [ + { + "linkId": "contribution", + "answer": [ + { + "valueCoding": { + "code": "yes", + "display": "Yes" + } + } + ] + } + ] + } + """ + .trimIndent() + + val questionnaire = json.decodeFromString(questionnaireJson) as Questionnaire + + val questionnaireResponse = + json.decodeFromString(questionnaireResponseJson) as QuestionnaireResponse + + assertTrue( + EnablementEvaluator(questionnaire, questionnaireResponse) + .evaluate( + questionnaire.item[0], + questionnaireResponse.item[0], + ), + ) + } + + @Test + fun evaluate_expectAnswerDoesNotExist_answerDoesNotExist_shouldReturnTrue() { + assertTrue( + evaluateEnableWhen( + behavior = null, + EnableWhen( + operator = Questionnaire.QuestionnaireItemOperator.Exists, + expected = Boolean(value = false), + actual = listOf(), + ), + ), + ) + } + + @Test + fun evaluate_expectAnswerEqualToToValue_noAnswer_shouldReturnFalse() { + assertFalse( + evaluateEnableWhen( + behavior = null, + EnableWhen( + operator = Questionnaire.QuestionnaireItemOperator.EqualTo, + expected = Integer(value = 123), + actual = listOf(), + ), + ), + ) + } + + @Test + fun evaluate_expectAnswerEqualToToValue_someAnswerEqualToToValue_shouldReturnTrue() { + assertTrue( + evaluateEnableWhen( + behavior = null, + EnableWhen( + operator = Questionnaire.QuestionnaireItemOperator.EqualTo, + expected = Integer(value = 123), + actual = listOf(Integer(value = 123), Integer(value = 456)), + ), + ), + ) + } + + @Test + fun evaluate_expectAnswerEqualToToValue_noAnswerEqualToToValue_shouldReturnFalse() { + assertFalse( + evaluateEnableWhen( + behavior = null, + EnableWhen( + operator = Questionnaire.QuestionnaireItemOperator.EqualTo, + expected = Integer(value = 123), + actual = listOf(Integer(value = 456), Integer(value = 789)), + ), + ), + ) + } + + @Test + fun evaluate_expectAnswerNotEqualToValue_noAnswer_shouldReturnFalse() { + assertFalse( + evaluateEnableWhen( + behavior = null, + EnableWhen( + operator = Questionnaire.QuestionnaireItemOperator.NotEqualTo, + expected = Integer(value = 123), + actual = listOf(), + ), + ), + ) + } + + @Test + fun evaluate_expectAnswerNotEqualToValue_someAnswerNotEqualToValue_shouldReturnTrue() { + assertTrue( + evaluateEnableWhen( + behavior = null, + EnableWhen( + operator = Questionnaire.QuestionnaireItemOperator.NotEqualTo, + expected = Integer(value = 123), + actual = listOf(Integer(value = 123), Integer(value = 456)), + ), + ), + ) + } + + @Test + fun evaluate_expectAnswerNotEqualToValue_noAnswerNotEqualToValue_shouldReturnFalse() { + assertFalse( + evaluateEnableWhen( + behavior = null, + EnableWhen( + operator = Questionnaire.QuestionnaireItemOperator.NotEqualTo, + expected = Integer(value = 123), + actual = listOf(Integer(value = 123), Integer(value = 123)), + ), + ), + ) + } + + @Test + fun evaluate_expectAnswerGreaterThanValue_someAnswerGreaterThanValue_shouldReturnTrue() { + assertTrue( + evaluateEnableWhen( + behavior = null, + EnableWhen( + operator = Questionnaire.QuestionnaireItemOperator.GreaterThan, + expected = Integer(value = 10), + actual = listOf(Integer(value = 20)), + ), + ), + ) + } + + @Test + fun evaluate_expectAnswerGreaterThanValue_noAnswerGreaterThanValue_shouldReturnFalse() { + assertFalse( + evaluateEnableWhen( + behavior = null, + EnableWhen( + operator = Questionnaire.QuestionnaireItemOperator.GreaterThan, + expected = Integer(value = 10), + actual = listOf(Integer(value = 5)), + ), + ), + ) + } + + @Test + fun evaluate_expectAnswerGreaterThanOrEqualToToValue_someAnswerGreaterThanOrEqualToToValue_shouldReturnTrue() { + assertTrue( + evaluateEnableWhen( + behavior = null, + EnableWhen( + operator = Questionnaire.QuestionnaireItemOperator.GreaterThanOrEqualTo, + expected = Integer(value = 10), + actual = listOf(Integer(value = 10)), + ), + ), + ) + } + + @Test + fun evaluate_expectAnswerGreaterThanOrEqualToToValue_noAnswerGreaterThanOrEqualToToValue_shouldReturnFalse() { + assertFalse( + evaluateEnableWhen( + behavior = null, + EnableWhen( + operator = Questionnaire.QuestionnaireItemOperator.GreaterThanOrEqualTo, + expected = Integer(value = 10), + actual = listOf(Integer(value = 5)), + ), + ), + ) + } + + @Test + fun evaluate_expectAnswerLessThanValue_someAnswerLessThanValue_shouldReturnTrue() { + assertTrue( + evaluateEnableWhen( + behavior = null, + EnableWhen( + operator = Questionnaire.QuestionnaireItemOperator.LessThan, + expected = Integer(value = 10), + actual = listOf(Integer(value = 5)), + ), + ), + ) + } + + @Test + fun evaluate_expectAnswerLessThanValue_noAnswerLessThanValue_shouldReturnFalse() { + assertFalse( + evaluateEnableWhen( + behavior = null, + EnableWhen( + operator = Questionnaire.QuestionnaireItemOperator.LessThan, + expected = Integer(value = 10), + actual = listOf(Integer(value = 20)), + ), + ), + ) + } + + @Test + fun evaluate_expectAnswerLessThanOrEqualToToValue_someAnswerLessThanOrEqualToToValue_shouldReturnTrue() { + assertTrue( + evaluateEnableWhen( + behavior = null, + EnableWhen( + operator = Questionnaire.QuestionnaireItemOperator.LessThanOrEqualTo, + expected = Integer(value = 10), + actual = listOf(Integer(value = 10)), + ), + ), + ) + } + + @Test + fun evaluate_expectAnswerLessThanOrEqualToToValue_noAnswerLessThanOrEqualToToValue_shouldReturnFalse() { + assertFalse( + evaluateEnableWhen( + behavior = null, + EnableWhen( + operator = Questionnaire.QuestionnaireItemOperator.LessThanOrEqualTo, + expected = Integer(value = 10), + actual = listOf(Integer(value = 20)), + ), + ), + ) + } + + @Test + fun evaluate_multipleEnableWhens_behaviorAny_noneSatisfied_shouldReturnFalse() { + assertFalse( + evaluateEnableWhen( + behavior = Questionnaire.EnableWhenBehavior.Any, + EnableWhen( + operator = Questionnaire.QuestionnaireItemOperator.Exists, + expected = Boolean(value = true), + actual = listOf(), + ), + EnableWhen( + operator = Questionnaire.QuestionnaireItemOperator.Exists, + expected = Boolean(value = true), + actual = listOf(), + ), + ), + ) + } + + @Test + fun evaluate_multipleEnableWhens_behaviorAny_someSatisfied_shouldReturnTrue() { + assertTrue( + evaluateEnableWhen( + behavior = Questionnaire.EnableWhenBehavior.Any, + EnableWhen( + operator = Questionnaire.QuestionnaireItemOperator.Exists, + expected = Boolean(value = false), + actual = listOf(), + ), + EnableWhen( + operator = Questionnaire.QuestionnaireItemOperator.Exists, + expected = Boolean(value = true), + actual = listOf(), + ), + ), + ) + } + + @Test + fun evaluate_multipleEnableWhens_behaviorAll_someSatisfied_shouldReturnFalse() { + assertFalse( + evaluateEnableWhen( + behavior = Questionnaire.EnableWhenBehavior.All, + EnableWhen( + operator = Questionnaire.QuestionnaireItemOperator.Exists, + expected = Boolean(value = false), + actual = listOf(), + ), + EnableWhen( + operator = Questionnaire.QuestionnaireItemOperator.Exists, + expected = Boolean(value = true), + actual = listOf(), + ), + ), + ) + } + + @Test + fun evaluate_multipleEnableWhens_behaviorAll_allSatisfied_shouldReturnTrue() { + assertTrue( + evaluateEnableWhen( + behavior = Questionnaire.EnableWhenBehavior.All, + EnableWhen( + operator = Questionnaire.QuestionnaireItemOperator.Exists, + expected = FhirR4Boolean(value = false), + actual = listOf(), + ), + EnableWhen( + operator = Questionnaire.QuestionnaireItemOperator.Exists, + expected = FhirR4Boolean(value = false), + actual = listOf(), + ), + ), + ) + } + + @Test + fun evaluate_primitiveType_EqualTo_shouldReturnTrue() { + assertTrue( + evaluateEnableWhen( + behavior = null, + EnableWhen( + operator = Questionnaire.QuestionnaireItemOperator.EqualTo, + expected = Integer(value = 123), + actual = listOf(Integer(value = 123)), + ), + ), + ) + } + + @Test + fun evaluate_primitiveType_EqualTo_shouldReturnFalse() { + assertFalse( + evaluateEnableWhen( + behavior = null, + EnableWhen( + operator = Questionnaire.QuestionnaireItemOperator.EqualTo, + expected = Integer(value = 123), + actual = listOf(Integer(value = 456)), + ), + ), + ) + } + + @Test + fun evaluate_primitiveType_notEqualTo_shouldReturnTrue() { + assertTrue( + evaluateEnableWhen( + behavior = null, + EnableWhen( + operator = Questionnaire.QuestionnaireItemOperator.NotEqualTo, + expected = Integer(value = 123), + actual = listOf(Integer(value = 456)), + ), + ), + ) + } + + @Test + fun evaluate_primitiveType_notEqualTo_shouldReturnFalse() { + assertFalse( + evaluateEnableWhen( + behavior = null, + EnableWhen( + operator = Questionnaire.QuestionnaireItemOperator.NotEqualTo, + expected = Integer(value = 123), + actual = listOf(Integer(value = 123)), + ), + ), + ) + } + + @Test + fun evaluate_codingType_EqualTo_differentSystem_shouldReturnFalse() { + assertFalse( + evaluateEnableWhen( + behavior = null, + EnableWhen( + operator = Questionnaire.QuestionnaireItemOperator.EqualTo, + expected = + Coding( + system = Uri(value = "system"), + code = Code(value = "code"), + display = String(value = "display"), + ), + actual = + listOf( + Coding( + system = Uri(value = "otherSystem"), + code = Code(value = "code"), + display = String(value = "display"), + ), + ), + ), + ), + ) + } + + @Test + fun evaluate_codingType_EqualTo_differentCode_shouldReturnFalse() { + assertFalse( + evaluateEnableWhen( + behavior = null, + EnableWhen( + operator = Questionnaire.QuestionnaireItemOperator.EqualTo, + expected = + Coding( + system = Uri(value = "system"), + code = Code(value = "code"), + display = String(value = "display"), + ), + actual = + listOf( + Coding( + system = Uri(value = "system"), + code = Code(value = "otherCode"), + display = String(value = "display"), + ), + ), + ), + ), + ) + } + + @Test + fun evaluate_codingType_EqualTo_differentDisplay_shouldReturnTrue() { + assertTrue( + evaluateEnableWhen( + behavior = null, + EnableWhen( + operator = Questionnaire.QuestionnaireItemOperator.EqualTo, + expected = + Coding( + system = Uri(value = "system"), + code = Code(value = "code"), + display = String(value = "display"), + ), + actual = + listOf( + Coding( + system = Uri(value = "system"), + code = Code(value = "code"), + display = String(value = "otherDisplay"), + ), + ), + ), + ), + ) + } + + @Test + fun evaluate_codingType_notEqualTo_differentSystem_shouldReturnTrue() { + assertTrue( + evaluateEnableWhen( + behavior = null, + EnableWhen( + operator = Questionnaire.QuestionnaireItemOperator.NotEqualTo, + expected = + Coding( + system = Uri(value = "system"), + code = Code(value = "code"), + display = String(value = "display"), + ), + actual = + listOf( + Coding( + system = Uri(value = "otherSystem"), + code = Code(value = "code"), + display = String(value = "display"), + ), + ), + ), + ), + ) + } + + @Test + fun evaluate_codingType_notEqualTo_differentCode_shouldReturnTrue() { + assertTrue( + evaluateEnableWhen( + behavior = null, + EnableWhen( + operator = Questionnaire.QuestionnaireItemOperator.NotEqualTo, + expected = + Coding( + system = Uri(value = "system"), + code = Code(value = "code"), + display = String(value = "display"), + ), + actual = + listOf( + Coding( + system = Uri(value = "system"), + code = Code(value = "otherCode"), + display = String(value = "display"), + ), + ), + ), + ), + ) + } + + @Test + fun evaluate_codingType_notEqualTo_differentDisplay_shouldReturnFalse() { + assertFalse( + evaluateEnableWhen( + behavior = null, + EnableWhen( + operator = Questionnaire.QuestionnaireItemOperator.NotEqualTo, + expected = + Coding( + system = Uri(value = "system"), + code = Code(value = "code"), + display = String(value = "display"), + ), + actual = + listOf( + Coding( + system = Uri(value = "system"), + code = Code(value = "code"), + display = String(value = "otherDisplay"), + ), + ), + ), + ), + ) + } + + private fun evaluateEnableWhen( + behavior: Questionnaire.EnableWhenBehavior? = null, + vararg enableWhen: EnableWhen, + ): kotlin.Boolean { + val theLinkId = "target" + val questionnaireItemBuilder = + Questionnaire.Item.Builder( + linkId = String.Builder().apply { value = theLinkId }, + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Boolean), + ) + .apply { + this.enableWhen = + enableWhen.mapIndexedTo(mutableListOf()) { index, enableWhen -> + Questionnaire.Item.EnableWhen.Builder( + question = String.Builder().apply { value = "$index" }, + operator = Enumeration(value = enableWhen.operator), + answer = + when (enableWhen.expected) { + is Boolean -> + Questionnaire.Item.EnableWhen.Answer.Boolean(value = enableWhen.expected) + is Decimal -> + Questionnaire.Item.EnableWhen.Answer.Decimal(value = enableWhen.expected) + is Integer -> + Questionnaire.Item.EnableWhen.Answer.Integer(value = enableWhen.expected) + is Date -> + Questionnaire.Item.EnableWhen.Answer.Date(value = enableWhen.expected) + is DateTime -> + Questionnaire.Item.EnableWhen.Answer.DateTime(value = enableWhen.expected) + is Time -> + Questionnaire.Item.EnableWhen.Answer.Time(value = enableWhen.expected) + is String -> + Questionnaire.Item.EnableWhen.Answer.String(value = enableWhen.expected) + is Coding -> + Questionnaire.Item.EnableWhen.Answer.Coding(value = enableWhen.expected) + is Quantity -> + Questionnaire.Item.EnableWhen.Answer.Quantity(value = enableWhen.expected) + is Reference -> + Questionnaire.Item.EnableWhen.Answer.Reference(value = enableWhen.expected) + else -> + throw IllegalStateException("Type not supported for ${enableWhen.expected}") + }, + ) + } + this.enableBehavior = Enumeration(value = behavior) + } + val questionnaire = + Questionnaire.Builder(status = Enumeration(value = PublicationStatus.Active)) + .apply { item = mutableListOf(questionnaireItemBuilder) } + .build() + + val targetResponseItem = + QuestionnaireResponse.Item.Builder(linkId = String.Builder().apply { value = theLinkId }) + + val questionnaireResponse = + QuestionnaireResponse.Builder( + status = Enumeration(value = QuestionnaireResponse.QuestionnaireResponseStatus.Completed), + ) + .apply { + this.item = + enableWhen + .mapIndexedTo(mutableListOf()) { index, enableWhen -> + QuestionnaireResponse.Item.Builder( + linkId = String.Builder().apply { value = "$index" }, + ) + .apply { + this.answer = + enableWhen.actual.mapTo(mutableListOf()) { + QuestionnaireResponse.Item.Answer.Builder().apply { + value = + when (it) { + is Boolean -> + QuestionnaireResponse.Item.Answer.Value.Boolean(value = it) + is Decimal -> + QuestionnaireResponse.Item.Answer.Value.Decimal(value = it) + is Integer -> + QuestionnaireResponse.Item.Answer.Value.Integer(value = it) + is Date -> QuestionnaireResponse.Item.Answer.Value.Date(value = it) + is DateTime -> + QuestionnaireResponse.Item.Answer.Value.DateTime(value = it) + is Time -> QuestionnaireResponse.Item.Answer.Value.Time(value = it) + is String -> + QuestionnaireResponse.Item.Answer.Value.String(value = it) + is Uri -> QuestionnaireResponse.Item.Answer.Value.Uri(value = it) + is Attachment -> + QuestionnaireResponse.Item.Answer.Value.Attachment(value = it) + is Coding -> + QuestionnaireResponse.Item.Answer.Value.Coding(value = it) + is Quantity -> + QuestionnaireResponse.Item.Answer.Value.Quantity(value = it) + is Reference -> + QuestionnaireResponse.Item.Answer.Value.Reference(value = it) + else -> null + } + } + } + } + } + .apply { add(targetResponseItem) } + } + .build() + + val result = + EnablementEvaluator(questionnaire, questionnaireResponse) + .evaluate(questionnaireItemBuilder.build(), targetResponseItem.build()) + return result + } + + private data class EnableWhen( + val operator: Questionnaire.QuestionnaireItemOperator, + val expected: Any, + val actual: List, + ) +} diff --git a/datacapture/src/commonTest/kotlin/dev/ohs/fhir/datacapture/extensions/MoreExpressionsTest.kt b/datacapture/src/commonTest/kotlin/dev/ohs/fhir/datacapture/extensions/MoreExpressionsTest.kt new file mode 100644 index 000000000..264c54f84 --- /dev/null +++ b/datacapture/src/commonTest/kotlin/dev/ohs/fhir/datacapture/extensions/MoreExpressionsTest.kt @@ -0,0 +1,62 @@ +/* + * Copyright 2022-2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.ohs.fhir.datacapture.extensions + +import com.google.fhir.model.r4.Enumeration +import com.google.fhir.model.r4.Expression +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class MoreExpressionsTest { + + @Test + fun `isXFhirQuery should return true`() { + val expression = + Expression( + language = Enumeration(value = Expression.ExpressionLanguage.Application_X_Fhir_Query), + ) + + assertTrue(expression.isXFhirQuery) + } + + @Test + fun `isXFhirQuery should return false`() { + val expression = + Expression(language = Enumeration(value = Expression.ExpressionLanguage.Text_Cql)) + + assertFalse(expression.isXFhirQuery) + } + + @Test + fun `isFhirPath should return true`() { + val expression = + Expression(language = Enumeration(value = Expression.ExpressionLanguage.Text_Fhirpath)) + + assertTrue(expression.isFhirPath) + } + + @Test + fun `isFhirPath should return false`() { + val expression = + Expression( + language = Enumeration(value = Expression.ExpressionLanguage.Application_X_Fhir_Query), + ) + + assertFalse(expression.isFhirPath) + } +} diff --git a/datacapture/src/commonTest/kotlin/dev/ohs/fhir/datacapture/validation/ConstraintItemExtensionValidatorTest.kt b/datacapture/src/commonTest/kotlin/dev/ohs/fhir/datacapture/validation/ConstraintItemExtensionValidatorTest.kt new file mode 100644 index 000000000..2f334b375 --- /dev/null +++ b/datacapture/src/commonTest/kotlin/dev/ohs/fhir/datacapture/validation/ConstraintItemExtensionValidatorTest.kt @@ -0,0 +1,183 @@ +/* + * Copyright 2022-2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.ohs.fhir.datacapture.validation + +import com.google.fhir.model.r4.Code +import com.google.fhir.model.r4.Enumeration +import com.google.fhir.model.r4.Extension +import com.google.fhir.model.r4.Questionnaire +import com.google.fhir.model.r4.QuestionnaireResponse +import com.google.fhir.model.r4.String as FhirString +import com.google.fhir.model.r4.terminologies.PublicationStatus +import dev.ohs.fhir.datacapture.fhirpath.ExpressionEvaluator +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlinx.coroutines.test.runTest + +class ConstraintItemExtensionValidatorTest { + + @Test + fun shouldReturnValidResultIfNoConstraintExtensionIsPresent() = runTest { + val questionnaireItem = + Questionnaire.Item.Builder( + linkId = FhirString.Builder().apply { value = "link-id" }, + type = + Enumeration( + value = Questionnaire.QuestionnaireItemType.String, + ), + ) + .build() + val questionnaireResponseItem = + QuestionnaireResponse.Item.Builder( + linkId = FhirString.Builder().apply { value = "link-id" }, + ) + .build() + + val questionnaire = + Questionnaire.Builder(status = Enumeration(value = PublicationStatus.Active)).build() + val questionnaireResponse = + QuestionnaireResponse.Builder( + status = + Enumeration(value = QuestionnaireResponse.QuestionnaireResponseStatus.In_Progress), + ) + .build() + + val expressionEvaluator = ExpressionEvaluator(questionnaire, questionnaireResponse) + val validator = ConstraintItemExtensionValidator(expressionEvaluator) + + val results = validator.validate(questionnaireItem, questionnaireResponseItem) + + assertTrue(results.isEmpty()) + } + + @Test + fun shouldReturnValidResultIfConstraintExpressionEvaluatesToTrue() = runTest { + val questionnaireItem = + Questionnaire.Item.Builder( + linkId = FhirString.Builder().apply { value = "link-id" }, + type = + Enumeration( + value = Questionnaire.QuestionnaireItemType.Integer, + ), + ) + .apply { + extension = + mutableListOf( + Extension.Builder( + url = "http://hl7.org/fhir/StructureDefinition/questionnaire-constraint", + ) + .apply { + extension = + mutableListOf( + Extension.Builder(url = "severity").apply { + value = Extension.Value.Code(value = Code(value = "error")) + }, + Extension.Builder(url = "expression").apply { + value = Extension.Value.String(value = FhirString(value = "true")) + }, + Extension.Builder(url = "human").apply { + value = Extension.Value.String(value = FhirString(value = "Error message")) + }, + ) + }, + ) + } + .build() + val questionnaireResponseItem = + QuestionnaireResponse.Item.Builder( + linkId = FhirString.Builder().apply { value = "link-id" }, + ) + .build() + + val questionnaire = + Questionnaire.Builder(status = Enumeration(value = PublicationStatus.Active)).build() + val questionnaireResponse = + QuestionnaireResponse.Builder( + status = + Enumeration(value = QuestionnaireResponse.QuestionnaireResponseStatus.In_Progress), + ) + .build() + + val expressionEvaluator = ExpressionEvaluator(questionnaire, questionnaireResponse) + val validator = ConstraintItemExtensionValidator(expressionEvaluator) + + val results = validator.validate(questionnaireItem, questionnaireResponseItem) + + assertEquals(1, results.size) + assertTrue(results[0].isValid) + } + + @Test + fun shouldReturnInvalidResultIfConstraintExpressionEvaluatesToFalse() = runTest { + val questionnaireItem = + Questionnaire.Item.Builder( + linkId = FhirString.Builder().apply { value = "link-id" }, + type = + Enumeration( + value = Questionnaire.QuestionnaireItemType.Integer, + ), + ) + .apply { + extension = + mutableListOf( + Extension.Builder( + url = "http://hl7.org/fhir/StructureDefinition/questionnaire-constraint", + ) + .apply { + extension = + mutableListOf( + Extension.Builder(url = "severity").apply { + value = Extension.Value.Code(value = Code(value = "error")) + }, + Extension.Builder(url = "expression").apply { + value = Extension.Value.String(value = FhirString(value = "false")) + }, + Extension.Builder(url = "human").apply { + value = Extension.Value.String(value = FhirString(value = "Error message")) + }, + ) + }, + ) + } + .build() + val questionnaireResponseItem = + QuestionnaireResponse.Item.Builder( + linkId = FhirString.Builder().apply { value = "link-id" }, + ) + .build() + + val questionnaire = + Questionnaire.Builder(status = Enumeration(value = PublicationStatus.Active)).build() + val questionnaireResponse = + QuestionnaireResponse.Builder( + status = + Enumeration(value = QuestionnaireResponse.QuestionnaireResponseStatus.In_Progress), + ) + .build() + + val expressionEvaluator = ExpressionEvaluator(questionnaire, questionnaireResponse) + val validator = ConstraintItemExtensionValidator(expressionEvaluator) + + val results = validator.validate(questionnaireItem, questionnaireResponseItem) + + assertEquals(1, results.size) + assertFalse(results[0].isValid) + assertEquals("Error message", results[0].errorMessage) + } +} diff --git a/datacapture/src/commonTest/kotlin/dev/ohs/fhir/datacapture/validation/MaxDecimalPlacesValidatorTest.kt b/datacapture/src/commonTest/kotlin/dev/ohs/fhir/datacapture/validation/MaxDecimalPlacesValidatorTest.kt new file mode 100644 index 000000000..aa1d91d07 --- /dev/null +++ b/datacapture/src/commonTest/kotlin/dev/ohs/fhir/datacapture/validation/MaxDecimalPlacesValidatorTest.kt @@ -0,0 +1,165 @@ +/* + * Copyright 2023-2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.ohs.fhir.datacapture.validation + +import com.google.fhir.model.r4.Decimal +import com.google.fhir.model.r4.Extension +import com.google.fhir.model.r4.Integer +import com.google.fhir.model.r4.Questionnaire +import com.google.fhir.model.r4.QuestionnaireResponse +import com.ionspin.kotlin.bignum.decimal.toBigDecimal +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlinx.coroutines.test.runTest + +class MaxDecimalPlacesValidatorTest { + + @Test + fun shouldReturnValidResultIfMaxDecimalPlacesExtensionIsNotPresent() = runTest { + val questionnaireItem = + Questionnaire.Item.Builder( + linkId = com.google.fhir.model.r4.String.Builder().apply { value = "link-id" }, + type = + com.google.fhir.model.r4.Enumeration( + value = Questionnaire.QuestionnaireItemType.Decimal, + ), + ) + .build() + val answer = + QuestionnaireResponse.Item.Answer.Builder() + .apply { + value = + QuestionnaireResponse.Item.Answer.Value.Decimal( + value = Decimal(value = "1.2345".toBigDecimal()), + ) + } + .build() + + val result = MaxDecimalPlacesValidator.validate(questionnaireItem, answer) { null } + + assertTrue(result.isValid) + } + + @Test + fun shouldReturnValidResultIfDecimalPlacesIsLessThanMaxDecimalPlaces() = runTest { + val questionnaireItem = + Questionnaire.Item.Builder( + linkId = com.google.fhir.model.r4.String.Builder().apply { value = "link-id" }, + type = + com.google.fhir.model.r4.Enumeration( + value = Questionnaire.QuestionnaireItemType.Decimal, + ), + ) + .apply { + extension = + mutableListOf( + Extension.Builder(url = "http://hl7.org/fhir/StructureDefinition/maxDecimalPlaces") + .apply { + value = + Extension.Value.Integer(value = Integer.Builder().apply { value = 3 }.build()) + }, + ) + } + .build() + val answer = + QuestionnaireResponse.Item.Answer.Builder() + .apply { + value = + QuestionnaireResponse.Item.Answer.Value.Decimal( + value = Decimal(value = "1.23".toBigDecimal()), + ) + } + .build() + + val result = MaxDecimalPlacesValidator.validate(questionnaireItem, answer) { null } + + assertTrue(result.isValid) + } + + @Test + fun shouldReturnValidResultIfDecimalPlacesIsEqualToMaxDecimalPlaces() = runTest { + val questionnaireItem = + Questionnaire.Item.Builder( + linkId = com.google.fhir.model.r4.String.Builder().apply { value = "link-id" }, + type = + com.google.fhir.model.r4.Enumeration( + value = Questionnaire.QuestionnaireItemType.Decimal, + ), + ) + .apply { + extension = + mutableListOf( + Extension.Builder(url = "http://hl7.org/fhir/StructureDefinition/maxDecimalPlaces") + .apply { + value = + Extension.Value.Integer(value = Integer.Builder().apply { value = 3 }.build()) + }, + ) + } + .build() + val answer = + QuestionnaireResponse.Item.Answer.Builder() + .apply { + value = + QuestionnaireResponse.Item.Answer.Value.Decimal( + value = Decimal(value = "1.234".toBigDecimal()), + ) + } + .build() + + val result = MaxDecimalPlacesValidator.validate(questionnaireItem, answer) { null } + + assertTrue(result.isValid) + } + + @Test + fun shouldReturnInvalidResultIfDecimalPlacesIsGreaterThanMaxDecimalPlaces() = runTest { + val questionnaireItem = + Questionnaire.Item.Builder( + linkId = com.google.fhir.model.r4.String.Builder().apply { value = "link-id" }, + type = + com.google.fhir.model.r4.Enumeration( + value = Questionnaire.QuestionnaireItemType.Decimal, + ), + ) + .apply { + extension = + mutableListOf( + Extension.Builder(url = "http://hl7.org/fhir/StructureDefinition/maxDecimalPlaces") + .apply { + value = + Extension.Value.Integer(value = Integer.Builder().apply { value = 3 }.build()) + }, + ) + } + .build() + val answer = + QuestionnaireResponse.Item.Answer.Builder() + .apply { + value = + QuestionnaireResponse.Item.Answer.Value.Decimal( + value = Decimal(value = "1.2345".toBigDecimal()), + ) + } + .build() + + val result = MaxDecimalPlacesValidator.validate(questionnaireItem, answer) { null } + + assertFalse(result.isValid) + } +} diff --git a/datacapture/src/commonTest/kotlin/dev/ohs/fhir/datacapture/validation/MaxLengthValidatorTest.kt b/datacapture/src/commonTest/kotlin/dev/ohs/fhir/datacapture/validation/MaxLengthValidatorTest.kt new file mode 100644 index 000000000..23367fdd7 --- /dev/null +++ b/datacapture/src/commonTest/kotlin/dev/ohs/fhir/datacapture/validation/MaxLengthValidatorTest.kt @@ -0,0 +1,135 @@ +/* + * Copyright 2022-2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.ohs.fhir.datacapture.validation + +import com.google.fhir.model.r4.Integer +import com.google.fhir.model.r4.Questionnaire +import com.google.fhir.model.r4.QuestionnaireResponse +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlinx.coroutines.test.runTest + +class MaxLengthValidatorTest { + + @Test + fun shouldReturnValidResultIfMaxLengthIsNull() = runTest { + val questionnaireItem = + Questionnaire.Item.Builder( + linkId = com.google.fhir.model.r4.String.Builder().apply { value = "link-id" }, + type = + com.google.fhir.model.r4.Enumeration( + value = Questionnaire.QuestionnaireItemType.String, + ), + ) + .build() + val answer = + QuestionnaireResponse.Item.Answer.Builder() + .apply { + value = + QuestionnaireResponse.Item.Answer.Value.String( + value = com.google.fhir.model.r4.String(value = "some answer"), + ) + } + .build() + + val result = MaxLengthValidator.validate(questionnaireItem, answer) { null } + + assertTrue(result.isValid) + } + + @Test + fun shouldReturnValidResultIfAnswerLengthIsLessThanMaxLength() = runTest { + val questionnaireItem = + Questionnaire.Item.Builder( + linkId = com.google.fhir.model.r4.String.Builder().apply { value = "link-id" }, + type = + com.google.fhir.model.r4.Enumeration( + value = Questionnaire.QuestionnaireItemType.String, + ), + ) + .apply { maxLength = Integer.Builder().apply { value = 10 } } + .build() + val answer = + QuestionnaireResponse.Item.Answer.Builder() + .apply { + value = + QuestionnaireResponse.Item.Answer.Value.String( + value = com.google.fhir.model.r4.String(value = "short"), + ) + } + .build() + + val result = MaxLengthValidator.validate(questionnaireItem, answer) { null } + + assertTrue(result.isValid) + } + + @Test + fun shouldReturnValidResultIfAnswerLengthIsEqualToMaxLength() = runTest { + val questionnaireItem = + Questionnaire.Item.Builder( + linkId = com.google.fhir.model.r4.String.Builder().apply { value = "link-id" }, + type = + com.google.fhir.model.r4.Enumeration( + value = Questionnaire.QuestionnaireItemType.String, + ), + ) + .apply { maxLength = Integer.Builder().apply { value = 10 } } + .build() + val answer = + QuestionnaireResponse.Item.Answer.Builder() + .apply { + value = + QuestionnaireResponse.Item.Answer.Value.String( + value = com.google.fhir.model.r4.String(value = "1234567890"), + ) + } + .build() + + val result = MaxLengthValidator.validate(questionnaireItem, answer) { null } + + assertTrue(result.isValid) + } + + @Test + fun shouldReturnInvalidResultIfAnswerLengthIsGreaterThanMaxLength() = runTest { + val questionnaireItem = + Questionnaire.Item.Builder( + linkId = com.google.fhir.model.r4.String.Builder().apply { value = "link-id" }, + type = + com.google.fhir.model.r4.Enumeration( + value = Questionnaire.QuestionnaireItemType.String, + ), + ) + .apply { maxLength = Integer.Builder().apply { value = 10 } } + .build() + val answer = + QuestionnaireResponse.Item.Answer.Builder() + .apply { + value = + QuestionnaireResponse.Item.Answer.Value.String( + value = com.google.fhir.model.r4.String(value = "very long answer"), + ) + } + .build() + + val result = MaxLengthValidator.validate(questionnaireItem, answer) { null } + + assertFalse(result.isValid) + } +} diff --git a/datacapture/src/commonTest/kotlin/dev/ohs/fhir/datacapture/validation/MaxValueValidatorTest.kt b/datacapture/src/commonTest/kotlin/dev/ohs/fhir/datacapture/validation/MaxValueValidatorTest.kt new file mode 100644 index 000000000..bd0a86663 --- /dev/null +++ b/datacapture/src/commonTest/kotlin/dev/ohs/fhir/datacapture/validation/MaxValueValidatorTest.kt @@ -0,0 +1,160 @@ +/* + * Copyright 2023-2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.ohs.fhir.datacapture.validation + +import com.google.fhir.model.r4.Extension +import com.google.fhir.model.r4.Integer +import com.google.fhir.model.r4.Questionnaire +import com.google.fhir.model.r4.QuestionnaireResponse +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlinx.coroutines.test.runTest + +class MaxValueValidatorTest { + + @Test + fun shouldReturnValidResultIfMaxValueExtensionIsNotPresent() = runTest { + val questionnaireItem = + Questionnaire.Item.Builder( + linkId = com.google.fhir.model.r4.String.Builder().apply { value = "link-id" }, + type = + com.google.fhir.model.r4.Enumeration( + value = Questionnaire.QuestionnaireItemType.Integer, + ), + ) + .build() + val answer = + QuestionnaireResponse.Item.Answer.Builder() + .apply { + value = + QuestionnaireResponse.Item.Answer.Value.Integer( + value = Integer(value = 10), + ) + } + .build() + + val result = MaxValueValidator.validate(questionnaireItem, answer) { null } + + assertTrue(result.isValid) + } + + @Test + fun shouldReturnValidResultIfAnswerValueIsLessThanMaxValue() = runTest { + val questionnaireItem = + Questionnaire.Item.Builder( + linkId = com.google.fhir.model.r4.String.Builder().apply { value = "link-id" }, + type = + com.google.fhir.model.r4.Enumeration( + value = Questionnaire.QuestionnaireItemType.Integer, + ), + ) + .apply { + extension = + mutableListOf( + Extension.Builder(url = MAX_VALUE_EXTENSION_URL).apply { + value = + Extension.Value.Integer(value = Integer.Builder().apply { value = 15 }.build()) + }, + ) + } + .build() + val answer = + QuestionnaireResponse.Item.Answer.Builder() + .apply { + value = + QuestionnaireResponse.Item.Answer.Value.Integer( + value = Integer(value = 10), + ) + } + .build() + + val result = MaxValueValidator.validate(questionnaireItem, answer) { null } + + assertTrue(result.isValid) + } + + @Test + fun shouldReturnValidResultIfAnswerValueIsEqualToMaxValue() = runTest { + val questionnaireItem = + Questionnaire.Item.Builder( + linkId = com.google.fhir.model.r4.String.Builder().apply { value = "link-id" }, + type = + com.google.fhir.model.r4.Enumeration( + value = Questionnaire.QuestionnaireItemType.Integer, + ), + ) + .apply { + extension = + mutableListOf( + Extension.Builder(url = MAX_VALUE_EXTENSION_URL).apply { + value = + Extension.Value.Integer(value = Integer.Builder().apply { value = 15 }.build()) + }, + ) + } + .build() + val answer = + QuestionnaireResponse.Item.Answer.Builder() + .apply { + value = + QuestionnaireResponse.Item.Answer.Value.Integer( + value = Integer(value = 15), + ) + } + .build() + + val result = MaxValueValidator.validate(questionnaireItem, answer) { null } + + assertTrue(result.isValid) + } + + @Test + fun shouldReturnInvalidResultIfAnswerValueIsGreaterThanMaxValue() = runTest { + val questionnaireItem = + Questionnaire.Item.Builder( + linkId = com.google.fhir.model.r4.String.Builder().apply { value = "link-id" }, + type = + com.google.fhir.model.r4.Enumeration( + value = Questionnaire.QuestionnaireItemType.Integer, + ), + ) + .apply { + extension = + mutableListOf( + Extension.Builder(url = MAX_VALUE_EXTENSION_URL).apply { + value = + Extension.Value.Integer(value = Integer.Builder().apply { value = 15 }.build()) + }, + ) + } + .build() + val answer = + QuestionnaireResponse.Item.Answer.Builder() + .apply { + value = + QuestionnaireResponse.Item.Answer.Value.Integer( + value = Integer(value = 20), + ) + } + .build() + + val result = MaxValueValidator.validate(questionnaireItem, answer) { null } + + assertFalse(result.isValid) + } +} diff --git a/datacapture/src/commonTest/kotlin/dev/ohs/fhir/datacapture/validation/MinLengthValidatorTest.kt b/datacapture/src/commonTest/kotlin/dev/ohs/fhir/datacapture/validation/MinLengthValidatorTest.kt new file mode 100644 index 000000000..59be3296a --- /dev/null +++ b/datacapture/src/commonTest/kotlin/dev/ohs/fhir/datacapture/validation/MinLengthValidatorTest.kt @@ -0,0 +1,160 @@ +/* + * Copyright 2023-2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.ohs.fhir.datacapture.validation + +import com.google.fhir.model.r4.Extension +import com.google.fhir.model.r4.Integer +import com.google.fhir.model.r4.Questionnaire +import com.google.fhir.model.r4.QuestionnaireResponse +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlinx.coroutines.test.runTest + +class MinLengthValidatorTest { + + @Test + fun shouldReturnValidResultIfMinLengthExtensionIsNotPresent() = runTest { + val questionnaireItem = + Questionnaire.Item.Builder( + linkId = com.google.fhir.model.r4.String.Builder().apply { value = "link-id" }, + type = + com.google.fhir.model.r4.Enumeration( + value = Questionnaire.QuestionnaireItemType.String, + ), + ) + .build() + val answer = + QuestionnaireResponse.Item.Answer.Builder() + .apply { + value = + QuestionnaireResponse.Item.Answer.Value.String( + value = com.google.fhir.model.r4.String(value = "some answer"), + ) + } + .build() + + val result = MinLengthValidator.validate(questionnaireItem, answer) { null } + + assertTrue(result.isValid) + } + + @Test + fun shouldReturnValidResultIfAnswerLengthIsGreaterThanMinLength() = runTest { + val questionnaireItem = + Questionnaire.Item.Builder( + linkId = com.google.fhir.model.r4.String.Builder().apply { value = "link-id" }, + type = + com.google.fhir.model.r4.Enumeration( + value = Questionnaire.QuestionnaireItemType.String, + ), + ) + .apply { + extension = + mutableListOf( + Extension.Builder(url = MIN_LENGTH_EXTENSION_URL).apply { + value = + Extension.Value.Integer(value = Integer.Builder().apply { value = 5 }.build()) + }, + ) + } + .build() + val answer = + QuestionnaireResponse.Item.Answer.Builder() + .apply { + value = + QuestionnaireResponse.Item.Answer.Value.String( + value = com.google.fhir.model.r4.String(value = "longer than 5"), + ) + } + .build() + + val result = MinLengthValidator.validate(questionnaireItem, answer) { null } + + assertTrue(result.isValid) + } + + @Test + fun shouldReturnValidResultIfAnswerLengthIsEqualToMinLength() = runTest { + val questionnaireItem = + Questionnaire.Item.Builder( + linkId = com.google.fhir.model.r4.String.Builder().apply { value = "link-id" }, + type = + com.google.fhir.model.r4.Enumeration( + value = Questionnaire.QuestionnaireItemType.String, + ), + ) + .apply { + extension = + mutableListOf( + Extension.Builder(url = MIN_LENGTH_EXTENSION_URL).apply { + value = + Extension.Value.Integer(value = Integer.Builder().apply { value = 5 }.build()) + }, + ) + } + .build() + val answer = + QuestionnaireResponse.Item.Answer.Builder() + .apply { + value = + QuestionnaireResponse.Item.Answer.Value.String( + value = com.google.fhir.model.r4.String(value = "12345"), + ) + } + .build() + + val result = MinLengthValidator.validate(questionnaireItem, answer) { null } + + assertTrue(result.isValid) + } + + @Test + fun shouldReturnInvalidResultIfAnswerLengthIsLessThanMinLength() = runTest { + val questionnaireItem = + Questionnaire.Item.Builder( + linkId = com.google.fhir.model.r4.String.Builder().apply { value = "link-id" }, + type = + com.google.fhir.model.r4.Enumeration( + value = Questionnaire.QuestionnaireItemType.String, + ), + ) + .apply { + extension = + mutableListOf( + Extension.Builder(url = MIN_LENGTH_EXTENSION_URL).apply { + value = + Extension.Value.Integer(value = Integer.Builder().apply { value = 5 }.build()) + }, + ) + } + .build() + val answer = + QuestionnaireResponse.Item.Answer.Builder() + .apply { + value = + QuestionnaireResponse.Item.Answer.Value.String( + value = com.google.fhir.model.r4.String(value = "123"), + ) + } + .build() + + val result = MinLengthValidator.validate(questionnaireItem, answer) { null } + + assertFalse(result.isValid) + } +} diff --git a/datacapture/src/commonTest/kotlin/dev/ohs/fhir/datacapture/validation/MinValueValidatorTest.kt b/datacapture/src/commonTest/kotlin/dev/ohs/fhir/datacapture/validation/MinValueValidatorTest.kt new file mode 100644 index 000000000..dfc64c8b9 --- /dev/null +++ b/datacapture/src/commonTest/kotlin/dev/ohs/fhir/datacapture/validation/MinValueValidatorTest.kt @@ -0,0 +1,160 @@ +/* + * Copyright 2023-2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.ohs.fhir.datacapture.validation + +import com.google.fhir.model.r4.Extension +import com.google.fhir.model.r4.Integer +import com.google.fhir.model.r4.Questionnaire +import com.google.fhir.model.r4.QuestionnaireResponse +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlinx.coroutines.test.runTest + +class MinValueValidatorTest { + + @Test + fun shouldReturnValidResultIfMinValueExtensionIsNotPresent() = runTest { + val questionnaireItem = + Questionnaire.Item.Builder( + linkId = com.google.fhir.model.r4.String.Builder().apply { value = "link-id" }, + type = + com.google.fhir.model.r4.Enumeration( + value = Questionnaire.QuestionnaireItemType.Integer, + ), + ) + .build() + val answer = + QuestionnaireResponse.Item.Answer.Builder() + .apply { + value = + QuestionnaireResponse.Item.Answer.Value.Integer( + value = Integer(value = 10), + ) + } + .build() + + val result = MinValueValidator.validate(questionnaireItem, answer) { null } + + assertTrue(result.isValid) + } + + @Test + fun shouldReturnValidResultIfAnswerValueIsGreaterThanMinValue() = runTest { + val questionnaireItem = + Questionnaire.Item.Builder( + linkId = com.google.fhir.model.r4.String.Builder().apply { value = "link-id" }, + type = + com.google.fhir.model.r4.Enumeration( + value = Questionnaire.QuestionnaireItemType.Integer, + ), + ) + .apply { + extension = + mutableListOf( + Extension.Builder(url = MIN_VALUE_EXTENSION_URL).apply { + value = + Extension.Value.Integer(value = Integer.Builder().apply { value = 5 }.build()) + }, + ) + } + .build() + val answer = + QuestionnaireResponse.Item.Answer.Builder() + .apply { + value = + QuestionnaireResponse.Item.Answer.Value.Integer( + value = Integer(value = 10), + ) + } + .build() + + val result = MinValueValidator.validate(questionnaireItem, answer) { null } + + assertTrue(result.isValid) + } + + @Test + fun shouldReturnValidResultIfAnswerValueIsEqualToMinValue() = runTest { + val questionnaireItem = + Questionnaire.Item.Builder( + linkId = com.google.fhir.model.r4.String.Builder().apply { value = "link-id" }, + type = + com.google.fhir.model.r4.Enumeration( + value = Questionnaire.QuestionnaireItemType.Integer, + ), + ) + .apply { + extension = + mutableListOf( + Extension.Builder(url = MIN_VALUE_EXTENSION_URL).apply { + value = + Extension.Value.Integer(value = Integer.Builder().apply { value = 5 }.build()) + }, + ) + } + .build() + val answer = + QuestionnaireResponse.Item.Answer.Builder() + .apply { + value = + QuestionnaireResponse.Item.Answer.Value.Integer( + value = Integer(value = 5), + ) + } + .build() + + val result = MinValueValidator.validate(questionnaireItem, answer) { null } + + assertTrue(result.isValid) + } + + @Test + fun shouldReturnInvalidResultIfAnswerValueIsLessThanMinValue() = runTest { + val questionnaireItem = + Questionnaire.Item.Builder( + linkId = com.google.fhir.model.r4.String.Builder().apply { value = "link-id" }, + type = + com.google.fhir.model.r4.Enumeration( + value = Questionnaire.QuestionnaireItemType.Integer, + ), + ) + .apply { + extension = + mutableListOf( + Extension.Builder(url = MIN_VALUE_EXTENSION_URL).apply { + value = + Extension.Value.Integer(value = Integer.Builder().apply { value = 5 }.build()) + }, + ) + } + .build() + val answer = + QuestionnaireResponse.Item.Answer.Builder() + .apply { + value = + QuestionnaireResponse.Item.Answer.Value.Integer( + value = Integer(value = 3), + ) + } + .build() + + val result = MinValueValidator.validate(questionnaireItem, answer) { null } + + assertFalse(result.isValid) + } +} diff --git a/datacapture/src/commonTest/kotlin/dev/ohs/fhir/datacapture/validation/QuestionnaireResponseItemValidatorTest.kt b/datacapture/src/commonTest/kotlin/dev/ohs/fhir/datacapture/validation/QuestionnaireResponseItemValidatorTest.kt new file mode 100644 index 000000000..2c2cd25f1 --- /dev/null +++ b/datacapture/src/commonTest/kotlin/dev/ohs/fhir/datacapture/validation/QuestionnaireResponseItemValidatorTest.kt @@ -0,0 +1,232 @@ +/* + * Copyright 2022-2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.ohs.fhir.datacapture.validation + +import com.google.fhir.model.r4.Boolean +import com.google.fhir.model.r4.Enumeration +import com.google.fhir.model.r4.Extension +import com.google.fhir.model.r4.Integer +import com.google.fhir.model.r4.Questionnaire +import com.google.fhir.model.r4.QuestionnaireResponse +import com.google.fhir.model.r4.String as FhirString +import com.google.fhir.model.r4.terminologies.PublicationStatus +import dev.ohs.fhir.datacapture.fhirpath.ExpressionEvaluator +import kotlin.test.Test +import kotlin.test.assertTrue +import kotlinx.coroutines.test.runTest + +class QuestionnaireResponseItemValidatorTest { + + @Test + fun shouldReturnValidResult() = runTest { + val questionnaireItem = + Questionnaire.Item.Builder( + linkId = FhirString.Builder().apply { value = "a-question" }, + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Integer), + ) + .apply { + extension = + mutableListOf( + Extension.Builder(url = MIN_VALUE_EXTENSION_URL).apply { + value = + Extension.Value.Integer(value = Integer.Builder().apply { value = 250 }.build()) + }, + Extension.Builder(url = MAX_VALUE_EXTENSION_URL).apply { + value = + Extension.Value.Integer(value = Integer.Builder().apply { value = 300 }.build()) + }, + ) + } + .build() + val answer = + QuestionnaireResponse.Item.Answer.Builder() + .apply { + value = + QuestionnaireResponse.Item.Answer.Value.Integer( + value = Integer.Builder().apply { value = 275 }.build(), + ) + } + .build() + val questionnaire = + Questionnaire.Builder(status = Enumeration(value = PublicationStatus.Active)) + .apply { this.item.add(questionnaireItem.toBuilder()) } + .build() + val questionnaireResponse = + QuestionnaireResponse.Builder( + status = Enumeration(value = QuestionnaireResponse.QuestionnaireResponseStatus.Completed), + ) + .apply { + this.item.add( + QuestionnaireResponse.Item.Builder( + linkId = FhirString.Builder().apply { value = "a-question" }, + ) + .apply { this.answer.add(answer.toBuilder()) }, + ) + } + .build() + val expressionEvaluator = + ExpressionEvaluator( + questionnaire, + questionnaireResponse, + ) + + val validationResult = + QuestionnaireResponseItemValidator(expressionEvaluator) + .validate( + questionnaire.item.first(), + questionnaireResponse.item.first(), + ) + + assertTrue(validationResult is Valid) + } + + @Test + fun shouldValidateIndividualAnswersAndCombineResults() = runTest { + val questionnaireItem = + Questionnaire.Item.Builder( + linkId = FhirString.Builder().apply { value = "a-question" }, + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Integer), + ) + .apply { + extension = + mutableListOf( + Extension.Builder(url = MIN_VALUE_EXTENSION_URL).apply { + value = + Extension.Value.Integer(value = Integer.Builder().apply { value = 100 }.build()) + }, + Extension.Builder(url = MAX_VALUE_EXTENSION_URL).apply { + value = + Extension.Value.Integer(value = Integer.Builder().apply { value = 200 }.build()) + }, + ) + } + .build() + val answers = + listOf( + QuestionnaireResponse.Item.Answer.Builder() + .apply { + value = + QuestionnaireResponse.Item.Answer.Value.Integer( + value = Integer.Builder().apply { value = 50 }.build(), + ) + } + .build(), + QuestionnaireResponse.Item.Answer.Builder() + .apply { + value = + QuestionnaireResponse.Item.Answer.Value.Integer( + value = Integer.Builder().apply { value = 150 }.build(), + ) + } + .build(), + QuestionnaireResponse.Item.Answer.Builder() + .apply { + value = + QuestionnaireResponse.Item.Answer.Value.Integer( + value = Integer.Builder().apply { value = 250 }.build(), + ) + } + .build(), + ) + val questionnaire = + Questionnaire.Builder(status = Enumeration(PublicationStatus.Active.getCode())) + .apply { this.item.add(questionnaireItem.toBuilder()) } + .build() + val questionnaireResponse = + QuestionnaireResponse.Builder( + status = + Enumeration(QuestionnaireResponse.QuestionnaireResponseStatus.Completed.getCode()), + ) + .apply { + this.item.add( + QuestionnaireResponse.Item.Builder( + linkId = FhirString.Builder().apply { value = "a-question" }, + ) + .apply { answers.forEach { this.answer.add(it.toBuilder()) } }, + ) + } + .build() + val expressionEvaluator = + ExpressionEvaluator( + questionnaire, + questionnaireResponse, + ) + + val validationResult = + QuestionnaireResponseItemValidator(expressionEvaluator) + .validate( + questionnaire.item.first(), + questionnaireResponse.item.first(), + ) + + assertTrue(validationResult is Invalid) + assertTrue( + validationResult.singleStringValidationMessage.contains("Minimum value allowed is:100"), + ) + assertTrue( + validationResult.singleStringValidationMessage.contains("Maximum value allowed is:200"), + ) + } + + @Test + fun shouldValidateAllAnswers() = runTest { + val questionnaireItem = + Questionnaire.Item.Builder( + linkId = FhirString.Builder().apply { value = "a-question" }, + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Integer), + ) + .apply { required = Boolean.Builder().apply { value = true } } + .build() + val questionnaire = + Questionnaire.Builder(status = Enumeration(PublicationStatus.Active.getCode())) + .apply { this.item.add(questionnaireItem.toBuilder()) } + .build() + val questionnaireResponse = + QuestionnaireResponse.Builder( + status = + Enumeration(QuestionnaireResponse.QuestionnaireResponseStatus.Completed.getCode()), + ) + .apply { + this.item.add( + QuestionnaireResponse.Item.Builder( + linkId = FhirString.Builder().apply { value = "a-question" }, + ), + ) + } + .build() + + val expressionEvaluator = + ExpressionEvaluator( + questionnaire, + questionnaireResponse, + ) + + val validationResult = + QuestionnaireResponseItemValidator(expressionEvaluator) + .validate( + questionnaire.item.first(), + questionnaireResponse.item.first(), + ) + + assertTrue(validationResult is Invalid) + assertTrue( + validationResult.singleStringValidationMessage.contains( + "Missing answer for required field.", + ), + ) + } +} diff --git a/datacapture/src/commonTest/kotlin/dev/ohs/fhir/datacapture/validation/QuestionnaireResponseValidatorTest.kt b/datacapture/src/commonTest/kotlin/dev/ohs/fhir/datacapture/validation/QuestionnaireResponseValidatorTest.kt new file mode 100644 index 000000000..6718bf0a9 --- /dev/null +++ b/datacapture/src/commonTest/kotlin/dev/ohs/fhir/datacapture/validation/QuestionnaireResponseValidatorTest.kt @@ -0,0 +1,117 @@ +/* + * Copyright 2022-2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.ohs.fhir.datacapture.validation + +import com.google.fhir.model.r4.Enumeration +import com.google.fhir.model.r4.Questionnaire +import com.google.fhir.model.r4.QuestionnaireResponse +import com.google.fhir.model.r4.String as FhirString +import com.google.fhir.model.r4.terminologies.PublicationStatus +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlinx.coroutines.test.runTest + +class QuestionnaireResponseValidatorTest { + + @Test + fun shouldReturnValidResults() = runTest { + val questionnaireItem = + Questionnaire.Item.Builder( + linkId = FhirString.Builder().apply { value = "a-question" }, + type = Enumeration(value = Questionnaire.QuestionnaireItemType.String), + ) + .build() + val questionnaire = + Questionnaire.Builder(status = Enumeration(PublicationStatus.Active.getCode())) + .apply { this.item.add(questionnaireItem.toBuilder()) } + .build() + val questionnaireResponse = + QuestionnaireResponse.Builder( + status = + Enumeration(QuestionnaireResponse.QuestionnaireResponseStatus.Completed.getCode()), + ) + .apply { + this.item.add( + QuestionnaireResponse.Item.Builder( + linkId = FhirString.Builder().apply { value = "a-question" }, + ) + .apply { + this.answer.add( + QuestionnaireResponse.Item.Answer.Builder().apply { + value = + QuestionnaireResponse.Item.Answer.Value.String( + value = FhirString(value = "some answer"), + ) + }, + ) + }, + ) + } + .build() + + val validationResults = + QuestionnaireResponseValidator.validateQuestionnaireResponse( + questionnaire, + questionnaireResponse, + ) + + assertTrue(validationResults.values.flatten().all { it is Valid }) + } + + @Test + fun shouldReturnInvalidResultsForMissingRequiredField() = runTest { + val questionnaireItem = + Questionnaire.Item.Builder( + linkId = FhirString.Builder().apply { value = "a-question" }, + type = Enumeration(value = Questionnaire.QuestionnaireItemType.String), + ) + .apply { required = com.google.fhir.model.r4.Boolean.Builder().apply { value = true } } + .build() + val questionnaire = + Questionnaire.Builder(status = Enumeration(PublicationStatus.Active.getCode())) + .apply { this.item.add(questionnaireItem.toBuilder()) } + .build() + val questionnaireResponse = + QuestionnaireResponse.Builder( + status = + Enumeration(QuestionnaireResponse.QuestionnaireResponseStatus.Completed.getCode()), + ) + .apply { + this.item.add( + QuestionnaireResponse.Item.Builder( + linkId = FhirString.Builder().apply { value = "a-question" }, + ), + ) + } + .build() + + val validationResults = + QuestionnaireResponseValidator.validateQuestionnaireResponse( + questionnaire, + questionnaireResponse, + ) + + assertEquals(1, validationResults.size) + val result = validationResults["a-question"] + assertTrue(result!![0] is Invalid) + assertEquals( + "Missing answer for required field.", + (result[0] as Invalid).singleStringValidationMessage, + ) + } +} diff --git a/datacapture/src/commonTest/kotlin/dev/ohs/fhir/datacapture/validation/RegexValidatorTest.kt b/datacapture/src/commonTest/kotlin/dev/ohs/fhir/datacapture/validation/RegexValidatorTest.kt new file mode 100644 index 000000000..462d2cda4 --- /dev/null +++ b/datacapture/src/commonTest/kotlin/dev/ohs/fhir/datacapture/validation/RegexValidatorTest.kt @@ -0,0 +1,129 @@ +/* + * Copyright 2023-2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.ohs.fhir.datacapture.validation + +import com.google.fhir.model.r4.Extension +import com.google.fhir.model.r4.Questionnaire +import com.google.fhir.model.r4.QuestionnaireResponse +import com.google.fhir.model.r4.String as FhirString +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlinx.coroutines.test.runTest + +class RegexValidatorTest { + + @Test + fun shouldReturnValidResultIfRegexExtensionIsNotPresent() = runTest { + val questionnaireItem = + Questionnaire.Item.Builder( + linkId = FhirString.Builder().apply { value = "link-id" }, + type = + com.google.fhir.model.r4.Enumeration( + value = Questionnaire.QuestionnaireItemType.String, + ), + ) + .build() + val answer = + QuestionnaireResponse.Item.Answer.Builder() + .apply { + value = + QuestionnaireResponse.Item.Answer.Value.String( + value = FhirString(value = "some answer"), + ) + } + .build() + + val result = RegexValidator.validate(questionnaireItem, answer) { null } + + assertTrue(result.isValid) + } + + @Test + fun shouldReturnValidResultIfAnswerMatchesRegex() = runTest { + val questionnaireItem = + Questionnaire.Item.Builder( + linkId = FhirString.Builder().apply { value = "link-id" }, + type = + com.google.fhir.model.r4.Enumeration( + value = Questionnaire.QuestionnaireItemType.String, + ), + ) + .apply { + extension = + mutableListOf( + Extension.Builder(url = REGEX_EXTENSION_URL).apply { + value = + Extension.Value.String( + value = FhirString.Builder().apply { value = "^[0-9]+$" }.build(), + ) + }, + ) + } + .build() + val answer = + QuestionnaireResponse.Item.Answer.Builder() + .apply { + value = + QuestionnaireResponse.Item.Answer.Value.String( + value = FhirString(value = "12345"), + ) + } + .build() + + val result = RegexValidator.validate(questionnaireItem, answer) { null } + + assertTrue(result.isValid) + } + + @Test + fun shouldReturnInvalidResultIfAnswerDoesNotMatchRegex() = runTest { + val questionnaireItem = + Questionnaire.Item.Builder( + linkId = FhirString.Builder().apply { value = "link-id" }, + type = + com.google.fhir.model.r4.Enumeration( + value = Questionnaire.QuestionnaireItemType.String, + ), + ) + .apply { + extension = + mutableListOf( + Extension.Builder(url = REGEX_EXTENSION_URL).apply { + value = + Extension.Value.String( + value = FhirString.Builder().apply { value = "^[0-9]+$" }.build(), + ) + }, + ) + } + .build() + val answer = + QuestionnaireResponse.Item.Answer.Builder() + .apply { + value = + QuestionnaireResponse.Item.Answer.Value.String( + value = FhirString(value = "123a45"), + ) + } + .build() + + val result = RegexValidator.validate(questionnaireItem, answer) { null } + + assertFalse(result.isValid) + } +} diff --git a/datacapture/src/commonTest/kotlin/dev/ohs/fhir/datacapture/validation/RequiredValidatorTest.kt b/datacapture/src/commonTest/kotlin/dev/ohs/fhir/datacapture/validation/RequiredValidatorTest.kt new file mode 100644 index 000000000..957b6a789 --- /dev/null +++ b/datacapture/src/commonTest/kotlin/dev/ohs/fhir/datacapture/validation/RequiredValidatorTest.kt @@ -0,0 +1,108 @@ +/* + * Copyright 2022-2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.ohs.fhir.datacapture.validation + +import com.google.fhir.model.r4.Boolean +import com.google.fhir.model.r4.Questionnaire +import com.google.fhir.model.r4.QuestionnaireResponse +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlinx.coroutines.test.runTest + +class RequiredValidatorTest { + + @Test + fun shouldReturnValidResultIfItemIsNotRequired() = runTest { + val questionnaireItem = + Questionnaire.Item.Builder( + linkId = com.google.fhir.model.r4.String.Builder().apply { value = "link-id" }, + type = + com.google.fhir.model.r4.Enumeration( + value = Questionnaire.QuestionnaireItemType.String, + ), + ) + .apply { required = Boolean.Builder().apply { value = false } } + .build() + val questionnaireResponseItem = + QuestionnaireResponse.Item.Builder( + linkId = com.google.fhir.model.r4.String.Builder().apply { value = "link-id" }, + ) + .build() + + val result = RequiredValidator.validate(questionnaireItem, questionnaireResponseItem) + + assertTrue(result[0].isValid) + } + + @Test + fun shouldReturnValidResultIfItemIsRequiredAndHasAnswer() = runTest { + val questionnaireItem = + Questionnaire.Item.Builder( + linkId = com.google.fhir.model.r4.String.Builder().apply { value = "link-id" }, + type = + com.google.fhir.model.r4.Enumeration( + value = Questionnaire.QuestionnaireItemType.String, + ), + ) + .apply { required = Boolean.Builder().apply { value = true } } + .build() + val questionnaireResponseItem = + QuestionnaireResponse.Item.Builder( + linkId = com.google.fhir.model.r4.String.Builder().apply { value = "link-id" }, + ) + .apply { + answer = + mutableListOf( + QuestionnaireResponse.Item.Answer.Builder().apply { + value = + QuestionnaireResponse.Item.Answer.Value.String( + value = com.google.fhir.model.r4.String(value = "some answer"), + ) + }, + ) + } + .build() + + val result = RequiredValidator.validate(questionnaireItem, questionnaireResponseItem) + + assertTrue(result[0].isValid) + } + + @Test + fun shouldReturnInvalidResultIfItemIsRequiredAndHasNoAnswer() = runTest { + val questionnaireItem = + Questionnaire.Item.Builder( + linkId = com.google.fhir.model.r4.String.Builder().apply { value = "link-id" }, + type = + com.google.fhir.model.r4.Enumeration( + value = Questionnaire.QuestionnaireItemType.String, + ), + ) + .apply { required = Boolean.Builder().apply { value = true } } + .build() + val questionnaireResponseItem = + QuestionnaireResponse.Item.Builder( + linkId = com.google.fhir.model.r4.String.Builder().apply { value = "link-id" }, + ) + .build() + + val result = RequiredValidator.validate(questionnaireItem, questionnaireResponseItem) + + assertFalse(result[0].isValid) + } +} diff --git a/datacapture/src/commonTest/kotlin/dev/ohs/fhir/datacapture/views/components/RepeatedGroupAddButtonItemTest.kt b/datacapture/src/commonTest/kotlin/dev/ohs/fhir/datacapture/views/components/RepeatedGroupAddButtonItemTest.kt new file mode 100644 index 000000000..5e6613d80 --- /dev/null +++ b/datacapture/src/commonTest/kotlin/dev/ohs/fhir/datacapture/views/components/RepeatedGroupAddButtonItemTest.kt @@ -0,0 +1,92 @@ +/* + * Copyright 2025-2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.ohs.fhir.datacapture.views.components + +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.runComposeUiTest +import com.google.fhir.model.r4.Enumeration +import com.google.fhir.model.r4.Questionnaire +import com.google.fhir.model.r4.QuestionnaireResponse +import dev.ohs.fhir.datacapture.extensions.FhirR4Boolean +import dev.ohs.fhir.datacapture.extensions.FhirR4String +import dev.ohs.fhir.datacapture.validation.NotValidated +import dev.ohs.fhir.datacapture.views.QuestionnaireViewItem +import kotlin.test.Test + +@OptIn(ExperimentalTestApi::class) +class RepeatedGroupAddButtonItemTest { + + @Test + fun testRepeatedGroupIsReadOnlyDisablesAddButton() = runComposeUiTest { + val viewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "repeated-group-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Group), + readOnly = FhirR4Boolean(value = true), + repeats = FhirR4Boolean(value = true), + text = FhirR4String(value = "Question?"), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "repeated-group-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + + setContent { RepeatedGroupAddButtonItem(viewItem) } + onNodeWithTag(ADD_REPEATED_GROUP_BUTTON_TAG).assertIsNotEnabled() + } + + @Test + fun repeatingGroup_shouldHaveAddItemButtonVisible() = runComposeUiTest { + val viewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "repeated-group-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Group), + repeats = FhirR4Boolean(value = true), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "repeated-group-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + setContent { RepeatedGroupAddButtonItem(viewItem) } + onNodeWithTag(ADD_REPEATED_GROUP_BUTTON_TAG).assertIsDisplayed() + } + + @Test + fun testRepeatedGroupIsNotReadOnlyEnablesAddButton() = runComposeUiTest { + val viewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "repeated-group-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Group), + readOnly = FhirR4Boolean(value = false), + repeats = FhirR4Boolean(value = true), + text = FhirR4String(value = "Question?"), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "repeated-group-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + setContent { RepeatedGroupAddButtonItem(viewItem) } + onNodeWithTag(ADD_REPEATED_GROUP_BUTTON_TAG).assertIsEnabled() + } +} diff --git a/datacapture/src/commonTest/kotlin/dev/ohs/fhir/datacapture/views/factories/AttachmentViewFactoryTest.kt b/datacapture/src/commonTest/kotlin/dev/ohs/fhir/datacapture/views/factories/AttachmentViewFactoryTest.kt new file mode 100644 index 000000000..7fa35a34f --- /dev/null +++ b/datacapture/src/commonTest/kotlin/dev/ohs/fhir/datacapture/views/factories/AttachmentViewFactoryTest.kt @@ -0,0 +1,615 @@ +/* + * Copyright 2023-2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.ohs.fhir.datacapture.views.factories + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.runComposeUiTest +import com.google.fhir.model.r4.Attachment +import com.google.fhir.model.r4.Base64Binary +import com.google.fhir.model.r4.Code +import com.google.fhir.model.r4.Enumeration +import com.google.fhir.model.r4.Extension +import com.google.fhir.model.r4.Questionnaire +import com.google.fhir.model.r4.QuestionnaireResponse +import dev.ohs.fhir.datacapture.extensions.FhirR4String +import dev.ohs.fhir.datacapture.validation.NotValidated +import dev.ohs.fhir.datacapture.views.QuestionnaireViewItem +import kotlin.test.Test + +@OptIn(ExperimentalTestApi::class) +class AttachmentViewFactoryTest { + + @Composable + fun QuestionnaireAttachmentView(questionnaireViewItem: QuestionnaireViewItem) { + AttachmentViewFactory.Content(questionnaireViewItem) + } + + @Test + fun shouldDisplayTakePhotoAndUploadPhotoButton() = runComposeUiTest { + val questionnaireItemView = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "attachment-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Attachment), + extension = + listOf( + Extension( + url = "http://hl7.org/fhir/StructureDefinition/mimeType", + value = Extension.Value.Code(value = Code(value = "image/*")), + ), + ), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "attachment-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + + setContent { QuestionnaireAttachmentView(questionnaireItemView) } + + onNodeWithTag(TAKE_PHOTO_BUTTON_TAG).assertIsDisplayed() + onNodeWithTag(UPLOAD_FILE_BUTTON_TAG).assertIsDisplayed() + onNodeWithTag(UPLOAD_FILE_BUTTON_TAG).assertTextEquals("Upload Photo") + } + + @Test + fun shouldDisplayUploadAudioButton() = runComposeUiTest { + val questionnaireItemView = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "attachment-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Attachment), + extension = + listOf( + Extension( + url = "http://hl7.org/fhir/StructureDefinition/mimeType", + value = Extension.Value.Code(value = Code(value = "audio/*")), + ), + ), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "attachment-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + + setContent { QuestionnaireAttachmentView(questionnaireItemView) } + + onNodeWithTag(UPLOAD_FILE_BUTTON_TAG).assertIsDisplayed() + onNodeWithTag(UPLOAD_FILE_BUTTON_TAG).assertTextEquals("Upload Audio") + } + + @Test + fun shouldDisplayUploadVideoButton() = runComposeUiTest { + val questionnaireItemView = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "attachment-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Attachment), + extension = + listOf( + Extension( + url = "http://hl7.org/fhir/StructureDefinition/mimeType", + value = Extension.Value.Code(value = Code(value = "video/*")), + ), + ), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "attachment-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + + setContent { QuestionnaireAttachmentView(questionnaireItemView) } + + onNodeWithTag(UPLOAD_FILE_BUTTON_TAG).assertIsDisplayed() + onNodeWithTag(UPLOAD_FILE_BUTTON_TAG).assertTextEquals("Upload Video") + } + + @Test + fun shouldDisplayUploadDocumentButton() = runComposeUiTest { + val questionnaireItemView = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "attachment-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Attachment), + extension = + listOf( + Extension( + url = "http://hl7.org/fhir/StructureDefinition/mimeType", + value = Extension.Value.Code(value = Code(value = "application/pdf")), + ), + ), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "attachment-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + + setContent { QuestionnaireAttachmentView(questionnaireItemView) } + + onNodeWithTag(UPLOAD_FILE_BUTTON_TAG).assertIsDisplayed() + onNodeWithTag(UPLOAD_FILE_BUTTON_TAG).assertTextEquals("Upload Document") + } + + @Test + fun shouldDisplayTakePhotoAndUploadFileButton() = runComposeUiTest { + val questionnaireItemView = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "attachment-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Attachment), + extension = + listOf( + Extension( + url = "http://hl7.org/fhir/StructureDefinition/mimeType", + value = Extension.Value.Code(value = Code(value = "image/*")), + ), + Extension( + url = "http://hl7.org/fhir/StructureDefinition/mimeType", + value = Extension.Value.Code(value = Code(value = "application/pdf")), + ), + ), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "attachment-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + + setContent { QuestionnaireAttachmentView(questionnaireItemView) } + + onNodeWithTag(TAKE_PHOTO_BUTTON_TAG).assertIsDisplayed() + onNodeWithTag(UPLOAD_FILE_BUTTON_TAG).assertIsDisplayed() + onNodeWithTag(UPLOAD_FILE_BUTTON_TAG).assertTextEquals("Upload File") + } + + @Test + fun shouldDisplayImagePreviewFromAnswer() = runComposeUiTest { + val questionnaireItemView = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "attachment-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Attachment), + extension = + listOf( + Extension( + url = "http://hl7.org/fhir/StructureDefinition/mimeType", + value = Extension.Value.Code(value = Code(value = "image/*")), + ), + ), + ), + QuestionnaireResponse.Item( + linkId = FhirR4String(value = "attachment-item"), + answer = + listOf( + QuestionnaireResponse.Item.Answer( + value = + QuestionnaireResponse.Item.Answer.Value.Attachment( + value = + Attachment( + title = FhirR4String(value = "IMG_1"), + contentType = Code(value = "image/*"), + data = + Base64Binary( + value = "R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7", + ), + ), + ), + ), + ), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + + setContent { QuestionnaireAttachmentView(questionnaireItemView) } + + onNodeWithTag(ATTACHMENT_MEDIA_PREVIEW_TAG).assertIsDisplayed() + onNodeWithText("IMG_1").assertIsDisplayed() + } + + @Test + fun shouldDisplayAudioFilePreviewFromAnswer() = runComposeUiTest { + val questionnaireItemView = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "attachment-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Attachment), + extension = + listOf( + Extension( + url = "http://hl7.org/fhir/StructureDefinition/mimeType", + value = Extension.Value.Code(value = Code(value = "audio/*")), + ), + ), + ), + QuestionnaireResponse.Item( + linkId = FhirR4String(value = "attachment-item"), + answer = + listOf( + QuestionnaireResponse.Item.Answer( + value = + QuestionnaireResponse.Item.Answer.Value.Attachment( + value = + Attachment( + title = FhirR4String(value = "Audio File"), + contentType = Code(value = "audio/*"), + data = + Base64Binary( + value = "R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7", + ), + ), + ), + ), + ), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + + setContent { QuestionnaireAttachmentView(questionnaireItemView) } + + onNodeWithTag(ATTACHMENT_MEDIA_PREVIEW_TAG).assertIsDisplayed() + onNodeWithText("Audio File").assertIsDisplayed() + } + + @Test + fun shouldDisplayVideoFilePreviewFromAnswer() = runComposeUiTest { + val questionnaireItemView = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "attachment-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Attachment), + extension = + listOf( + Extension( + url = "http://hl7.org/fhir/StructureDefinition/mimeType", + value = Extension.Value.Code(value = Code(value = "video/*")), + ), + ), + ), + QuestionnaireResponse.Item( + linkId = FhirR4String(value = "attachment-item"), + answer = + listOf( + QuestionnaireResponse.Item.Answer( + value = + QuestionnaireResponse.Item.Answer.Value.Attachment( + value = + Attachment( + title = FhirR4String(value = "Video File"), + contentType = Code(value = "video/*"), + data = + Base64Binary( + value = "R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7", + ), + ), + ), + ), + ), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + + setContent { QuestionnaireAttachmentView(questionnaireItemView) } + + onNodeWithTag(ATTACHMENT_MEDIA_PREVIEW_TAG).assertIsDisplayed() + onNodeWithText("Video File").assertIsDisplayed() + } + + @Test + fun shouldDisplayDocumentFilePreviewFromAnswer() = runComposeUiTest { + val questionnaireItemView = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "attachment-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Attachment), + extension = + listOf( + Extension( + url = "http://hl7.org/fhir/StructureDefinition/mimeType", + value = Extension.Value.Code(value = Code(value = "application/pdf")), + ), + ), + ), + QuestionnaireResponse.Item( + linkId = FhirR4String(value = "attachment-item"), + answer = + listOf( + QuestionnaireResponse.Item.Answer( + value = + QuestionnaireResponse.Item.Answer.Value.Attachment( + value = + Attachment( + title = FhirR4String(value = "Document File"), + contentType = Code(value = "application/pdf"), + data = + Base64Binary( + value = "R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7", + ), + ), + ), + ), + ), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + + setContent { QuestionnaireAttachmentView(questionnaireItemView) } + + onNodeWithTag(ATTACHMENT_MEDIA_PREVIEW_TAG).assertIsDisplayed() + onNodeWithText("Document File").assertIsDisplayed() + } + + @Test + fun doNotShowPreviewIfAnswerDoesNotHaveAttachment() = runComposeUiTest { + val questionnaireItemView = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "attachment-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Attachment), + extension = + listOf( + Extension( + url = "http://hl7.org/fhir/StructureDefinition/mimeType", + value = Extension.Value.Code(value = Code(value = "image/*")), + ), + Extension( + url = "http://hl7.org/fhir/StructureDefinition/mimeType", + value = Extension.Value.Code(value = Code(value = "application/pdf")), + ), + ), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "attachment-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + + setContent { QuestionnaireAttachmentView(questionnaireItemView) } + + onNodeWithTag(ATTACHMENT_MEDIA_PREVIEW_TAG).assertDoesNotExist() + } + + @Test + fun doNotShowPreviewOfPreviousAnswerAttachmentForCurrentAnswerItem() = runComposeUiTest { + var questionnaireViewItem by + mutableStateOf( + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "attachment-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Attachment), + extension = + listOf( + Extension( + url = "http://hl7.org/fhir/StructureDefinition/mimeType", + value = Extension.Value.Code(value = Code(value = "image/*")), + ), + Extension( + url = "http://hl7.org/fhir/StructureDefinition/mimeType", + value = Extension.Value.Code(value = Code(value = "application/pdf")), + ), + ), + ), + QuestionnaireResponse.Item( + linkId = FhirR4String(value = "attachment-item"), + answer = + listOf( + QuestionnaireResponse.Item.Answer( + value = + QuestionnaireResponse.Item.Answer.Value.Attachment( + value = + Attachment( + title = FhirR4String(value = "IMG_1.jpeg"), + contentType = Code(value = "image/jpeg"), + data = + Base64Binary( + value = "R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7", + ), + ), + ), + ), + ), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ), + ) + + setContent { QuestionnaireAttachmentView(questionnaireViewItem) } + + onNodeWithTag(ATTACHMENT_MEDIA_PREVIEW_TAG).assertIsDisplayed() + + questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "attachment-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Attachment), + extension = + listOf( + Extension( + url = "http://hl7.org/fhir/StructureDefinition/mimeType", + value = Extension.Value.Code(value = Code(value = "image/*")), + ), + Extension( + url = "http://hl7.org/fhir/StructureDefinition/mimeType", + value = Extension.Value.Code(value = Code(value = "application/pdf")), + ), + ), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "attachment-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + + onNodeWithTag(ATTACHMENT_MEDIA_PREVIEW_TAG).assertDoesNotExist() + } + + @Test + fun showPreviewReplacesPreviewOfPreviousAnswerAttachmentForCurrentAnswerItem() = + runComposeUiTest { + var questionnaireViewItem by + mutableStateOf( + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "attachment-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Attachment), + extension = + listOf( + Extension( + url = "http://hl7.org/fhir/StructureDefinition/mimeType", + value = Extension.Value.Code(value = Code(value = "image/*")), + ), + Extension( + url = "http://hl7.org/fhir/StructureDefinition/mimeType", + value = Extension.Value.Code(value = Code(value = "application/pdf")), + ), + ), + ), + QuestionnaireResponse.Item( + linkId = FhirR4String(value = "attachment-item"), + answer = + listOf( + QuestionnaireResponse.Item.Answer( + value = + QuestionnaireResponse.Item.Answer.Value.Attachment( + value = + Attachment( + title = FhirR4String(value = "IMG_1.jpeg"), + contentType = Code(value = "image/jpeg"), + data = + Base64Binary( + value = "R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7", + ), + ), + ), + ), + ), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ), + ) + + setContent { QuestionnaireAttachmentView(questionnaireViewItem) } + + onNodeWithTag(ATTACHMENT_MEDIA_PREVIEW_TAG).assertIsDisplayed() + onNodeWithText("IMG_1.jpeg").assertIsDisplayed() + + questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "attachment-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Attachment), + extension = + listOf( + Extension( + url = "http://hl7.org/fhir/StructureDefinition/mimeType", + value = Extension.Value.Code(value = Code(value = "image/*")), + ), + Extension( + url = "http://hl7.org/fhir/StructureDefinition/mimeType", + value = Extension.Value.Code(value = Code(value = "application/pdf")), + ), + ), + ), + QuestionnaireResponse.Item( + linkId = FhirR4String(value = "attachment-item"), + answer = + listOf( + QuestionnaireResponse.Item.Answer( + value = + QuestionnaireResponse.Item.Answer.Value.Attachment( + value = + Attachment( + title = FhirR4String(value = "Yellow Doc"), + contentType = Code(value = "application/pdf"), + data = + Base64Binary( + value = "R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7", + ), + ), + ), + ), + ), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + + onNodeWithTag(ATTACHMENT_MEDIA_PREVIEW_TAG).assertIsDisplayed() + onNodeWithText("IMG_1.jpeg").assertDoesNotExist() + onNodeWithText("Yellow Doc").assertIsDisplayed() + } + + @Test + fun deleteRemovesPreviewOfAnswerAttachment() = runComposeUiTest { + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "attachment-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Attachment), + extension = + listOf( + Extension( + url = "http://hl7.org/fhir/StructureDefinition/mimeType", + value = Extension.Value.Code(value = Code(value = "image/*")), + ), + ), + ), + QuestionnaireResponse.Item( + linkId = FhirR4String(value = "attachment-item"), + answer = + listOf( + QuestionnaireResponse.Item.Answer( + value = + QuestionnaireResponse.Item.Answer.Value.Attachment( + value = + Attachment( + title = FhirR4String(value = "IMG_1.jpeg"), + contentType = Code(value = "image/jpeg"), + data = + Base64Binary( + value = "R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7", + ), + ), + ), + ), + ), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + + setContent { QuestionnaireAttachmentView(questionnaireViewItem) } + + onNodeWithTag(ATTACHMENT_MEDIA_PREVIEW_TAG).assertIsDisplayed() + onNodeWithText("IMG_1.jpeg").assertIsDisplayed() + onNodeWithContentDescription("Delete").assertIsDisplayed().performClick() + + onNodeWithTag(ATTACHMENT_MEDIA_PREVIEW_TAG).assertDoesNotExist() + onNodeWithContentDescription("Delete").assertDoesNotExist() + } +} diff --git a/datacapture/src/commonTest/kotlin/dev/ohs/fhir/datacapture/views/factories/AutoCompleteViewFactoryTest.kt b/datacapture/src/commonTest/kotlin/dev/ohs/fhir/datacapture/views/factories/AutoCompleteViewFactoryTest.kt new file mode 100644 index 000000000..21a1f4243 --- /dev/null +++ b/datacapture/src/commonTest/kotlin/dev/ohs/fhir/datacapture/views/factories/AutoCompleteViewFactoryTest.kt @@ -0,0 +1,1059 @@ +/* + * Copyright 2025-2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.ohs.fhir.datacapture.views.factories + +import androidx.compose.runtime.Composable +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.test.assert +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.hasAnyAncestor +import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.hasTextExactly +import androidx.compose.ui.test.isPopup +import androidx.compose.ui.test.onFirst +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextReplacement +import androidx.compose.ui.test.runComposeUiTest +import com.google.fhir.model.r4.Canonical +import com.google.fhir.model.r4.Code +import com.google.fhir.model.r4.CodeableConcept +import com.google.fhir.model.r4.Coding +import com.google.fhir.model.r4.Enumeration +import com.google.fhir.model.r4.Extension +import com.google.fhir.model.r4.Questionnaire +import com.google.fhir.model.r4.QuestionnaireResponse +import com.google.fhir.model.r4.Uri +import dev.ohs.fhir.datacapture.extensions.EXTENSION_ITEM_CONTROL_SYSTEM +import dev.ohs.fhir.datacapture.extensions.EXTENSION_ITEM_CONTROL_URL +import dev.ohs.fhir.datacapture.extensions.FhirR4Boolean +import dev.ohs.fhir.datacapture.extensions.FhirR4String +import dev.ohs.fhir.datacapture.extensions.toQuestionnaireResponseItemAnswer +import dev.ohs.fhir.datacapture.theme.QuestionnaireTheme +import dev.ohs.fhir.datacapture.validation.Invalid +import dev.ohs.fhir.datacapture.validation.NotValidated +import dev.ohs.fhir.datacapture.validation.Valid +import dev.ohs.fhir.datacapture.views.QuestionTextConfiguration +import dev.ohs.fhir.datacapture.views.QuestionnaireViewItem +import dev.ohs.fhir.datacapture.views.components.DROP_DOWN_ANSWER_MENU_ITEM_TAG +import dev.ohs.fhir.datacapture.views.components.ERROR_TEXT_AT_HEADER_TEST_TAG +import dev.ohs.fhir.datacapture.views.components.MULTI_AUTO_COMPLETE_INPUT_CHIP_TAG +import dev.ohs.fhir.datacapture.views.components.MULTI_AUTO_COMPLETE_TEXT_FIELD_TAG +import dev.ohs.fhir.datacapture.views.components.QUESTION_HEADER_TAG +import dev.ohs.fhir.datacapture.views.components.REQUIRED_OPTIONAL_HEADER_TEXT_TAG +import io.kotest.matchers.collections.shouldContainExactly +import kotlin.test.Test + +@OptIn(ExperimentalTestApi::class) +class AutoCompleteViewFactoryTest { + + @Composable + fun QuestionnaireAutoCompleteView(questionnaireViewItem: QuestionnaireViewItem) { + QuestionnaireTheme { AutoCompleteViewFactory.Content(questionnaireViewItem) } + } + + @Test + fun shouldSetQuestionHeader() = runComposeUiTest { + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "autocomplete-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Choice), + extension = + listOf( + Extension( + url = EXTENSION_ITEM_CONTROL_URL, + value = + Extension.Value.CodeableConcept( + value = + CodeableConcept( + coding = + listOf( + Coding( + system = Uri(value = EXTENSION_ITEM_CONTROL_SYSTEM), + code = Code(value = "autocomplete"), + ), + ), + ), + ), + ), + ), + text = FhirR4String(value = "Question"), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "autocomplete-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + + setContent { QuestionnaireAutoCompleteView(questionnaireViewItem) } + + onNodeWithTag(QUESTION_HEADER_TAG).assertTextEquals("Question") + } + + @Test + fun shouldHaveSingleAnswerChip() = runComposeUiTest { + val questionnaireItem = + Questionnaire.Item( + linkId = FhirR4String(value = "autocomplete-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Choice), + extension = + listOf( + Extension( + url = EXTENSION_ITEM_CONTROL_URL, + value = + Extension.Value.CodeableConcept( + value = + CodeableConcept( + coding = + listOf( + Coding( + system = Uri(value = EXTENSION_ITEM_CONTROL_SYSTEM), + code = Code(value = "autocomplete"), + ), + ), + ), + ), + ), + ), + repeats = FhirR4Boolean(value = false), + answerOption = + listOf( + Questionnaire.Item.AnswerOption( + value = + Questionnaire.Item.AnswerOption.Value.Coding( + value = + Coding( + code = Code(value = "test1-code"), + display = FhirR4String(value = "Test1 Code"), + ), + ), + ), + Questionnaire.Item.AnswerOption( + value = + Questionnaire.Item.AnswerOption.Value.Coding( + value = + Coding( + code = Code(value = "test2-code"), + display = FhirR4String(value = "Test2 Code"), + ), + ), + ), + ), + ) + + setContent { + QuestionnaireAutoCompleteView( + QuestionnaireViewItem( + questionnaireItem, + QuestionnaireResponse.Item( + linkId = FhirR4String(value = "autocomplete-item"), + answer = + listOf( + QuestionnaireResponse.Item.Answer( + value = + QuestionnaireResponse.Item.Answer.Value.Coding( + value = + Coding( + code = Code(value = "test1-code"), + display = FhirR4String(value = "Test1 Code"), + ), + ), + ), + ), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ), + ) + } + + onAllNodes(hasTestTag(MULTI_AUTO_COMPLETE_INPUT_CHIP_TAG)).assertCountEquals(1) + } + + @Test + fun shouldHaveTwoAnswerChipWithExternalValueSet() = runComposeUiTest { + val answers = + listOf( + Questionnaire.Item.AnswerOption( + value = + Questionnaire.Item.AnswerOption.Value.Coding( + value = + Coding( + code = Code(value = "test1-code"), + display = FhirR4String(value = "Test1 Code"), + ), + ), + ), + Questionnaire.Item.AnswerOption( + value = + Questionnaire.Item.AnswerOption.Value.Coding( + value = + Coding( + code = Code(value = "test2-code"), + display = FhirR4String(value = "Test2 Code"), + ), + ), + ), + ) + + val fakeAnswerValueSetResolver = { uri: String -> + if (uri == "http://answwer-value-set-url") { + answers + } else { + emptyList() + } + } + + val questionnaireItem = + Questionnaire.Item( + linkId = FhirR4String(value = "autocomplete-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Choice), + extension = + listOf( + Extension( + url = EXTENSION_ITEM_CONTROL_URL, + value = + Extension.Value.CodeableConcept( + value = + CodeableConcept( + coding = + listOf( + Coding( + system = Uri(value = EXTENSION_ITEM_CONTROL_SYSTEM), + code = Code(value = "autocomplete"), + ), + ), + ), + ), + ), + ), + repeats = FhirR4Boolean(value = true), + answerValueSet = Canonical(value = "http://answwer-value-set-url"), + ) + + setContent { + QuestionnaireAutoCompleteView( + QuestionnaireViewItem( + questionnaireItem, + QuestionnaireResponse.Item( + linkId = FhirR4String(value = "autocomplete-item"), + answer = + listOf( + QuestionnaireResponse.Item.Answer( + value = + QuestionnaireResponse.Item.Answer.Value.Coding( + value = + Coding( + code = Code(value = "test1-code"), + display = FhirR4String(value = "Test1 Code"), + ), + ), + ), + QuestionnaireResponse.Item.Answer( + value = + QuestionnaireResponse.Item.Answer.Value.Coding( + value = + Coding( + code = Code(value = "test2-code"), + display = FhirR4String(value = "Test2 Code"), + ), + ), + ), + ), + ), + enabledAnswerOptions = + fakeAnswerValueSetResolver.invoke(questionnaireItem.answerValueSet?.value!!), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ), + ) + } + + onAllNodes(hasTestTag(MULTI_AUTO_COMPLETE_INPUT_CHIP_TAG)).assertCountEquals(2) + } + + @Test + fun shouldHaveTwoAnswerChipWithAnswerOptionsHavingSameDisplayStringDifferentId() = + runComposeUiTest { + val answers = + listOf( + Questionnaire.Item.AnswerOption( + value = + Questionnaire.Item.AnswerOption.Value.Coding( + value = + Coding( + code = Code(value = "test1-code"), + display = FhirR4String(value = "Test Code"), + id = "test1-code", + ), + ), + ), + Questionnaire.Item.AnswerOption( + value = + Questionnaire.Item.AnswerOption.Value.Coding( + value = + Coding( + system = Uri(value = "http://answers/test-codes"), + version = FhirR4String(value = "1.0"), + code = Code(value = "test2-code"), + display = FhirR4String(value = "Test Code"), + ), + ), + ), + ) + + val fakeAnswerValueSetResolver = { uri: String -> + if (uri == "http://answwer-value-set-url") { + answers + } else { + emptyList() + } + } + val questionnaireItem = + Questionnaire.Item( + linkId = FhirR4String(value = "autocomplete-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Choice), + extension = + listOf( + Extension( + url = EXTENSION_ITEM_CONTROL_URL, + value = + Extension.Value.CodeableConcept( + value = + CodeableConcept( + coding = + listOf( + Coding( + system = Uri(value = EXTENSION_ITEM_CONTROL_SYSTEM), + code = Code(value = "autocomplete"), + ), + ), + ), + ), + ), + ), + repeats = FhirR4Boolean(value = true), + answerValueSet = Canonical(value = "http://answwer-value-set-url"), + ) + + setContent { + QuestionnaireAutoCompleteView( + QuestionnaireViewItem( + questionnaireItem, + QuestionnaireResponse.Item( + linkId = FhirR4String(value = "autocomplete-item"), + answer = + listOf( + QuestionnaireResponse.Item.Answer( + value = + QuestionnaireResponse.Item.Answer.Value.Coding( + value = + Coding( + code = Code(value = "test1-code"), + display = FhirR4String(value = "Test Code"), + id = "test1-code", + ), + ), + ), + QuestionnaireResponse.Item.Answer( + value = + QuestionnaireResponse.Item.Answer.Value.Coding( + value = + Coding( + system = Uri(value = "http://answers/test-codes"), + version = FhirR4String(value = "1.0"), + code = Code(value = "test2-code"), + display = FhirR4String(value = "Test Code"), + ), + ), + ), + ), + ), + enabledAnswerOptions = + fakeAnswerValueSetResolver.invoke(questionnaireItem.answerValueSet?.value!!), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ), + ) + } + + onAllNodes(hasTestTag(MULTI_AUTO_COMPLETE_INPUT_CHIP_TAG)).assertCountEquals(2) + } + + @Test + fun shouldHaveSingleAnswerChipWithContainedAnswerValueSet() = runComposeUiTest { + val answers = + listOf( + Questionnaire.Item.AnswerOption( + value = + Questionnaire.Item.AnswerOption.Value.Coding( + value = + Coding( + code = Code(value = "test1-code"), + display = FhirR4String(value = "Test1 Code"), + ), + ), + ), + Questionnaire.Item.AnswerOption( + value = + Questionnaire.Item.AnswerOption.Value.Coding( + value = + Coding( + code = Code(value = "test2-code"), + display = FhirR4String(value = "Test2 Code"), + ), + ), + ), + ) + + val fakeAnswerValueSetResolver = { uri: String -> + if (uri == "#ContainedValueSet") { + answers + } else { + emptyList() + } + } + val questionnaireItem = + Questionnaire.Item( + linkId = FhirR4String(value = "autocomplete-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Choice), + extension = + listOf( + Extension( + url = EXTENSION_ITEM_CONTROL_URL, + value = + Extension.Value.CodeableConcept( + value = + CodeableConcept( + coding = + listOf( + Coding( + system = Uri(value = EXTENSION_ITEM_CONTROL_SYSTEM), + code = Code(value = "autocomplete"), + ), + ), + ), + ), + ), + ), + repeats = FhirR4Boolean(value = false), + answerValueSet = Canonical(value = "#ContainedValueSet"), + ) + + setContent { + QuestionnaireAutoCompleteView( + QuestionnaireViewItem( + questionnaireItem, + QuestionnaireResponse.Item( + linkId = FhirR4String(value = "autocomplete-item"), + answer = + listOf( + QuestionnaireResponse.Item.Answer( + value = + QuestionnaireResponse.Item.Answer.Value.Coding( + value = + Coding( + code = Code(value = "test1-code"), + display = FhirR4String(value = "Test1 Code"), + ), + ), + ), + ), + ), + enabledAnswerOptions = + fakeAnswerValueSetResolver.invoke(questionnaireItem.answerValueSet?.value!!), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ), + ) + } + + onAllNodes(hasTestTag(MULTI_AUTO_COMPLETE_INPUT_CHIP_TAG)).assertCountEquals(1) + } + + @Test + fun noDisplayString_shouldShowCode() = runComposeUiTest { + val answers = + listOf( + Questionnaire.Item.AnswerOption( + value = + Questionnaire.Item.AnswerOption.Value.Coding( + value = Coding(code = Code(value = "test1-code")), + ), + initialSelected = FhirR4Boolean(value = true), + ), + ) + + setContent { + QuestionnaireAutoCompleteView( + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "autocomplete-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Choice), + extension = + listOf( + Extension( + url = EXTENSION_ITEM_CONTROL_URL, + value = + Extension.Value.CodeableConcept( + value = + CodeableConcept( + coding = + listOf( + Coding( + system = Uri(value = EXTENSION_ITEM_CONTROL_SYSTEM), + code = Code(value = "autocomplete"), + ), + ), + ), + ), + ), + ), + ), + QuestionnaireResponse.Item( + linkId = FhirR4String(value = "autocomplete-item"), + answer = + answers.map { itemAnswerOption -> + itemAnswerOption.toQuestionnaireResponseItemAnswer() + }, + ), + enabledAnswerOptions = answers, + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ), + ) + } + + onAllNodes(hasTestTag(MULTI_AUTO_COMPLETE_INPUT_CHIP_TAG)) + .onFirst() + .assertTextEquals("test1-code") + } + + @Test + fun shouldReturnFilteredDropDownMenuItems() = runComposeUiTest { + val questionnaireViewItem = + QuestionnaireViewItem( + answerOptions(false, "Coding 1", "Coding 2", "Coding 3", "Coding 4", "Coding 5"), + responseOptions(), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + + setContent { QuestionnaireAutoCompleteView(questionnaireViewItem) } + + onNodeWithTag(MULTI_AUTO_COMPLETE_TEXT_FIELD_TAG).performTextReplacement("Coding 1") + onAllNodes(hasTestTag(DROP_DOWN_ANSWER_MENU_ITEM_TAG)).assertCountEquals(1) + } + + @Test + fun shouldAddDropDownValueSelectedForMultipleAnswersAutoCompleteTextView() = runComposeUiTest { + var answerHolder: List? = null + val questionnaireViewItem = + QuestionnaireViewItem( + answerOptions(true, "Coding 1", "Coding 2", "Coding 3", "Coding 4", "Coding 5"), + responseOptions("Coding 1", "Coding 5"), + validationResult = NotValidated, + answersChangedCallback = { _, _, answers, _ -> answerHolder = answers }, + ) + setContent { QuestionnaireAutoCompleteView(questionnaireViewItem) } + + onNodeWithTag(MULTI_AUTO_COMPLETE_TEXT_FIELD_TAG).performTextReplacement("Coding 3") + + onNode( + hasTestTag(DROP_DOWN_ANSWER_MENU_ITEM_TAG) and + hasTextExactly("Coding 3") and + hasAnyAncestor(isPopup()), + ) + .assertIsDisplayed() + .performClick() + + onNodeWithTag(MULTI_AUTO_COMPLETE_TEXT_FIELD_TAG).assertTextEquals("") + + waitUntil { answerHolder != null } + + answerHolder!! + .map { itemAnswer -> itemAnswer.value?.asCoding()?.value?.display?.value } + .shouldContainExactly("Coding 1", "Coding 5", "Coding 3") + } + + @Test + fun shouldSetCorrectNumberOfChipsForSelectedAnswers() = runComposeUiTest { + val questionnaireViewItem = + QuestionnaireViewItem( + answerOptions(true, "Coding 1", "Coding 2", "Coding 3", "Coding 4", "Coding 5"), + responseOptions("Coding 1", "Coding 5"), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + setContent { QuestionnaireAutoCompleteView(questionnaireViewItem) } + onAllNodes(hasTestTag(MULTI_AUTO_COMPLETE_INPUT_CHIP_TAG)).assertCountEquals(2) + } + + @Test + fun displayValidationResult_error_shouldShowErrorMessage() = runComposeUiTest { + setContent { + QuestionnaireAutoCompleteView( + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "autocomplete-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Choice), + extension = + listOf( + Extension( + url = EXTENSION_ITEM_CONTROL_URL, + value = + Extension.Value.CodeableConcept( + value = + CodeableConcept( + coding = + listOf( + Coding( + system = Uri(value = EXTENSION_ITEM_CONTROL_SYSTEM), + code = Code(value = "autocomplete"), + ), + ), + ), + ), + ), + ), + required = FhirR4Boolean(value = true), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "autocomplete-item")), + validationResult = Invalid(listOf("Missing answer for required field.")), + answersChangedCallback = { _, _, _, _ -> }, + ), + ) + } + + onNodeWithTag(MULTI_AUTO_COMPLETE_TEXT_FIELD_TAG) + .assert( + SemanticsMatcher.expectValue( + SemanticsProperties.Error, + "Missing answer for required field.", + ), + ) + } + + @Test + fun displayValidationResult_noError_shouldShowNoErrorMessage() = runComposeUiTest { + setContent { + QuestionnaireAutoCompleteView( + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "autocomplete-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Choice), + extension = + listOf( + Extension( + url = EXTENSION_ITEM_CONTROL_URL, + value = + Extension.Value.CodeableConcept( + value = + CodeableConcept( + coding = + listOf( + Coding( + system = Uri(value = EXTENSION_ITEM_CONTROL_SYSTEM), + code = Code(value = "autocomplete"), + ), + ), + ), + ), + ), + ), + answerOption = + listOf( + Questionnaire.Item.AnswerOption( + value = + Questionnaire.Item.AnswerOption.Value.Coding( + value = Coding(display = FhirR4String(value = "display")), + ), + ), + ), + required = FhirR4Boolean(value = true), + ), + QuestionnaireResponse.Item( + linkId = FhirR4String(value = "autocomplete-item"), + answer = + listOf( + QuestionnaireResponse.Item.Answer( + value = + QuestionnaireResponse.Item.Answer.Value.Coding( + value = Coding(display = FhirR4String(value = "display")), + ), + ), + ), + ), + validationResult = Valid, + answersChangedCallback = { _, _, _, _ -> }, + ), + ) + } + + onNodeWithTag(MULTI_AUTO_COMPLETE_TEXT_FIELD_TAG) + .assert( + SemanticsMatcher.keyNotDefined( + SemanticsProperties.Error, + ), + ) + } + + @Test + fun hidesErrorTextviewInTheHeader() = runComposeUiTest { + setContent { + QuestionnaireAutoCompleteView( + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "autocomplete-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Choice), + extension = + listOf( + Extension( + url = EXTENSION_ITEM_CONTROL_URL, + value = + Extension.Value.CodeableConcept( + value = + CodeableConcept( + coding = + listOf( + Coding( + system = Uri(value = EXTENSION_ITEM_CONTROL_SYSTEM), + code = Code(value = "autocomplete"), + ), + ), + ), + ), + ), + ), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "autocomplete-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ), + ) + } + + onNodeWithTag(ERROR_TEXT_AT_HEADER_TEST_TAG).assertDoesNotExist() + } + + @Test + fun showAsterisk() = runComposeUiTest { + setContent { + QuestionnaireAutoCompleteView( + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "autocomplete-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Choice), + extension = + listOf( + Extension( + url = EXTENSION_ITEM_CONTROL_URL, + value = + Extension.Value.CodeableConcept( + value = + CodeableConcept( + coding = + listOf( + Coding( + system = Uri(value = EXTENSION_ITEM_CONTROL_SYSTEM), + code = Code(value = "autocomplete"), + ), + ), + ), + ), + ), + ), + text = FhirR4String(value = "Question"), + required = FhirR4Boolean(value = true), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "autocomplete-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + questionViewTextConfiguration = QuestionTextConfiguration(showAsterisk = true), + ), + ) + } + + onNodeWithTag(QUESTION_HEADER_TAG).assertTextEquals("Question *") + } + + @Test + fun hideAsterisk() = runComposeUiTest { + setContent { + QuestionnaireAutoCompleteView( + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "autocomplete-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Choice), + extension = + listOf( + Extension( + url = EXTENSION_ITEM_CONTROL_URL, + value = + Extension.Value.CodeableConcept( + value = + CodeableConcept( + coding = + listOf( + Coding( + system = Uri(value = EXTENSION_ITEM_CONTROL_SYSTEM), + code = Code(value = "autocomplete"), + ), + ), + ), + ), + ), + ), + text = FhirR4String(value = "Question"), + required = FhirR4Boolean(value = true), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "autocomplete-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + questionViewTextConfiguration = QuestionTextConfiguration(showAsterisk = false), + ), + ) + } + + onNodeWithTag(QUESTION_HEADER_TAG).assertTextEquals("Question") + } + + @Test + fun showsRequiredText() = runComposeUiTest { + setContent { + QuestionnaireAutoCompleteView( + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "autocomplete-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Choice), + extension = + listOf( + Extension( + url = EXTENSION_ITEM_CONTROL_URL, + value = + Extension.Value.CodeableConcept( + value = + CodeableConcept( + coding = + listOf( + Coding( + system = Uri(value = EXTENSION_ITEM_CONTROL_SYSTEM), + code = Code(value = "autocomplete"), + ), + ), + ), + ), + ), + ), + required = FhirR4Boolean(value = true), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "autocomplete-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + questionViewTextConfiguration = QuestionTextConfiguration(showRequiredText = true), + ), + ) + } + + onNodeWithTag(REQUIRED_OPTIONAL_HEADER_TEXT_TAG) + .assertIsDisplayed() + .assertTextEquals("Required") + } + + @Test + fun hideRequiredText() = runComposeUiTest { + setContent { + QuestionnaireAutoCompleteView( + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "autocomplete-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Choice), + extension = + listOf( + Extension( + url = EXTENSION_ITEM_CONTROL_URL, + value = + Extension.Value.CodeableConcept( + value = + CodeableConcept( + coding = + listOf( + Coding( + system = Uri(value = EXTENSION_ITEM_CONTROL_SYSTEM), + code = Code(value = "autocomplete"), + ), + ), + ), + ), + ), + ), + required = FhirR4Boolean(value = true), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "autocomplete-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + questionViewTextConfiguration = QuestionTextConfiguration(showRequiredText = false), + ), + ) + } + + onNodeWithTag(REQUIRED_OPTIONAL_HEADER_TEXT_TAG).assertDoesNotExist() + } + + @Test + fun showsOptionalText() = runComposeUiTest { + setContent { + QuestionnaireAutoCompleteView( + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "autocomplete-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Choice), + extension = + listOf( + Extension( + url = EXTENSION_ITEM_CONTROL_URL, + value = + Extension.Value.CodeableConcept( + value = + CodeableConcept( + coding = + listOf( + Coding( + system = Uri(value = EXTENSION_ITEM_CONTROL_SYSTEM), + code = Code(value = "autocomplete"), + ), + ), + ), + ), + ), + ), + text = FhirR4String(value = "Question"), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "autocomplete-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + questionViewTextConfiguration = QuestionTextConfiguration(showOptionalText = true), + ), + ) + } + + onNodeWithTag(REQUIRED_OPTIONAL_HEADER_TEXT_TAG) + .assertIsDisplayed() + .assertTextEquals("Optional") + } + + @Test + fun hideOptionalText() = runComposeUiTest { + setContent { + QuestionnaireAutoCompleteView( + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "autocomplete-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Choice), + extension = + listOf( + Extension( + url = EXTENSION_ITEM_CONTROL_URL, + value = + Extension.Value.CodeableConcept( + value = + CodeableConcept( + coding = + listOf( + Coding( + system = Uri(value = EXTENSION_ITEM_CONTROL_SYSTEM), + code = Code(value = "autocomplete"), + ), + ), + ), + ), + ), + ), + text = FhirR4String(value = "Question"), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "autocomplete-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + questionViewTextConfiguration = QuestionTextConfiguration(showOptionalText = false), + ), + ) + } + + onNodeWithTag(REQUIRED_OPTIONAL_HEADER_TEXT_TAG).assertDoesNotExist() + } + + private fun answerOptions(repeats: Boolean, vararg options: String) = + Questionnaire.Item( + linkId = FhirR4String(value = "autocomplete-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Choice), + extension = + listOf( + Extension( + url = EXTENSION_ITEM_CONTROL_URL, + value = + Extension.Value.CodeableConcept( + value = + CodeableConcept( + coding = + listOf( + Coding( + system = Uri(value = EXTENSION_ITEM_CONTROL_SYSTEM), + code = Code(value = "autocomplete"), + ), + ), + ), + ), + ), + ), + answerOption = + options.map { option -> + Questionnaire.Item.AnswerOption( + value = + Questionnaire.Item.AnswerOption.Value.Coding( + value = + Coding( + code = Code(value = option.replace(" ", "_")), + display = FhirR4String(value = option), + ), + ), + ) + }, + repeats = FhirR4Boolean(value = repeats), + ) + + private fun responseOptions(vararg options: String) = + QuestionnaireResponse.Item( + linkId = FhirR4String(value = "autocomplete-item"), + answer = + options.map { option -> + QuestionnaireResponse.Item.Answer( + value = + QuestionnaireResponse.Item.Answer.Value.Coding( + value = + Coding( + code = Code(value = option.replace(" ", "_")), + display = FhirR4String(value = option), + ), + ), + ) + }, + ) +} diff --git a/datacapture/src/commonTest/kotlin/dev/ohs/fhir/datacapture/views/factories/BooleanChoiceViewFactoryTest.kt b/datacapture/src/commonTest/kotlin/dev/ohs/fhir/datacapture/views/factories/BooleanChoiceViewFactoryTest.kt new file mode 100644 index 000000000..cd6cd1292 --- /dev/null +++ b/datacapture/src/commonTest/kotlin/dev/ohs/fhir/datacapture/views/factories/BooleanChoiceViewFactoryTest.kt @@ -0,0 +1,601 @@ +/* + * Copyright 2025-2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.ohs.fhir.datacapture.views.factories + +import androidx.compose.runtime.Composable +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.assertIsNotSelected +import androidx.compose.ui.test.assertIsSelected +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.runComposeUiTest +import com.google.fhir.model.r4.Enumeration +import com.google.fhir.model.r4.Questionnaire +import com.google.fhir.model.r4.QuestionnaireResponse +import dev.ohs.fhir.datacapture.extensions.FhirR4Boolean +import dev.ohs.fhir.datacapture.extensions.FhirR4String +import dev.ohs.fhir.datacapture.theme.QuestionnaireTheme +import dev.ohs.fhir.datacapture.validation.Invalid +import dev.ohs.fhir.datacapture.validation.NotValidated +import dev.ohs.fhir.datacapture.views.QuestionTextConfiguration +import dev.ohs.fhir.datacapture.views.QuestionnaireViewItem +import dev.ohs.fhir.datacapture.views.components.ERROR_TEXT_AT_HEADER_TEST_TAG +import dev.ohs.fhir.datacapture.views.components.QUESTION_HEADER_TAG +import dev.ohs.fhir.datacapture.views.components.REQUIRED_OPTIONAL_HEADER_TEXT_TAG +import io.kotest.matchers.booleans.shouldBeFalse +import io.kotest.matchers.booleans.shouldBeTrue +import io.kotest.matchers.collections.shouldBeEmpty +import kotlin.test.Test + +@OptIn(ExperimentalTestApi::class) +class BooleanChoiceViewFactoryTest { + + @Composable + fun QuestionnaireBooleanChoice(questionnaireViewItem: QuestionnaireViewItem) { + QuestionnaireTheme { BooleanChoiceViewFactory.Content(questionnaireViewItem) } + } + + @Test + fun bind_shouldSetQuestionHeader() = runComposeUiTest { + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "boolean-choice-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Boolean), + text = FhirR4String(value = "Question?"), + repeats = FhirR4Boolean(value = true), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "boolean-choice-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + + setContent { QuestionnaireBooleanChoice(questionnaireViewItem) } + + onNodeWithTag(QUESTION_HEADER_TAG).assertTextEquals("Question?") + } + + @Test + fun noAnswer_shouldSetAnswerEmpty() = runComposeUiTest { + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "boolean-choice-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Boolean), + text = FhirR4String(value = "Question?"), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "boolean-choice-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + setContent { QuestionnaireBooleanChoice(questionnaireViewItem) } + + questionnaireViewItem.answers.shouldBeEmpty() + } + + @Test + fun noAnswer_shouldNotCheckYesOrNoRadioButton() = runComposeUiTest { + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "boolean-choice-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Boolean), + text = FhirR4String(value = "Question?"), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "boolean-choice-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + + setContent { QuestionnaireBooleanChoice(questionnaireViewItem) } + + onNodeWithTag(YES_CHOICE_RADIO_BUTTON_TAG).assertIsNotSelected() + onNodeWithTag(NO_CHOICE_RADIO_BUTTON_TAG).assertIsNotSelected() + } + + @Test + fun answerTrue_shouldSetAnswerTrue() = runComposeUiTest { + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "boolean-choice-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Boolean), + text = FhirR4String(value = "Question?"), + ), + QuestionnaireResponse.Item( + linkId = FhirR4String(value = "boolean-choice-item"), + answer = + listOf( + QuestionnaireResponse.Item.Answer( + value = + QuestionnaireResponse.Item.Answer.Value.Boolean( + value = FhirR4Boolean(value = true), + ), + ), + ), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + setContent { QuestionnaireBooleanChoice(questionnaireViewItem) } + questionnaireViewItem.answers.single().value?.asBoolean()?.value?.value.shouldBeTrue() + } + + @Test + fun answerTrue_shouldCheckYesRadioButton() = runComposeUiTest { + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "boolean-choice-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Boolean), + text = FhirR4String(value = "Question?"), + ), + QuestionnaireResponse.Item( + linkId = FhirR4String(value = "boolean-choice-item"), + answer = + listOf( + QuestionnaireResponse.Item.Answer( + value = + QuestionnaireResponse.Item.Answer.Value.Boolean( + value = FhirR4Boolean(value = true), + ), + ), + ), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + + setContent { QuestionnaireBooleanChoice(questionnaireViewItem) } + + onNodeWithTag(YES_CHOICE_RADIO_BUTTON_TAG).assertIsSelected() + onNodeWithTag(NO_CHOICE_RADIO_BUTTON_TAG).assertIsNotSelected() + } + + @Test + fun answerFalse_shouldSetAnswerFalse() = runComposeUiTest { + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "boolean-choice-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Boolean), + text = FhirR4String(value = "Question?"), + ), + QuestionnaireResponse.Item( + linkId = FhirR4String(value = "boolean-choice-item"), + answer = + listOf( + QuestionnaireResponse.Item.Answer( + value = + QuestionnaireResponse.Item.Answer.Value.Boolean( + value = FhirR4Boolean(value = false), + ), + ), + ), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + setContent { QuestionnaireBooleanChoice(questionnaireViewItem) } + + questionnaireViewItem.answers.single().value?.asBoolean()?.value?.value.shouldBeFalse() + } + + @Test + fun answerFalse_shouldCheckNoRadioButton() = runComposeUiTest { + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "boolean-choice-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Boolean), + text = FhirR4String(value = "Question?"), + ), + QuestionnaireResponse.Item( + linkId = FhirR4String(value = "boolean-choice-item"), + answer = + listOf( + QuestionnaireResponse.Item.Answer( + value = + QuestionnaireResponse.Item.Answer.Value.Boolean( + value = FhirR4Boolean(value = false), + ), + ), + ), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + + setContent { QuestionnaireBooleanChoice(questionnaireViewItem) } + onNodeWithTag(YES_CHOICE_RADIO_BUTTON_TAG).assertIsNotSelected() + onNodeWithTag(NO_CHOICE_RADIO_BUTTON_TAG).assertIsSelected() + } + + @Test + fun click_shouldSetAnswerTrue() = runComposeUiTest { + var answerHolder: List? = null + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "boolean-choice-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Boolean), + text = FhirR4String(value = "Question?"), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "boolean-choice-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, answers, _ -> answerHolder = answers }, + ) + setContent { QuestionnaireBooleanChoice(questionnaireViewItem) } + onNodeWithTag(YES_CHOICE_RADIO_BUTTON_TAG).performClick() + waitUntil { answerHolder != null } + + answerHolder?.single()?.value?.asBoolean()?.value?.value.shouldBeTrue() + } + + @Test + fun click_shouldSetAnswerFalse() = runComposeUiTest { + var answerHolder: List? = null + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "boolean-choice-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Boolean), + text = FhirR4String(value = "Question?"), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "boolean-choice-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, answers, _ -> answerHolder = answers }, + ) + setContent { QuestionnaireBooleanChoice(questionnaireViewItem) } + onNodeWithTag(NO_CHOICE_RADIO_BUTTON_TAG).performClick() + waitUntil { answerHolder != null } + + answerHolder?.single()?.value?.asBoolean()?.value?.value.shouldBeFalse() + } + + @Test + fun yesSelected_clickYes_shouldClearAnswer() = runComposeUiTest { + var answerHolder: List? = null + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "boolean-choice-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Boolean), + text = FhirR4String(value = "Question?"), + ), + QuestionnaireResponse.Item( + linkId = FhirR4String(value = "boolean-choice-item"), + answer = + listOf( + QuestionnaireResponse.Item.Answer( + value = + QuestionnaireResponse.Item.Answer.Value.Boolean( + value = FhirR4Boolean(value = true), + ), + ), + ), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, answers, _ -> answerHolder = answers }, + ) + setContent { QuestionnaireBooleanChoice(questionnaireViewItem) } + onNodeWithTag(YES_CHOICE_RADIO_BUTTON_TAG).performClick() + waitUntil { answerHolder != null } + + answerHolder.shouldBeEmpty() + } + + @Test + fun yesSelected_clickYes_shouldClearRadioButtons() = runComposeUiTest { + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "boolean-choice-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Boolean), + text = FhirR4String(value = "Question?"), + ), + QuestionnaireResponse.Item( + linkId = FhirR4String(value = "boolean-choice-item"), + answer = + listOf( + QuestionnaireResponse.Item.Answer( + value = + QuestionnaireResponse.Item.Answer.Value.Boolean( + value = FhirR4Boolean(value = true), + ), + ), + ), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + setContent { QuestionnaireBooleanChoice(questionnaireViewItem) } + onNodeWithTag(YES_CHOICE_RADIO_BUTTON_TAG).performClick() + onNodeWithTag(YES_CHOICE_RADIO_BUTTON_TAG).assertIsNotSelected() + onNodeWithTag(NO_CHOICE_RADIO_BUTTON_TAG).assertIsNotSelected() + } + + @Test + fun noSelected_clickNo_shouldClearAnswer() = runComposeUiTest { + var answerHolder: List? = null + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "boolean-choice-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Boolean), + text = FhirR4String(value = "Question?"), + ), + QuestionnaireResponse.Item( + linkId = FhirR4String(value = "boolean-choice-item"), + answer = + listOf( + QuestionnaireResponse.Item.Answer( + value = + QuestionnaireResponse.Item.Answer.Value.Boolean( + value = FhirR4Boolean(value = false), + ), + ), + ), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, answers, _ -> answerHolder = answers }, + ) + setContent { QuestionnaireBooleanChoice(questionnaireViewItem) } + onNodeWithTag(NO_CHOICE_RADIO_BUTTON_TAG).performClick() + waitUntil { answerHolder != null } + + answerHolder.shouldBeEmpty() + } + + @Test + fun noSelected_clickNo_shouldClearRadioButtons() = runComposeUiTest { + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "boolean-choice-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Boolean), + text = FhirR4String(value = "Question?"), + ), + QuestionnaireResponse.Item( + linkId = FhirR4String(value = "boolean-choice-item"), + answer = + listOf( + QuestionnaireResponse.Item.Answer( + value = + QuestionnaireResponse.Item.Answer.Value.Boolean( + value = FhirR4Boolean(value = false), + ), + ), + ), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + setContent { QuestionnaireBooleanChoice(questionnaireViewItem) } + onNodeWithTag(NO_CHOICE_RADIO_BUTTON_TAG).performClick() + + onNodeWithTag(NO_CHOICE_RADIO_BUTTON_TAG).assertIsNotSelected() + onNodeWithTag(YES_CHOICE_RADIO_BUTTON_TAG).assertIsNotSelected() + } + + @Test + fun displayValidationResult_error_shouldShowErrorMessage() = runComposeUiTest { + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "boolean-choice-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Boolean), + required = FhirR4Boolean(value = true), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "boolean-choice-item")), + validationResult = Invalid(listOf("Missing answer for required field.")), + answersChangedCallback = { _, _, _, _ -> }, + ) + + setContent { QuestionnaireBooleanChoice(questionnaireViewItem) } + onNodeWithTag(ERROR_TEXT_AT_HEADER_TEST_TAG) + .assertTextEquals("Missing answer for required field.") + } + + @Test + fun displayValidationResult_noError_shouldShowNoErrorMessage() = runComposeUiTest { + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "boolean-choice-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Boolean), + text = FhirR4String(value = "Question?"), + ), + QuestionnaireResponse.Item( + linkId = FhirR4String(value = "boolean-choice-item"), + answer = + listOf( + QuestionnaireResponse.Item.Answer( + value = + QuestionnaireResponse.Item.Answer.Value.Boolean( + value = FhirR4Boolean(value = true), + ), + ), + ), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + + setContent { QuestionnaireBooleanChoice(questionnaireViewItem) } + + onNodeWithTag(ERROR_TEXT_AT_HEADER_TEST_TAG).assertDoesNotExist() + } + + @Test + fun bind_readOnly_shouldDisableView() = runComposeUiTest { + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "boolean-choice-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Boolean), + text = FhirR4String(value = "Question?"), + readOnly = FhirR4Boolean(value = true), + ), + QuestionnaireResponse.Item( + linkId = FhirR4String(value = "boolean-choice-item"), + answer = + listOf( + QuestionnaireResponse.Item.Answer( + value = + QuestionnaireResponse.Item.Answer.Value.Boolean( + value = FhirR4Boolean(value = true), + ), + ), + ), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + + setContent { QuestionnaireBooleanChoice(questionnaireViewItem) } + + onNodeWithTag(YES_CHOICE_RADIO_BUTTON_TAG).assertIsNotEnabled() + onNodeWithTag(NO_CHOICE_RADIO_BUTTON_TAG).assertIsNotEnabled() + } + + @Test + fun showAsterisk() = runComposeUiTest { + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "boolean-choice-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Boolean), + text = FhirR4String(value = "Question"), + required = FhirR4Boolean(value = true), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "boolean-choice-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + questionViewTextConfiguration = QuestionTextConfiguration(showAsterisk = true), + ) + + setContent { QuestionnaireBooleanChoice(questionnaireViewItem) } + + onNodeWithTag(QUESTION_HEADER_TAG).assertTextEquals("Question *") + } + + @Test + fun hideAsterisk() = runComposeUiTest { + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "boolean-choice-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Boolean), + text = FhirR4String(value = "Question"), + required = FhirR4Boolean(value = true), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "boolean-choice-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + questionViewTextConfiguration = QuestionTextConfiguration(showAsterisk = false), + ) + + setContent { QuestionnaireBooleanChoice(questionnaireViewItem) } + onNodeWithTag(QUESTION_HEADER_TAG).assertTextEquals("Question") + } + + @Test + fun showsRequiredText() = runComposeUiTest { + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "boolean-choice-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Boolean), + required = FhirR4Boolean(value = true), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "boolean-choice-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + questionViewTextConfiguration = QuestionTextConfiguration(showRequiredText = true), + ) + + setContent { QuestionnaireBooleanChoice(questionnaireViewItem) } + + onNodeWithTag(REQUIRED_OPTIONAL_HEADER_TEXT_TAG) + .assertIsDisplayed() + .assertTextEquals("Required") + } + + @Test + fun hideRequiredText() = runComposeUiTest { + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "boolean-choice-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Boolean), + required = FhirR4Boolean(value = true), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "boolean-choice-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + questionViewTextConfiguration = QuestionTextConfiguration(showRequiredText = false), + ) + + setContent { QuestionnaireBooleanChoice(questionnaireViewItem) } + + onNodeWithTag(REQUIRED_OPTIONAL_HEADER_TEXT_TAG).assertDoesNotExist() + } + + @Test + fun showOptionalText() = runComposeUiTest { + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "boolean-choice-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Boolean), + text = FhirR4String(value = "Question"), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "boolean-choice-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + questionViewTextConfiguration = QuestionTextConfiguration(showOptionalText = true), + ) + + setContent { QuestionnaireBooleanChoice(questionnaireViewItem) } + onNodeWithTag(REQUIRED_OPTIONAL_HEADER_TEXT_TAG) + .assertIsDisplayed() + .assertTextEquals("Optional") + } + + @Test + fun hideOptionalText() = runComposeUiTest { + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "boolean-choice-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Boolean), + text = FhirR4String(value = "Question"), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "boolean-choice-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + questionViewTextConfiguration = QuestionTextConfiguration(showOptionalText = false), + ) + + setContent { QuestionnaireBooleanChoice(questionnaireViewItem) } + onNodeWithTag(REQUIRED_OPTIONAL_HEADER_TEXT_TAG).assertDoesNotExist() + } +} diff --git a/datacapture/src/commonTest/kotlin/dev/ohs/fhir/datacapture/views/factories/CheckBoxGroupViewFactoryTest.kt b/datacapture/src/commonTest/kotlin/dev/ohs/fhir/datacapture/views/factories/CheckBoxGroupViewFactoryTest.kt new file mode 100644 index 000000000..cd8a7045d --- /dev/null +++ b/datacapture/src/commonTest/kotlin/dev/ohs/fhir/datacapture/views/factories/CheckBoxGroupViewFactoryTest.kt @@ -0,0 +1,1113 @@ +/* + * Copyright 2025-2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.ohs.fhir.datacapture.views.factories + +import androidx.compose.runtime.Composable +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assert +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.isNotEnabled +import androidx.compose.ui.test.isOff +import androidx.compose.ui.test.isOn +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.runComposeUiTest +import com.google.fhir.model.r4.Attachment +import com.google.fhir.model.r4.Base64Binary +import com.google.fhir.model.r4.Canonical +import com.google.fhir.model.r4.Code +import com.google.fhir.model.r4.CodeableConcept +import com.google.fhir.model.r4.Coding +import com.google.fhir.model.r4.Enumeration +import com.google.fhir.model.r4.Extension +import com.google.fhir.model.r4.Questionnaire +import com.google.fhir.model.r4.QuestionnaireResponse +import com.google.fhir.model.r4.Uri +import dev.ohs.fhir.datacapture.extensions.ChoiceOrientationTypes +import dev.ohs.fhir.datacapture.extensions.EXTENSION_CHOICE_ORIENTATION_URL +import dev.ohs.fhir.datacapture.extensions.EXTENSION_ITEM_ANSWER_MEDIA +import dev.ohs.fhir.datacapture.extensions.EXTENSION_ITEM_CONTROL_SYSTEM +import dev.ohs.fhir.datacapture.extensions.EXTENSION_ITEM_CONTROL_URL +import dev.ohs.fhir.datacapture.extensions.EXTENSION_OPTION_EXCLUSIVE_URL +import dev.ohs.fhir.datacapture.extensions.FhirR4Boolean +import dev.ohs.fhir.datacapture.extensions.FhirR4String +import dev.ohs.fhir.datacapture.theme.QuestionnaireTheme +import dev.ohs.fhir.datacapture.validation.Invalid +import dev.ohs.fhir.datacapture.validation.NotValidated +import dev.ohs.fhir.datacapture.views.QuestionTextConfiguration +import dev.ohs.fhir.datacapture.views.QuestionnaireViewItem +import dev.ohs.fhir.datacapture.views.components.CHOICE_CHECKBOX_IMAGE_TAG +import dev.ohs.fhir.datacapture.views.components.ERROR_TEXT_AT_HEADER_TEST_TAG +import dev.ohs.fhir.datacapture.views.components.QUESTION_HEADER_TAG +import dev.ohs.fhir.datacapture.views.components.REQUIRED_OPTIONAL_HEADER_TEXT_TAG +import io.kotest.matchers.collections.shouldBeEmpty +import io.kotest.matchers.comparables.shouldBeLessThan +import io.kotest.matchers.shouldBe +import kotlin.io.encoding.ExperimentalEncodingApi +import kotlin.test.Test + +@OptIn(ExperimentalTestApi::class) +class CheckBoxGroupViewFactoryTest { + + @Composable + fun QuestionnaireCheckBoxGroup(questionnaireViewItem: QuestionnaireViewItem) { + QuestionnaireTheme { CheckBoxGroupViewFactory.Content(questionnaireViewItem) } + } + + @OptIn(ExperimentalEncodingApi::class) + private val itemAnswerMediaExtension = + Extension( + url = EXTENSION_ITEM_ANSWER_MEDIA, + value = + Extension.Value.Attachment( + value = + Attachment( + data = + Base64Binary( + value = + "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", + ), + contentType = Code(value = "image/png"), + ), + ), + ) + + @Test + fun bind_shouldSetQuestionHeader() = runComposeUiTest { + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "checkbox-group-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Choice), + extension = + listOf( + Extension( + url = EXTENSION_ITEM_CONTROL_URL, + value = + Extension.Value.CodeableConcept( + value = + CodeableConcept( + coding = + listOf( + Coding( + system = Uri(value = EXTENSION_ITEM_CONTROL_SYSTEM), + code = Code(value = "check-box"), + ), + ), + ), + ), + ), + ), + repeats = FhirR4Boolean(value = true), + text = FhirR4String(value = "Question?"), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "checkbox-group-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + + setContent { QuestionnaireCheckBoxGroup(questionnaireViewItem) } + + onNodeWithTag(QUESTION_HEADER_TAG).assertTextEquals("Question?") + } + + @Test + fun bind_vertical_shouldCreateCheckBoxButtons() = runComposeUiTest { + val questionnaire = + Questionnaire.Item( + linkId = FhirR4String(value = "checkbox-group-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Choice), + extension = + listOf( + Extension( + url = EXTENSION_ITEM_CONTROL_URL, + value = + Extension.Value.CodeableConcept( + value = + CodeableConcept( + coding = + listOf( + Coding( + system = Uri(value = EXTENSION_ITEM_CONTROL_SYSTEM), + code = Code(value = "check-box"), + ), + ), + ), + ), + ), + Extension( + url = EXTENSION_CHOICE_ORIENTATION_URL, + value = + Extension.Value.Code( + value = Code(value = ChoiceOrientationTypes.VERTICAL.extensionCode), + ), + ), + ), + repeats = FhirR4Boolean(value = true), + answerOption = + listOf( + Questionnaire.Item.AnswerOption( + value = + Questionnaire.Item.AnswerOption.Value.Coding( + value = Coding(display = FhirR4String(value = "Coding 1")), + ), + ), + Questionnaire.Item.AnswerOption( + value = + Questionnaire.Item.AnswerOption.Value.Coding( + value = Coding(display = FhirR4String(value = "Coding 2")), + ), + ), + ), + ) + + setContent { + QuestionnaireCheckBoxGroup( + QuestionnaireViewItem( + questionnaire, + QuestionnaireResponse.Item(linkId = FhirR4String(value = "checkbox-group-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ), + ) + } + + onAllNodes(hasTestTag(CHECKBOX_OPTION_TAG)).assertCountEquals(2) + questionnaire.answerOption.forEachIndexed { index, opt -> + onAllNodes(hasTestTag(CHECKBOX_OPTION_TAG))[index].assertTextEquals( + opt.value.asCoding()?.value?.display?.value!!, + ) + + val node = onAllNodes(hasTestTag(CHECKBOX_OPTION_TAG))[index] + node.assertTextEquals(opt.value.asCoding()?.value?.display?.value!!) + + // Verify fills max width + val semanticsNode = node.fetchSemanticsNode() + val nodeWidth = semanticsNode.size.width + val parentWidth = + semanticsNode.layoutInfo.coordinates.parentLayoutCoordinates?.size?.width ?: 0 + nodeWidth.shouldBe(parentWidth) + } + } + + @Test + fun bind_horizontal_shouldCreateCheckBoxButtons() = runComposeUiTest { + val questionnaire = + Questionnaire.Item( + linkId = FhirR4String(value = "checkbox-group-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Choice), + extension = + listOf( + Extension( + url = EXTENSION_ITEM_CONTROL_URL, + value = + Extension.Value.CodeableConcept( + value = + CodeableConcept( + coding = + listOf( + Coding( + system = Uri(value = EXTENSION_ITEM_CONTROL_SYSTEM), + code = Code(value = "check-box"), + ), + ), + ), + ), + ), + Extension( + url = EXTENSION_CHOICE_ORIENTATION_URL, + value = + Extension.Value.Code( + value = Code(value = ChoiceOrientationTypes.HORIZONTAL.extensionCode), + ), + ), + ), + repeats = FhirR4Boolean(value = true), + answerOption = + listOf( + Questionnaire.Item.AnswerOption( + value = + Questionnaire.Item.AnswerOption.Value.Coding( + value = Coding(display = FhirR4String(value = "Coding 1")), + ), + ), + Questionnaire.Item.AnswerOption( + value = + Questionnaire.Item.AnswerOption.Value.Coding( + value = Coding(display = FhirR4String(value = "Coding 2")), + ), + ), + ), + ) + + setContent { + QuestionnaireCheckBoxGroup( + QuestionnaireViewItem( + questionnaire, + QuestionnaireResponse.Item(linkId = FhirR4String(value = "checkbox-group-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ), + ) + } + + onAllNodes(hasTestTag(CHECKBOX_OPTION_TAG)).assertCountEquals(2) + questionnaire.answerOption.forEachIndexed { index, opt -> + onAllNodes(hasTestTag(CHECKBOX_OPTION_TAG))[index].assertTextEquals( + opt.value.asCoding()?.value?.display?.value!!, + ) + + val node = onAllNodes(hasTestTag(CHECKBOX_OPTION_TAG))[index] + node.assertTextEquals(opt.value.asCoding()?.value?.display?.value!!) + + // Verify it doesn't fill max width + val semanticsNode = node.fetchSemanticsNode() + val nodeWidth = semanticsNode.size.width + val parentWidth = + semanticsNode.layoutInfo.coordinates.parentLayoutCoordinates?.size?.width ?: 0 + nodeWidth.shouldBeLessThan(parentWidth) + } + } + + @Test + fun bind_noAnswer_shouldLeaveCheckButtonsUnchecked() = runComposeUiTest { + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "checkbox-group-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Choice), + extension = + listOf( + Extension( + url = EXTENSION_ITEM_CONTROL_URL, + value = + Extension.Value.CodeableConcept( + value = + CodeableConcept( + coding = + listOf( + Coding( + system = Uri(value = EXTENSION_ITEM_CONTROL_SYSTEM), + code = Code(value = "check-box"), + ), + ), + ), + ), + ), + ), + repeats = FhirR4Boolean(value = true), + answerOption = + listOf( + Questionnaire.Item.AnswerOption( + value = + Questionnaire.Item.AnswerOption.Value.Coding( + value = Coding(display = FhirR4String(value = "Coding 1")), + ), + ), + ), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "checkbox-group-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + + setContent { QuestionnaireCheckBoxGroup(questionnaireViewItem) } + + onNode(hasTestTag(CHECKBOX_OPTION_TAG) and hasText("Coding 1")).assert(isOff()) + } + + @Test + fun bind_withImageInItemAnswerMediaExtension_shouldShowImageAfterCheckBox() = runComposeUiTest { + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "checkbox-group-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Choice), + extension = + listOf( + Extension( + url = EXTENSION_ITEM_CONTROL_URL, + value = + Extension.Value.CodeableConcept( + value = + CodeableConcept( + coding = + listOf( + Coding( + system = Uri(value = EXTENSION_ITEM_CONTROL_SYSTEM), + code = Code(value = "check-box"), + ), + ), + ), + ), + ), + ), + repeats = FhirR4Boolean(value = true), + answerOption = + listOf( + Questionnaire.Item.AnswerOption( + extension = listOf(itemAnswerMediaExtension), + value = + Questionnaire.Item.AnswerOption.Value.String( + value = FhirR4String(value = "Test Code"), + ), + ), + ), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "checkbox-group-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + + setContent { QuestionnaireCheckBoxGroup(questionnaireViewItem) } + + onNodeWithTag(CHOICE_CHECKBOX_IMAGE_TAG, useUnmergedTree = true).assertIsDisplayed() + } + + @Test + fun bind_answer_shouldSetCheckBoxButton() = runComposeUiTest { + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "checkbox-group-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Choice), + extension = + listOf( + Extension( + url = EXTENSION_ITEM_CONTROL_URL, + value = + Extension.Value.CodeableConcept( + value = + CodeableConcept( + coding = + listOf( + Coding( + system = Uri(value = EXTENSION_ITEM_CONTROL_SYSTEM), + code = Code(value = "check-box"), + ), + ), + ), + ), + ), + ), + repeats = FhirR4Boolean(value = true), + answerOption = + listOf( + Questionnaire.Item.AnswerOption( + value = + Questionnaire.Item.AnswerOption.Value.Coding( + value = + Coding( + code = Code(value = "code 1"), + display = FhirR4String(value = "Coding 1"), + ), + ), + ), + ), + ), + QuestionnaireResponse.Item( + linkId = FhirR4String(value = "checkbox-group-item"), + answer = + listOf( + QuestionnaireResponse.Item.Answer( + value = + QuestionnaireResponse.Item.Answer.Value.Coding( + value = + Coding( + code = Code(value = "code 1"), + display = FhirR4String(value = "Coding 1"), + ), + ), + ), + ), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + + setContent { QuestionnaireCheckBoxGroup(questionnaireViewItem) } + + onNode(hasTestTag(CHECKBOX_OPTION_TAG) and hasText("Coding 1")).assert(isOn()) + } + + @Test + fun click_shouldAddQuestionnaireResponseItemAnswer() = runComposeUiTest { + var answerHolder: List? = null + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "checkbox-group-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Choice), + extension = + listOf( + Extension( + url = EXTENSION_ITEM_CONTROL_URL, + value = + Extension.Value.CodeableConcept( + value = + CodeableConcept( + coding = + listOf( + Coding( + system = Uri(value = EXTENSION_ITEM_CONTROL_SYSTEM), + code = Code(value = "check-box"), + ), + ), + ), + ), + ), + ), + repeats = FhirR4Boolean(value = true), + answerOption = + listOf( + Questionnaire.Item.AnswerOption( + value = + Questionnaire.Item.AnswerOption.Value.Coding( + value = + Coding( + code = Code(value = "code 1"), + display = FhirR4String(value = "Coding 1"), + ), + ), + ), + ), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "checkbox-group-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, answers, _ -> answerHolder = answers }, + ) + setContent { QuestionnaireCheckBoxGroup(questionnaireViewItem) } + onNode(hasTestTag(CHECKBOX_OPTION_TAG) and hasText("Coding 1")).performClick() + waitUntil { answerHolder != null } + answerHolder!!.single().value?.asCoding()?.value?.display?.value.shouldBe("Coding 1") + } + + @Test + fun optionExclusiveAnswerOption_click_deselectsOtherAnswerOptions() = runComposeUiTest { + var answerHolder: List? = null + var answersUpdated = false + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "checkbox-group-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Choice), + extension = + listOf( + Extension( + url = EXTENSION_ITEM_CONTROL_URL, + value = + Extension.Value.CodeableConcept( + value = + CodeableConcept( + coding = + listOf( + Coding( + system = Uri(value = EXTENSION_ITEM_CONTROL_SYSTEM), + code = Code(value = "check-box"), + ), + ), + ), + ), + ), + ), + repeats = FhirR4Boolean(value = true), + answerOption = + listOf( + Questionnaire.Item.AnswerOption( + value = + Questionnaire.Item.AnswerOption.Value.Coding( + value = + Coding( + code = Code(value = "code-1"), + display = FhirR4String(value = "display-1"), + ), + ), + extension = + listOf( + Extension( + url = EXTENSION_OPTION_EXCLUSIVE_URL, + value = Extension.Value.Boolean(FhirR4Boolean(value = true)), + ), + ), + ), + Questionnaire.Item.AnswerOption( + value = + Questionnaire.Item.AnswerOption.Value.Coding( + value = + Coding( + code = Code(value = "code-2"), + display = FhirR4String(value = "display-2"), + ), + ), + ), + ), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "checkbox-group-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, answers, _ -> + answerHolder = answers + answersUpdated = true + }, + ) + setContent { QuestionnaireCheckBoxGroup(questionnaireViewItem) } + onNode(hasTestTag(CHECKBOX_OPTION_TAG) and hasText("display-2")).performClick() + waitUntil { answersUpdated } + answersUpdated = false + onNode(hasTestTag(CHECKBOX_OPTION_TAG) and hasText("display-1")).performClick() + waitUntil { answersUpdated } + answerHolder!!.single().value?.asCoding()?.value?.display?.value.shouldBe("display-1") + } + + @Test + fun answerOption_click_deselectsOptionExclusiveAnswerOption() = runComposeUiTest { + var answerHolder: List? = null + var answersUpdated = false + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "checkbox-group-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Choice), + extension = + listOf( + Extension( + url = EXTENSION_ITEM_CONTROL_URL, + value = + Extension.Value.CodeableConcept( + value = + CodeableConcept( + coding = + listOf( + Coding( + system = Uri(value = EXTENSION_ITEM_CONTROL_SYSTEM), + code = Code(value = "check-box"), + ), + ), + ), + ), + ), + ), + repeats = FhirR4Boolean(value = true), + answerOption = + listOf( + Questionnaire.Item.AnswerOption( + value = + Questionnaire.Item.AnswerOption.Value.Coding( + value = + Coding( + code = Code(value = "code-1"), + display = FhirR4String(value = "display-1"), + ), + ), + extension = + listOf( + Extension( + url = EXTENSION_OPTION_EXCLUSIVE_URL, + value = Extension.Value.Boolean(FhirR4Boolean(value = true)), + ), + ), + ), + Questionnaire.Item.AnswerOption( + value = + Questionnaire.Item.AnswerOption.Value.Coding( + value = + Coding( + code = Code(value = "code-2"), + display = FhirR4String(value = "display-2"), + ), + ), + ), + ), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "checkbox-group-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, answers, _ -> + answerHolder = answers + answersUpdated = true + }, + ) + + setContent { QuestionnaireCheckBoxGroup(questionnaireViewItem) } + onNode(hasTestTag(CHECKBOX_OPTION_TAG) and hasText("display-1")).performClick() + waitUntil { answersUpdated } + answersUpdated = false + onNode(hasTestTag(CHECKBOX_OPTION_TAG) and hasText("display-2")).performClick() + waitUntil { answersUpdated } + answerHolder!!.single().value?.asCoding()?.value?.display?.value!!.shouldBe("display-2") + } + + @Test + fun click_shouldRemoveQuestionnaireResponseItemAnswer() = runComposeUiTest { + var answerHolder: List? = null + val fakeAnswerValueSetResolver = { uri: String -> + if (uri == "http://coding-value-set-url") { + listOf( + Questionnaire.Item.AnswerOption( + value = + Questionnaire.Item.AnswerOption.Value.Coding( + value = + Coding(code = Code(value = "code 1"), display = FhirR4String(value = "Coding 1")), + ), + ), + ) + } else { + emptyList() + } + } + val questionnaireItem = + Questionnaire.Item( + linkId = FhirR4String(value = "checkbox-group-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Choice), + extension = + listOf( + Extension( + url = EXTENSION_ITEM_CONTROL_URL, + value = + Extension.Value.CodeableConcept( + value = + CodeableConcept( + coding = + listOf( + Coding( + system = Uri(value = EXTENSION_ITEM_CONTROL_SYSTEM), + code = Code(value = "check-box"), + ), + ), + ), + ), + ), + ), + repeats = FhirR4Boolean(value = true), + answerValueSet = Canonical(value = "http://coding-value-set-url"), + ) + + val questionnaireViewItem = + QuestionnaireViewItem( + questionnaireItem, + QuestionnaireResponse.Item( + linkId = FhirR4String(value = "checkbox-group-item"), + answer = + listOf( + QuestionnaireResponse.Item.Answer( + value = + QuestionnaireResponse.Item.Answer.Value.Coding( + value = + Coding( + code = Code(value = "code 1"), + display = FhirR4String(value = "Coding 1"), + ), + ), + ), + ), + ), + enabledAnswerOptions = + fakeAnswerValueSetResolver.invoke(questionnaireItem.answerValueSet?.value!!), + validationResult = NotValidated, + answersChangedCallback = { _, _, answers, _ -> answerHolder = answers }, + ) + setContent { QuestionnaireCheckBoxGroup(questionnaireViewItem) } + onNode(hasTestTag(CHECKBOX_OPTION_TAG) and hasText("Coding 1")).performClick() + waitUntil { answerHolder != null } + + answerHolder.shouldBeEmpty() + } + + @Test + fun displayValidationResult_error_shouldShowErrorMessage() = runComposeUiTest { + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "checkbox-group-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Choice), + extension = + listOf( + Extension( + url = EXTENSION_ITEM_CONTROL_URL, + value = + Extension.Value.CodeableConcept( + value = + CodeableConcept( + coding = + listOf( + Coding( + system = Uri(value = EXTENSION_ITEM_CONTROL_SYSTEM), + code = Code(value = "check-box"), + ), + ), + ), + ), + ), + ), + repeats = FhirR4Boolean(value = true), + required = FhirR4Boolean(value = true), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "checkbox-group-item")), + validationResult = Invalid(listOf("Missing answer for required field.")), + answersChangedCallback = { _, _, _, _ -> }, + ) + + setContent { QuestionnaireCheckBoxGroup(questionnaireViewItem) } + + onNodeWithTag(ERROR_TEXT_AT_HEADER_TEST_TAG) + .assertTextEquals("Missing answer for required field.") + } + + @Test + fun displayValidationResult_noError_shouldShowNoErrorMessage() = runComposeUiTest { + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "checkbox-group-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Choice), + extension = + listOf( + Extension( + url = EXTENSION_ITEM_CONTROL_URL, + value = + Extension.Value.CodeableConcept( + value = + CodeableConcept( + coding = + listOf( + Coding( + system = Uri(value = EXTENSION_ITEM_CONTROL_SYSTEM), + code = Code(value = "check-box"), + ), + ), + ), + ), + ), + ), + repeats = FhirR4Boolean(value = true), + required = FhirR4Boolean(value = true), + answerOption = + listOf( + Questionnaire.Item.AnswerOption( + value = + Questionnaire.Item.AnswerOption.Value.Coding( + value = Coding(display = FhirR4String(value = "display")), + ), + ), + ), + ), + QuestionnaireResponse.Item( + linkId = FhirR4String(value = "checkbox-group-item"), + answer = + listOf( + QuestionnaireResponse.Item.Answer( + value = + QuestionnaireResponse.Item.Answer.Value.Coding( + value = Coding(display = FhirR4String(value = "display")), + ), + ), + ), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + setContent { QuestionnaireCheckBoxGroup(questionnaireViewItem) } + + onNodeWithTag(ERROR_TEXT_AT_HEADER_TEST_TAG).assertDoesNotExist() + } + + @Test + fun bind_readOnly_shouldDisableView() = runComposeUiTest { + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "checkbox-group-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Choice), + extension = + listOf( + Extension( + url = EXTENSION_ITEM_CONTROL_URL, + value = + Extension.Value.CodeableConcept( + value = + CodeableConcept( + coding = + listOf( + Coding( + system = Uri(value = EXTENSION_ITEM_CONTROL_SYSTEM), + code = Code(value = "check-box"), + ), + ), + ), + ), + ), + ), + repeats = FhirR4Boolean(value = true), + answerOption = + listOf( + Questionnaire.Item.AnswerOption( + value = + Questionnaire.Item.AnswerOption.Value.Coding( + value = Coding(display = FhirR4String(value = "Coding 1")), + ), + ), + ), + readOnly = FhirR4Boolean(value = true), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "checkbox-group-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + + setContent { QuestionnaireCheckBoxGroup(questionnaireViewItem) } + + onNode(hasTestTag(CHECKBOX_OPTION_TAG) and hasText("Coding 1")).assert(isNotEnabled()) + } + + @Test + fun showAsterisk() = runComposeUiTest { + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "checkbox-group-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Choice), + extension = + listOf( + Extension( + url = EXTENSION_ITEM_CONTROL_URL, + value = + Extension.Value.CodeableConcept( + value = + CodeableConcept( + coding = + listOf( + Coding( + system = Uri(value = EXTENSION_ITEM_CONTROL_SYSTEM), + code = Code(value = "check-box"), + ), + ), + ), + ), + ), + ), + required = FhirR4Boolean(value = true), + text = FhirR4String(value = "Question"), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "checkbox-group-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + questionViewTextConfiguration = QuestionTextConfiguration(showAsterisk = true), + ) + + setContent { QuestionnaireCheckBoxGroup(questionnaireViewItem) } + + onNodeWithTag(QUESTION_HEADER_TAG).assertTextEquals("Question *") + } + + @Test + fun hideAsterisk() = runComposeUiTest { + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "checkbox-group-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Choice), + extension = + listOf( + Extension( + url = EXTENSION_ITEM_CONTROL_URL, + value = + Extension.Value.CodeableConcept( + value = + CodeableConcept( + coding = + listOf( + Coding( + system = Uri(value = EXTENSION_ITEM_CONTROL_SYSTEM), + code = Code(value = "check-box"), + ), + ), + ), + ), + ), + ), + required = FhirR4Boolean(value = true), + text = FhirR4String(value = "Question"), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "checkbox-group-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + questionViewTextConfiguration = QuestionTextConfiguration(showAsterisk = false), + ) + + setContent { QuestionnaireCheckBoxGroup(questionnaireViewItem) } + + onNodeWithTag(QUESTION_HEADER_TAG).assertTextEquals("Question") + } + + @Test + fun showRequiredText() = runComposeUiTest { + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "checkbox-group-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Choice), + extension = + listOf( + Extension( + url = EXTENSION_ITEM_CONTROL_URL, + value = + Extension.Value.CodeableConcept( + value = + CodeableConcept( + coding = + listOf( + Coding( + system = Uri(value = EXTENSION_ITEM_CONTROL_SYSTEM), + code = Code(value = "check-box"), + ), + ), + ), + ), + ), + ), + required = FhirR4Boolean(value = true), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "checkbox-group-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + questionViewTextConfiguration = QuestionTextConfiguration(showRequiredText = true), + ) + + setContent { QuestionnaireCheckBoxGroup(questionnaireViewItem) } + + onNodeWithTag(REQUIRED_OPTIONAL_HEADER_TEXT_TAG).assertTextEquals("Required") + } + + @Test + fun hideRequiredText() = runComposeUiTest { + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "checkbox-group-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Choice), + extension = + listOf( + Extension( + url = EXTENSION_ITEM_CONTROL_URL, + value = + Extension.Value.CodeableConcept( + value = + CodeableConcept( + coding = + listOf( + Coding( + system = Uri(value = EXTENSION_ITEM_CONTROL_SYSTEM), + code = Code(value = "check-box"), + ), + ), + ), + ), + ), + ), + required = FhirR4Boolean(value = true), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "checkbox-group-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + questionViewTextConfiguration = QuestionTextConfiguration(showRequiredText = false), + ) + + setContent { QuestionnaireCheckBoxGroup(questionnaireViewItem) } + + onNodeWithTag(REQUIRED_OPTIONAL_HEADER_TEXT_TAG).assertDoesNotExist() + } + + @Test + fun showOptionalText() = runComposeUiTest { + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "checkbox-group-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Choice), + extension = + listOf( + Extension( + url = EXTENSION_ITEM_CONTROL_URL, + value = + Extension.Value.CodeableConcept( + value = + CodeableConcept( + coding = + listOf( + Coding( + system = Uri(value = EXTENSION_ITEM_CONTROL_SYSTEM), + code = Code(value = "check-box"), + ), + ), + ), + ), + ), + ), + text = FhirR4String(value = "Question"), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "checkbox-group-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + questionViewTextConfiguration = QuestionTextConfiguration(showOptionalText = true), + ) + + setContent { QuestionnaireCheckBoxGroup(questionnaireViewItem) } + + onNodeWithTag(REQUIRED_OPTIONAL_HEADER_TEXT_TAG).assertTextEquals("Optional") + } + + @Test + fun hideOptionalText() = runComposeUiTest { + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "checkbox-group-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Choice), + extension = + listOf( + Extension( + url = EXTENSION_ITEM_CONTROL_URL, + value = + Extension.Value.CodeableConcept( + value = + CodeableConcept( + coding = + listOf( + Coding( + system = Uri(value = EXTENSION_ITEM_CONTROL_SYSTEM), + code = Code(value = "check-box"), + ), + ), + ), + ), + ), + ), + text = FhirR4String(value = "Question?"), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "checkbox-group-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + questionViewTextConfiguration = QuestionTextConfiguration(showOptionalText = false), + ) + + setContent { QuestionnaireCheckBoxGroup(questionnaireViewItem) } + + onNodeWithTag(REQUIRED_OPTIONAL_HEADER_TEXT_TAG).assertDoesNotExist() + } +} diff --git a/datacapture/src/commonTest/kotlin/dev/ohs/fhir/datacapture/views/factories/DateViewFactoryTest.kt b/datacapture/src/commonTest/kotlin/dev/ohs/fhir/datacapture/views/factories/DateViewFactoryTest.kt new file mode 100644 index 000000000..dd40373f4 --- /dev/null +++ b/datacapture/src/commonTest/kotlin/dev/ohs/fhir/datacapture/views/factories/DateViewFactoryTest.kt @@ -0,0 +1,1239 @@ +/* + * Copyright 2025-2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.ohs.fhir.datacapture.views.factories + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.test.assert +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.assertTextContains +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performSemanticsAction +import androidx.compose.ui.test.performTextClearance +import androidx.compose.ui.test.performTextInput +import androidx.compose.ui.test.performTextReplacement +import androidx.compose.ui.test.runComposeUiTest +import com.google.fhir.model.r4.Enumeration +import com.google.fhir.model.r4.Extension +import com.google.fhir.model.r4.FhirDate +import com.google.fhir.model.r4.Questionnaire +import com.google.fhir.model.r4.QuestionnaireResponse +import dev.ohs.fhir.datacapture.extensions.EXTENSION_ENTRY_FORMAT_URL +import dev.ohs.fhir.datacapture.extensions.FhirR4Boolean +import dev.ohs.fhir.datacapture.extensions.FhirR4DateType +import dev.ohs.fhir.datacapture.extensions.FhirR4String +import dev.ohs.fhir.datacapture.extensions.toAnnotatedString +import dev.ohs.fhir.datacapture.theme.QuestionnaireTheme +import dev.ohs.fhir.datacapture.validation.Invalid +import dev.ohs.fhir.datacapture.validation.NotValidated +import dev.ohs.fhir.datacapture.views.QuestionTextConfiguration +import dev.ohs.fhir.datacapture.views.QuestionnaireViewItem +import dev.ohs.fhir.datacapture.views.components.DATE_TEXT_INPUT_FIELD +import dev.ohs.fhir.datacapture.views.components.ERROR_TEXT_AT_HEADER_TEST_TAG +import dev.ohs.fhir.datacapture.views.components.QUESTION_HEADER_TAG +import io.kotest.matchers.collections.shouldBeEmpty +import io.kotest.matchers.nulls.shouldBeNull +import io.kotest.matchers.nulls.shouldNotBeNull +import io.kotest.matchers.shouldBe +import kotlin.test.Ignore +import kotlin.test.Test +import kotlin.test.assertTrue +import kotlinx.datetime.LocalDate +import kotlinx.datetime.number + +@OptIn(ExperimentalTestApi::class) +class DateViewFactoryTest { + + @Composable + fun QuestionnaireDateView(questionnaireViewItem: QuestionnaireViewItem) { + QuestionnaireTheme { DateViewFactory.Content(questionnaireViewItem) } + } + + @Test + fun shouldSetQuestionHeader() = runComposeUiTest { + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "date-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Date), + text = FhirR4String(value = "Question?"), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "date-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + setContent { QuestionnaireDateView(questionnaireViewItem) } + + onNodeWithTag(QUESTION_HEADER_TAG).assertTextEquals("Question?") + } + + @Test + fun shouldSetEmptyDateInput() = runComposeUiTest { + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "date-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Date), + text = FhirR4String(value = "Question?"), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "date-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + + setContent { QuestionnaireDateView(questionnaireViewItem) } + + onNodeWithTag(DATE_TEXT_INPUT_FIELD, useUnmergedTree = true).assertTextEquals("") + } + + @Test + fun shouldSetTextFieldEmptyWhenDateFieldIsInitializedButAnswerDateValueIsNull() = + runComposeUiTest { + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "date-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Date), + text = FhirR4String(value = "Question?"), + ), + QuestionnaireResponse.Item( + linkId = FhirR4String(value = "date-item"), + answer = + listOf( + QuestionnaireResponse.Item.Answer( + value = + QuestionnaireResponse.Item.Answer.Value.Date( + value = FhirR4DateType(value = null), + ), + ), + ), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + setContent { QuestionnaireDateView(questionnaireViewItem) } + onNodeWithTag(DATE_TEXT_INPUT_FIELD, useUnmergedTree = true).assertTextEquals("") + } + + @Test + @Ignore + fun shouldSetDateInput_localeUs() = + runComposeUiTest { + // setLocale(Locale.US) + // val questionnaireViewItem = + // QuestionnaireViewItem( + // Questionnaire.Item( + // linkId = FhirR4String(value = "date-item"), + // type = Enumeration(value = Questionnaire.QuestionnaireItemType.Date), + // text = FhirR4String(value = "Question?"), + // ), + // QuestionnaireResponse.Item( + // linkId = FhirR4String(value = "date-item"), + // answer = + // listOf( + // QuestionnaireResponse.Item.Answer( + // value = + // QuestionnaireResponse.Item.Answer.Value.Date( + // value = FhirR4DateType(value = FhirDate.fromString("2020-10-19")), + // ), + // ), + // ), + // ), + // validationResult = NotValidated, + // answersChangedCallback = { _, _, _, _ -> }, + // ) + // + // setContent { QuestionnaireDateView(questionnaireViewItem) } + // + // onNodeWithTag(DATE_TEXT_INPUT_FIELD, useUnmergedTree = + // true).assertTextEquals("11/19/2020") + } + + @Test + fun showDateFormatLabelInLowerCase() = runComposeUiTest { + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "date-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Date), + extension = + listOf( + Extension( + url = EXTENSION_ENTRY_FORMAT_URL, + value = Extension.Value.String(value = FhirR4String(value = "mm/dd/yyyy")), + ), + ), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "date-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + + setContent { QuestionnaireDateView(questionnaireViewItem) } + + onNodeWithTag(DATE_TEXT_INPUT_FIELD).assertTextEquals("mm/dd/yyyy", includeEditableText = false) + } + + @Test + @Ignore + fun shouldSetDateInput_localeJp() = + runComposeUiTest { + // setLocale(Locale.JAPAN) + // val questionnaireViewItem = QuestionnaireViewItem( + // Questionnaire.Item( + // linkId = FhirR4String(value = "date-item"), + // type = Enumeration(value = Questionnaire.QuestionnaireItemType.Date), + // text = FhirR4String(value = "Question?") + // ), + // QuestionnaireResponse.Item(linkId = FhirR4String(value = "date-item"), + // answer = listOf( + // QuestionnaireResponse.Item.Answer( + // value = QuestionnaireResponse.Item.Answer.Value.Date(value = + // FhirR4DateType(value = + // FhirDate.Date(LocalDate(2020, 10, 19)))) + // ) + // )), + // validationResult = NotValidated, + // + // answersChangedCallback = { _, _, _, _ -> }, + // ) + // + // setContent { QuestionnaireDateView(questionnaireViewItem) } + // onNodeWithTag(DATE_TEXT_INPUT_FIELD, useUnmergedTree = true) + // .assertTextEquals("2020/11/19") + } + + @Test + @Ignore + fun shouldSetDateInput_localeEn() = + runComposeUiTest { + // setLocale(Locale.ENGLISH) + // val questionnaireViewItem = QuestionnaireViewItem( + // Questionnaire.Item( + // linkId = FhirR4String(value = "date-item"), + // type = Enumeration(value = Questionnaire.QuestionnaireItemType.Date), + // text = FhirR4String(value = "Question?") + // ), + // QuestionnaireResponse.Item(linkId = FhirR4String(value = "date-item"), + // answer = listOf( + // QuestionnaireResponse.Item.Answer( + // value = QuestionnaireResponse.Item.Answer.Value.Date(value = + // FhirR4DateType(value = + // FhirDate.Date(LocalDate(2020, 10, 19)))) + // ) + // )), + // validationResult = NotValidated, + // answersChangedCallback = { _, _, _, _ -> }, + // ) + // + // setContent { QuestionnaireDateView(questionnaireViewItem) } + // + // onNodeWithTag(DATE_TEXT_INPUT_FIELD, useUnmergedTree = true) + // .assertTextEquals("11/19/2020") + } + + @Test + fun shouldSetDateInput() = runComposeUiTest { + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "date-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Date), + text = FhirR4String(value = "Question?"), + extension = + listOf( + Extension( + url = EXTENSION_ENTRY_FORMAT_URL, + value = Extension.Value.String(value = FhirR4String(value = "dd/MM/yyyy")), + ), + ), + ), + QuestionnaireResponse.Item( + linkId = FhirR4String(value = "date-item"), + answer = + listOf( + QuestionnaireResponse.Item.Answer( + value = + QuestionnaireResponse.Item.Answer.Value.Date( + value = FhirR4DateType(value = FhirDate.Date(LocalDate(2020, 11, 19))), + ), + ), + ), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + + setContent { QuestionnaireDateView(questionnaireViewItem) } + + onNodeWithTag(DATE_TEXT_INPUT_FIELD, useUnmergedTree = true).assertTextEquals("19/11/2020") + } + + @Test + @Ignore + fun parseDateTextInputInUsLocale() = + runComposeUiTest { + // setLocale(Locale.US) + // var answers: List? = null + // val item = + // QuestionnaireViewItem( + // Questionnaire.Item( + // linkId = FhirR4String(value = "date-item"), + // type = Enumeration(value = Questionnaire.QuestionnaireItemType.Date), + // ), + // QuestionnaireResponse.Item(linkId = FhirR4String(value = "date-item")), + // validationResult = NotValidated, + // answersChangedCallback = { _, _, result, _ -> answers = result }, + // ) + // + // setContent { QuestionnaireDateView(item) } + // onNodeWithTag(DATE_TEXT_INPUT_FIELD).performTextInput("11/19/2020") + // waitUntil { answers != null } + // + // val answer = (answers!!.single().value?.asDate()?.value?.value as? FhirDate.Date)?.date + // + // answer?.day.shouldBe(19) + // answer?.month.shouldBe(10) + // answer?.year.shouldBe(2020) + } + + @Test + @Ignore + fun parseDateTextInputInJapanLocale() = + runComposeUiTest { + // setLocale(Locale.JAPAN) + // var answers: List? = null + // val item = + // QuestionnaireViewItem( + // Questionnaire.Item( + // linkId = FhirR4String(value = "date-item"), + // type = Enumeration(value = Questionnaire.QuestionnaireItemType.Date), + // ), + // QuestionnaireResponse.Item(linkId = FhirR4String(value = "date-item")), + // validationResult = NotValidated, + // answersChangedCallback = { _, _, result, _ -> answers = result }, + // ) + // setContent { QuestionnaireDateView(item) } + // onNodeWithTag(DATE_TEXT_INPUT_FIELD).performTextInput("2020/11/19") + // waitUntil { answers != null } + // val answer = (answers!!.single().value?.asDate()?.value?.value as? FhirDate.Date)?.date + // + // answer?.day.shouldBe(19) + // answer?.month.shouldBe(10) + // answer?.year.shouldBe(2020) + } + + @Test + fun parseDateTextInput() = runComposeUiTest { + var answers: List? = null + val item = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "date-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Date), + extension = + listOf( + Extension( + url = EXTENSION_ENTRY_FORMAT_URL, + value = Extension.Value.String(value = FhirR4String(value = "MM/dd/yyyy")), + ), + ), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "date-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, result, _ -> answers = result }, + ) + + setContent { QuestionnaireDateView(item) } + onNodeWithTag(DATE_TEXT_INPUT_FIELD).performTextInput("11/19/2020") + waitUntil { answers != null } + + val answer = (answers!!.single().value?.asDate()?.value?.value as? FhirDate.Date)?.date + + answer.shouldNotBeNull() + answer.day.shouldBe(19) + answer.month.number.shouldBe(11) + answer.year.shouldBe(2020) + } + + @Test + fun clearTheAnswerIfDateInputIsInvalid() = runComposeUiTest { + var answers: List? = null + val questionnaireItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "date-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Date), + extension = + listOf( + Extension( + url = EXTENSION_ENTRY_FORMAT_URL, + value = Extension.Value.String(value = FhirR4String(value = "MM/dd/yyyy")), + ), + ), + ), + QuestionnaireResponse.Item( + linkId = FhirR4String(value = "date-item"), + answer = + listOf( + QuestionnaireResponse.Item.Answer( + value = + QuestionnaireResponse.Item.Answer.Value.Date( + value = FhirR4DateType(value = FhirDate.Date(LocalDate(2020, 11, 19))), + ), + ), + ), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, result, _ -> answers = result }, + ) + setContent { QuestionnaireDateView(questionnaireItem) } + onNodeWithTag(DATE_TEXT_INPUT_FIELD, useUnmergedTree = true).assertTextEquals("11/19/2020") + val dateTextInput = "11/19" + onNodeWithTag(DATE_TEXT_INPUT_FIELD).performSemanticsAction( + SemanticsActions.SetText, + ) { + it(dateTextInput.toAnnotatedString()) + } + waitUntil { answers != null } + answers.shouldBeEmpty() + } + + @Test + fun doNotClearTheTextFieldInputForInvalidDate() = runComposeUiTest { + val questionnaireItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "date-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Date), + extension = + listOf( + Extension( + url = EXTENSION_ENTRY_FORMAT_URL, + value = Extension.Value.String(value = FhirR4String(value = "MM/dd/yyyy")), + ), + ), + ), + QuestionnaireResponse.Item( + linkId = FhirR4String(value = "date-item"), + answer = + listOf( + QuestionnaireResponse.Item.Answer( + value = + QuestionnaireResponse.Item.Answer.Value.Date( + value = FhirR4DateType(value = FhirDate.Date(LocalDate(2020, 11, 19))), + ), + ), + ), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + setContent { QuestionnaireDateView(questionnaireItem) } + onNodeWithTag(DATE_TEXT_INPUT_FIELD, useUnmergedTree = true).assertTextEquals("11/19/2020") + onNodeWithTag(DATE_TEXT_INPUT_FIELD).performSemanticsAction( + SemanticsActions.SetText, + ) { + it("11/19".toAnnotatedString()) + } + onNodeWithTag(DATE_TEXT_INPUT_FIELD, useUnmergedTree = true).assertTextEquals("11/19") + } + + @Test + fun clearQuestionnaireResponseAnswerOnDraftAnswerUpdate() = runComposeUiTest { + var answers: List? = + listOf(QuestionnaireResponse.Item.Answer()) + val questionnaireItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "date-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Date), + extension = + listOf( + Extension( + url = EXTENSION_ENTRY_FORMAT_URL, + value = Extension.Value.String(value = FhirR4String(value = "MM/dd/yyyy")), + ), + ), + ), + QuestionnaireResponse.Item( + linkId = FhirR4String(value = "date-item"), + answer = + listOf( + QuestionnaireResponse.Item.Answer( + value = + QuestionnaireResponse.Item.Answer.Value.Date( + value = FhirR4DateType(value = FhirDate.Date(LocalDate(2020, 10, 19))), + ), + ), + ), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, answersForCallback, _ -> answers = answersForCallback }, + ) + + setContent { QuestionnaireDateView(questionnaireItem) } + questionnaireItem.setDraftAnswer("02/07") + waitUntil { answers != null } + answers.shouldBeEmpty() + } + + @Test + fun clearDraftValueOnAnValidAnswerUpdate() = runComposeUiTest { + val answer = + QuestionnaireResponse.Item.Answer( + value = + QuestionnaireResponse.Item.Answer.Value.Date( + value = FhirR4DateType(value = FhirDate.Date(LocalDate(2026, 1, 1))), + ), + ) + var partialValue: String? = "02/07" + val questionnaireItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "date-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Date), + extension = + listOf( + Extension( + url = EXTENSION_ENTRY_FORMAT_URL, + value = Extension.Value.String(value = FhirR4String(value = "MM/dd/yyyy")), + ), + ), + ), + QuestionnaireResponse.Item( + linkId = FhirR4String(value = "date-item"), + answer = + listOf( + QuestionnaireResponse.Item.Answer( + value = + QuestionnaireResponse.Item.Answer.Value.Date( + value = FhirR4DateType(value = FhirDate.Date(LocalDate(2020, 11, 19))), + ), + ), + ), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, partialAnswer -> + partialValue = partialAnswer as? String + }, + ) + + setContent { QuestionnaireDateView(questionnaireItem) } + questionnaireItem.setAnswer(answer) + partialValue.shouldBeNull() + } + + @Test + fun displayPartialAnswerInTheTextFieldOfRecycledItems() = runComposeUiTest { + var questionnaireItem by + mutableStateOf( + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "date-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Date), + extension = + listOf( + Extension( + url = EXTENSION_ENTRY_FORMAT_URL, + value = Extension.Value.String(value = FhirR4String(value = "MM/dd/yyyy")), + ), + ), + ), + QuestionnaireResponse.Item( + linkId = FhirR4String(value = "date-item"), + answer = + listOf( + QuestionnaireResponse.Item.Answer( + value = + QuestionnaireResponse.Item.Answer.Value.Date( + value = FhirR4DateType(value = FhirDate.Date(LocalDate(2020, 11, 19))), + ), + ), + ), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ), + ) + + setContent { QuestionnaireDateView(questionnaireItem) } + onNodeWithTag(DATE_TEXT_INPUT_FIELD, useUnmergedTree = true).assertTextEquals("11/19/2020") + + questionnaireItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "date-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Date), + extension = + listOf( + Extension( + url = EXTENSION_ENTRY_FORMAT_URL, + value = Extension.Value.String(value = FhirR4String(value = "MM/dd/yyyy")), + ), + ), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "date-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + draftAnswer = "02/07", + ) + + onNodeWithTag(DATE_TEXT_INPUT_FIELD, useUnmergedTree = true).assertTextEquals("02/07") + } + + @Test + fun displayAnAnswerInTheTextFieldOfPartiallyAnsweredRecycledItem() = runComposeUiTest { + var questionnaireItem by + mutableStateOf( + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "date-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Date), + extension = + listOf( + Extension( + url = EXTENSION_ENTRY_FORMAT_URL, + value = Extension.Value.String(value = FhirR4String(value = "MM/dd/yyyy")), + ), + ), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "date-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + draftAnswer = "02/07", + ), + ) + + setContent { QuestionnaireDateView(questionnaireItem) } + onNodeWithTag(DATE_TEXT_INPUT_FIELD, useUnmergedTree = true).assertTextEquals("02/07") + + questionnaireItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "date-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Date), + extension = + listOf( + Extension( + url = EXTENSION_ENTRY_FORMAT_URL, + value = Extension.Value.String(value = FhirR4String(value = "MM/dd/yyyy")), + ), + ), + ), + QuestionnaireResponse.Item( + linkId = FhirR4String(value = "date-item"), + answer = + listOf( + QuestionnaireResponse.Item.Answer( + value = + QuestionnaireResponse.Item.Answer.Value.Date( + value = FhirR4DateType(value = FhirDate.Date(LocalDate(2020, 11, 19))), + ), + ), + ), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + + onNodeWithTag(DATE_TEXT_INPUT_FIELD, useUnmergedTree = true).assertTextEquals("11/19/2020") + } + + @Test + fun displayValidationResult_error_shouldShowErrorMessage() = runComposeUiTest { + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "date-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Date), + required = FhirR4Boolean(value = true), + extension = + listOf( + Extension( + url = "http://hl7.org/fhir/StructureDefinition/minValue", + value = + Extension.Value.Date( + value = FhirR4DateType(value = FhirDate.fromString("2020-01-01")), + ), + ), + Extension( + url = "http://hl7.org/fhir/StructureDefinition/maxValue", + value = + Extension.Value.Date( + value = FhirR4DateType(value = FhirDate.fromString("2025-01-01")), + ), + ), + ), + ), + QuestionnaireResponse.Item( + linkId = FhirR4String(value = "date-item"), + answer = + listOf( + QuestionnaireResponse.Item.Answer( + value = + QuestionnaireResponse.Item.Answer.Value.Date( + value = FhirR4DateType(value = FhirDate.fromString("2026-01-01")), + ), + ), + ), + ), + validationResult = Invalid(listOf("Maximum value allowed is:2025-01-01")), + answersChangedCallback = { _, _, _, _ -> }, + ) + + setContent { QuestionnaireDateView(questionnaireViewItem) } + + onNodeWithTag(DATE_TEXT_INPUT_FIELD) + .assert( + SemanticsMatcher.expectValue( + SemanticsProperties.Error, + "Maximum value allowed is:2025-01-01", + ), + ) + } + + @Test + fun showDateFormatInLowercaseInTheErrorMessage() = runComposeUiTest { + val itemViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "date-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Date), + extension = + listOf( + Extension( + url = EXTENSION_ENTRY_FORMAT_URL, + value = Extension.Value.String(value = FhirR4String(value = "MM/dd/yyyy")), + ), + ), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "date-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + draftAnswer = "11/19/202", + ) + + setContent { QuestionnaireDateView(itemViewItem) } + + onNodeWithTag(DATE_TEXT_INPUT_FIELD) + .assert( + SemanticsMatcher.expectValue( + SemanticsProperties.Error, + "Date format needs to be mm/dd/yyyy (e.g. 01/31/2023)", + ), + ) + } + + @Test + fun displayValidationResult_noError_shouldShowNoErrorMessage() = runComposeUiTest { + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "date-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Date), + extension = + listOf( + Extension( + url = "http://hl7.org/fhir/StructureDefinition/minValue", + value = + Extension.Value.Date( + value = FhirR4DateType(value = FhirDate.fromString("2020-01-01")), + ), + ), + Extension( + url = "http://hl7.org/fhir/StructureDefinition/maxValue", + value = + Extension.Value.Date( + value = FhirR4DateType(value = FhirDate.fromString("2025-01-01")), + ), + ), + ), + ), + QuestionnaireResponse.Item( + linkId = FhirR4String(value = "date-item"), + answer = + listOf( + QuestionnaireResponse.Item.Answer( + value = + QuestionnaireResponse.Item.Answer.Value.Date( + value = FhirR4DateType(value = FhirDate.fromString("2023-01-01")), + ), + ), + ), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + + setContent { QuestionnaireDateView(questionnaireViewItem) } + + onNodeWithTag(DATE_TEXT_INPUT_FIELD) + .assert(SemanticsMatcher.keyNotDefined(SemanticsProperties.Error)) + } + + @Test + fun hidesErrorTextviewInTheHeader() = runComposeUiTest { + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "date-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Date), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "date-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + + setContent { QuestionnaireDateView(questionnaireViewItem) } + + onNodeWithTag(ERROR_TEXT_AT_HEADER_TEST_TAG).assertDoesNotExist() + } + + @Test + fun bind_readOnly_shouldDisableView() = runComposeUiTest { + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "date-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Date), + readOnly = FhirR4Boolean(value = true), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "date-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + + setContent { QuestionnaireDateView(questionnaireViewItem) } + + onNodeWithTag(DATE_TEXT_INPUT_FIELD).assertIsNotEnabled() + onNodeWithContentDescription("Select date").assertIsNotEnabled() + } + + @Test + fun bindMultipleTimesWithDifferentQuestionnaireItemViewItemShouldShowProperDate() = + runComposeUiTest { + var questionnaireViewItem by + mutableStateOf( + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "date-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Date), + text = FhirR4String(value = "Question?"), + extension = + listOf( + Extension( + url = EXTENSION_ENTRY_FORMAT_URL, + value = Extension.Value.String(value = FhirR4String(value = "MM/dd/yyyy")), + ), + ), + ), + QuestionnaireResponse.Item( + linkId = FhirR4String(value = "date-item"), + answer = + listOf( + QuestionnaireResponse.Item.Answer( + value = + QuestionnaireResponse.Item.Answer.Value.Date( + value = FhirR4DateType(value = FhirDate.Date(LocalDate(2020, 11, 19))), + ), + ), + ), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ), + ) + + setContent { QuestionnaireDateView(questionnaireViewItem) } + onNodeWithTag(DATE_TEXT_INPUT_FIELD, useUnmergedTree = true).assertTextEquals("11/19/2020") + + questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "date-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Date), + text = FhirR4String(value = "Question?"), + extension = + listOf( + Extension( + url = EXTENSION_ENTRY_FORMAT_URL, + value = Extension.Value.String(value = FhirR4String(value = "MM/dd/yyyy")), + ), + ), + ), + QuestionnaireResponse.Item( + linkId = FhirR4String(value = "date-item"), + answer = + listOf( + QuestionnaireResponse.Item.Answer( + value = + QuestionnaireResponse.Item.Answer.Value.Date( + value = FhirR4DateType(value = FhirDate.Date(LocalDate(2021, 11, 19))), + ), + ), + ), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + + onNodeWithTag(DATE_TEXT_INPUT_FIELD, useUnmergedTree = true).assertTextEquals("11/19/2021") + + questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "date-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Date), + text = FhirR4String(value = "Question?"), + extension = + listOf( + Extension( + url = EXTENSION_ENTRY_FORMAT_URL, + value = Extension.Value.String(value = FhirR4String(value = "MM/dd/yyyy")), + ), + ), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "date-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + + onNodeWithTag(DATE_TEXT_INPUT_FIELD, useUnmergedTree = true).assertTextEquals("") + } + + @Test + fun shouldUseDateFormatInTheEntryFormatExtension() = runComposeUiTest { + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "date-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Date), + extension = + listOf( + Extension( + url = EXTENSION_ENTRY_FORMAT_URL, + value = Extension.Value.String(value = FhirR4String(value = "yyyy-MM-dd")), + ), + ), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "date-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + + setContent { QuestionnaireDateView(questionnaireViewItem) } + onNodeWithTag(DATE_TEXT_INPUT_FIELD).assertTextEquals("yyyy-mm-dd", includeEditableText = false) + } + + @Test + @Ignore + fun shouldSetLocalDateInputFormatWhenEntryFormatExtensionHasIncorrectFormatStringInQuestionnaire() = + runComposeUiTest { + // setLocale(Locale.US) + // val questionnaireViewItem = QuestionnaireViewItem( + // Questionnaire.Item( + // linkId = FhirR4String(value = "date-item"), + // type = Enumeration(value = Questionnaire.QuestionnaireItemType.Date), + // extension = listOf( + // Extension(url = EXTENSION_ENTRY_FORMAT_URL, value = + // Extension.Value.String(value = FhirR4String(value = "yMyd"))) + // ) + // ), + // QuestionnaireResponse.Item(linkId = FhirR4String(value = "date-item")), + // validationResult = NotValidated, + // answersChangedCallback = { _, _, _, _ -> }, + // ) + // + // setContent { QuestionnaireDateView(questionnaireViewItem) } + // onNodeWithTag(DATE_TEXT_INPUT_FIELD) + // .assertTextEquals("mm/dd/yyyy", includeEditableText = false) + } + + @Test + fun shouldUseDateFormatInTheEntryFormatExtensionThoughDateSeparatorIsMissing() = + runComposeUiTest { + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "date-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Date), + extension = + listOf( + Extension( + url = EXTENSION_ENTRY_FORMAT_URL, + value = Extension.Value.String(value = FhirR4String(value = "yyyyMMdd")), + ), + ), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "date-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + + setContent { QuestionnaireDateView(questionnaireViewItem) } + onNodeWithTag(DATE_TEXT_INPUT_FIELD).assertTextEquals("yyyymmdd", includeEditableText = false) + } + + @Test + fun shouldUseDateFormatInTheEntryFormatAfterConvertingItToShortFormatStyle() = runComposeUiTest { + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "date-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Date), + extension = + listOf( + Extension( + url = EXTENSION_ENTRY_FORMAT_URL, + value = Extension.Value.String(value = FhirR4String(value = "yyyy MMMM dd")), + ), + ), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "date-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + + setContent { QuestionnaireDateView(questionnaireViewItem) } + onNodeWithTag(DATE_TEXT_INPUT_FIELD).assertTextEquals("yyyy mm dd", includeEditableText = false) + } + + @Test + @Ignore + fun shouldSetLocalDateInputFormatWhenEntryFormatExtensionHasEmptyStringInQuestionnaire() = + runComposeUiTest { + // setLocale(Locale.US) + // val questionnaireViewItem = QuestionnaireViewItem( + // Questionnaire.Item( + // linkId = FhirR4String(value = "date-item"), + // type = Enumeration(value = Questionnaire.QuestionnaireItemType.Date), + // extension = listOf( + // Extension(url = EXTENSION_ENTRY_FORMAT_URL, value = + // Extension.Value.String(value = FhirR4String(value = ""))) + // ) + // ), + // QuestionnaireResponse.Item(linkId = FhirR4String(value = "date-item")), + // validationResult = NotValidated, + // answersChangedCallback = { _, _, _, _ -> }, + // ) + // + // setContent { QuestionnaireDateView(questionnaireViewItem) } + // onNodeWithTag(DATE_TEXT_INPUT_FIELD) + // .assertTextEquals("mm/dd/yyyy", includeEditableText = false) + } + + @Test + @Ignore + fun shouldSetLocalDateInputFormatWhenNoEntryFormatExtensionInQuestionnaire() = + runComposeUiTest { + // setLocale(Locale.US) + // val questionnaireViewItem = QuestionnaireViewItem( + // Questionnaire.Item( + // linkId = FhirR4String(value = "date-item"), + // type = Enumeration(value = Questionnaire.QuestionnaireItemType.Date), + // ), + // QuestionnaireResponse.Item(linkId = FhirR4String(value = "date-item")), + // validationResult = NotValidated, + // answersChangedCallback = { _, _, _, _ -> }, + // ) + // + // setContent { QuestionnaireDateView(questionnaireViewItem) } + // onNodeWithTag(DATE_TEXT_INPUT_FIELD) + // .assertTextEquals("mm/dd/yyyy", includeEditableText = false) + } + + @Test + fun clearUiTextClearsQuestionnaireResponseAnswerAndDraftAnswer() = runComposeUiTest { + var questionnaireResponseAnswers: List? = null + var draftAnswer: String? = null + + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "date-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Date), + extension = + listOf( + Extension( + url = EXTENSION_ENTRY_FORMAT_URL, + value = Extension.Value.String(value = FhirR4String(value = "MM/dd/yyyy")), + ), + ), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "date-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, answers, draft -> + questionnaireResponseAnswers = answers + draftAnswer = draft as? String + }, + ) + + setContent { QuestionnaireDateView(questionnaireViewItem) } + + onNodeWithTag(DATE_TEXT_INPUT_FIELD).performTextInput("11/19/2020") + waitUntil { questionnaireResponseAnswers != null } + val dateAnswer = + (questionnaireResponseAnswers!!.single().value?.asDate()?.value?.value as? FhirDate.Date) + ?.date + dateAnswer.shouldBe(LocalDate(2020, 11, 19)) + + questionnaireResponseAnswers = null + onNodeWithTag(DATE_TEXT_INPUT_FIELD).performTextReplacement("11/19") + waitUntil { questionnaireResponseAnswers != null } + draftAnswer.shouldNotBeNull() + draftAnswer.shouldBe("11/19") + + questionnaireResponseAnswers = null + onNodeWithTag(DATE_TEXT_INPUT_FIELD).performTextClearance() + waitUntil { questionnaireResponseAnswers != null } + questionnaireResponseAnswers.shouldBeEmpty() + assertTrue { draftAnswer.isNullOrEmpty() } + } + + @Test + fun showAsterisk() = runComposeUiTest { + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "date-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Date), + text = FhirR4String(value = "Question?"), + required = FhirR4Boolean(value = true), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "date-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + questionViewTextConfiguration = QuestionTextConfiguration(showAsterisk = true), + ) + setContent { QuestionnaireDateView(questionnaireViewItem) } + + onNodeWithTag(QUESTION_HEADER_TAG).assertTextEquals("Question? *") + } + + @Test + fun hideAsterisk() = runComposeUiTest { + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "date-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Date), + text = FhirR4String(value = "Question?"), + required = FhirR4Boolean(value = true), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "date-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + questionViewTextConfiguration = QuestionTextConfiguration(showAsterisk = false), + ) + + setContent { QuestionnaireDateView(questionnaireViewItem) } + + onNodeWithTag(QUESTION_HEADER_TAG).assertTextEquals("Question?") + } + + @Test + fun showRequiredText() = runComposeUiTest { + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "date-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Date), + required = FhirR4Boolean(value = true), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "date-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + questionViewTextConfiguration = QuestionTextConfiguration(showRequiredText = true), + ) + + setContent { QuestionnaireDateView(questionnaireViewItem) } + + onNodeWithTag(DATE_TEXT_INPUT_FIELD).assertTextContains("Required", substring = true) + } + + @Test + fun hideRequiredText() = runComposeUiTest { + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "date-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Date), + required = FhirR4Boolean(value = true), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "date-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + questionViewTextConfiguration = QuestionTextConfiguration(showRequiredText = false), + ) + + setContent { QuestionnaireDateView(questionnaireViewItem) } + + onNodeWithText("Required").assertDoesNotExist() + } + + @Test + fun showsOptionalText() = runComposeUiTest { + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "date-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Date), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "date-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + questionViewTextConfiguration = QuestionTextConfiguration(showOptionalText = true), + ) + + setContent { QuestionnaireDateView(questionnaireViewItem) } + + onNodeWithTag(DATE_TEXT_INPUT_FIELD).assertTextContains("Optional") + } + + @Test + fun hideOptionalText() = runComposeUiTest { + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "date-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Date), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "date-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + questionViewTextConfiguration = QuestionTextConfiguration(showOptionalText = false), + ) + + setContent { QuestionnaireDateView(questionnaireViewItem) } + onNodeWithText("Optional").assertDoesNotExist() + } + + // private fun setLocale(locale: Locale) { + // Locale.setDefault(locale) + // parent.context.resources.configuration.setLocale(locale) + // } +} diff --git a/datacapture/src/commonTest/kotlin/dev/ohs/fhir/datacapture/views/factories/DialogSelectViewFactoryTest.kt b/datacapture/src/commonTest/kotlin/dev/ohs/fhir/datacapture/views/factories/DialogSelectViewFactoryTest.kt new file mode 100644 index 000000000..81d74064d --- /dev/null +++ b/datacapture/src/commonTest/kotlin/dev/ohs/fhir/datacapture/views/factories/DialogSelectViewFactoryTest.kt @@ -0,0 +1,1319 @@ +/* + * Copyright 2023-2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.ohs.fhir.datacapture.views.factories + +import androidx.compose.runtime.Composable +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.test.assert +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollToNode +import androidx.compose.ui.test.runComposeUiTest +import androidx.compose.ui.text.AnnotatedString +import com.google.fhir.model.r4.Code +import com.google.fhir.model.r4.CodeableConcept +import com.google.fhir.model.r4.Coding +import com.google.fhir.model.r4.Enumeration +import com.google.fhir.model.r4.Extension +import com.google.fhir.model.r4.Questionnaire +import com.google.fhir.model.r4.QuestionnaireResponse +import com.google.fhir.model.r4.Uri +import dev.ohs.fhir.datacapture.extensions.DisplayItemControlType +import dev.ohs.fhir.datacapture.extensions.EXTENSION_DIALOG_URL_ANDROID_FHIR +import dev.ohs.fhir.datacapture.extensions.EXTENSION_ITEM_CONTROL_SYSTEM +import dev.ohs.fhir.datacapture.extensions.EXTENSION_ITEM_CONTROL_URL +import dev.ohs.fhir.datacapture.extensions.EXTENSION_OPTION_EXCLUSIVE_URL +import dev.ohs.fhir.datacapture.extensions.FhirR4Boolean +import dev.ohs.fhir.datacapture.extensions.FhirR4String +import dev.ohs.fhir.datacapture.extensions.ItemControlTypes +import dev.ohs.fhir.datacapture.theme.QuestionnaireTheme +import dev.ohs.fhir.datacapture.validation.Invalid +import dev.ohs.fhir.datacapture.validation.NotValidated +import dev.ohs.fhir.datacapture.views.QuestionTextConfiguration +import dev.ohs.fhir.datacapture.views.QuestionnaireViewItem +import dev.ohs.fhir.datacapture.views.components.ERROR_TEXT_AT_HEADER_TEST_TAG +import dev.ohs.fhir.datacapture.views.components.OPTION_CHOICE_LIST_TAG +import dev.ohs.fhir.datacapture.views.components.OPTION_CHOICE_TAG +import dev.ohs.fhir.datacapture.views.components.OTHER_OPTION_TEXT_FIELD_TAG +import dev.ohs.fhir.datacapture.views.components.QUESTION_HEADER_TAG +import io.kotest.matchers.collections.shouldBeEmpty +import io.kotest.matchers.collections.shouldContainInOrder +import kotlin.test.Test + +@OptIn(ExperimentalTestApi::class) +class DialogSelectViewFactoryTest { + + @Composable + fun QuestionnaireDialogSelect(questionnaireViewItem: QuestionnaireViewItem) { + QuestionnaireTheme { DialogSelectViewFactory.Content(questionnaireViewItem) } + } + + @Test + fun multipleChoice_selectMultiple_clickSave_shouldSaveMultipleOptions() = runComposeUiTest { + var answerHolder: List? = null + val questionnaireViewItem = + QuestionnaireViewItem( + answerOptions(true, "Coding 1", "Coding 2", "Coding 3", "Coding 4", "Coding 5"), + responseOptions(), + validationResult = NotValidated, + answersChangedCallback = { _, _, answers, _ -> answerHolder = answers }, + ) + + setContent { QuestionnaireDialogSelect(questionnaireViewItem) } + + // Click to open the dialog + onNodeWithTag(MULTI_SELECT_TEXT_FIELD_TAG).performClick() + + // Select options in the dialog + onNodeWithText("Coding 1").performClick() + onNodeWithText("Coding 3").performClick() + onNodeWithText("Coding 5").performClick() + onNodeWithText("Save").performClick() + + onNodeWithTag(MULTI_SELECT_TEXT_FIELD_TAG) + .assertTextEquals("Coding 1, Coding 3, Coding 5") + .assertIsDisplayed() + + answerHolder!! + .mapNotNull { it.value?.asCoding()?.value?.display?.value } + .shouldContainInOrder("Coding 1", "Coding 3", "Coding 5") + } + + @Test + fun multipleChoice_selectMultiple_selectExclusive_clickSave_shouldSaveOnlyExclusiveOption() = + runComposeUiTest { + var answerHolder: List? = null + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "dialog-select-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Choice), + extension = listOf(Extension(url = EXTENSION_DIALOG_URL_ANDROID_FHIR)), + repeats = FhirR4Boolean(value = true), + answerOption = + arrayOf("Coding 1", "Coding 2", "Coding 3").map { option -> + Questionnaire.Item.AnswerOption( + value = + Questionnaire.Item.AnswerOption.Value.Coding( + value = Coding(display = FhirR4String(value = option)), + ), + ) + } + + listOf( + Questionnaire.Item.AnswerOption( + value = + Questionnaire.Item.AnswerOption.Value.Coding( + value = Coding(display = FhirR4String(value = "Coding Exclusive")), + ), + extension = + listOf( + Extension( + url = EXTENSION_OPTION_EXCLUSIVE_URL, + value = Extension.Value.Boolean(value = FhirR4Boolean(value = true)), + ), + ), + ), + ), + ), + responseOptions(), + validationResult = NotValidated, + answersChangedCallback = { _, _, answers, _ -> answerHolder = answers }, + ) + + setContent { QuestionnaireDialogSelect(questionnaireViewItem) } + + onNodeWithTag(MULTI_SELECT_TEXT_FIELD_TAG).performClick() + + onNodeWithText("Coding 1").performClick() + onNodeWithText("Coding 3").performClick() + onNodeWithText("Coding Exclusive").performClick() + onNodeWithText("Save").performClick() + + onNodeWithTag(MULTI_SELECT_TEXT_FIELD_TAG) + .assertTextEquals("Coding Exclusive") + .assertIsDisplayed() + + answerHolder!! + .mapNotNull { it.value?.asCoding()?.value?.display?.value } + .shouldContainInOrder("Coding Exclusive") + } + + @Test + fun multipleChoice_selectExclusive_selectMultiple_clickSave_shouldSaveWithoutExclusiveOption() = + runComposeUiTest { + var answerHolder: List? = null + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "dialog-select-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Choice), + extension = listOf(Extension(url = EXTENSION_DIALOG_URL_ANDROID_FHIR)), + repeats = FhirR4Boolean(value = true), + answerOption = + arrayOf("Coding 1", "Coding 2", "Coding 3").map { option -> + Questionnaire.Item.AnswerOption( + value = + Questionnaire.Item.AnswerOption.Value.Coding( + value = Coding(display = FhirR4String(value = option)), + ), + ) + } + + listOf( + Questionnaire.Item.AnswerOption( + value = + Questionnaire.Item.AnswerOption.Value.Coding( + value = Coding(display = FhirR4String(value = "Coding Exclusive")), + ), + extension = + listOf( + Extension( + url = EXTENSION_OPTION_EXCLUSIVE_URL, + value = Extension.Value.Boolean(value = FhirR4Boolean(value = true)), + ), + ), + ), + ), + ), + responseOptions(), + validationResult = NotValidated, + answersChangedCallback = { _, _, answers, _ -> answerHolder = answers }, + ) + + setContent { QuestionnaireDialogSelect(questionnaireViewItem) } + + onNodeWithTag(MULTI_SELECT_TEXT_FIELD_TAG).performClick() + + onNodeWithText("Coding Exclusive").performClick() + onNodeWithText("Coding 1").performClick() + onNodeWithText("Coding 3").performClick() + onNodeWithText("Save").performClick() + + onNodeWithTag(MULTI_SELECT_TEXT_FIELD_TAG) + .assertTextEquals("Coding 1, Coding 3") + .assertIsDisplayed() + + answerHolder!! + .mapNotNull { it.value?.asCoding()?.value?.display?.value } + .shouldContainInOrder("Coding 1", "Coding 3") + } + + @Test + fun multipleChoice_multipleOptionExclusive_selectMultiple_selectExclusive1_selectExclusive2_clickSave_shouldSaveOnlyLastSelectedExclusiveOption() = + runComposeUiTest { + var answerHolder: List? = null + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "dialog-select-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Choice), + extension = listOf(Extension(url = EXTENSION_DIALOG_URL_ANDROID_FHIR)), + repeats = FhirR4Boolean(value = true), + answerOption = + arrayOf("Coding 1", "Coding 2", "Coding 3").map { option -> + Questionnaire.Item.AnswerOption( + value = + Questionnaire.Item.AnswerOption.Value.Coding( + value = Coding(display = FhirR4String(value = option)), + ), + ) + } + + listOf( + Questionnaire.Item.AnswerOption( + value = + Questionnaire.Item.AnswerOption.Value.Coding( + value = Coding(display = FhirR4String(value = "Coding Exclusive 1")), + ), + extension = + listOf( + Extension( + url = EXTENSION_OPTION_EXCLUSIVE_URL, + value = Extension.Value.Boolean(value = FhirR4Boolean(value = true)), + ), + ), + ), + Questionnaire.Item.AnswerOption( + value = + Questionnaire.Item.AnswerOption.Value.Coding( + value = Coding(display = FhirR4String(value = "Coding Exclusive 2")), + ), + extension = + listOf( + Extension( + url = EXTENSION_OPTION_EXCLUSIVE_URL, + value = Extension.Value.Boolean(value = FhirR4Boolean(value = true)), + ), + ), + ), + ), + ), + responseOptions(), + validationResult = NotValidated, + answersChangedCallback = { _, _, answers, _ -> answerHolder = answers }, + ) + + setContent { QuestionnaireDialogSelect(questionnaireViewItem) } + + onNodeWithTag(MULTI_SELECT_TEXT_FIELD_TAG).performClick() + + onNodeWithText("Coding 1").performClick() + onNodeWithText("Coding 3").performClick() + onNodeWithText("Coding Exclusive 1").performClick() + onNodeWithText("Coding Exclusive 2").performClick() + onNodeWithText("Save").performClick() + + onNodeWithTag(MULTI_SELECT_TEXT_FIELD_TAG) + .assertTextEquals("Coding Exclusive 2") + .assertIsDisplayed() + + answerHolder!! + .mapNotNull { it.value?.asCoding()?.value?.display?.value } + .shouldContainInOrder("Coding Exclusive 2") + } + + @Test + fun multipleChoice_multipleOptionExclusive_selectExclusive1_selectExclusive2_selectMultiple_clickSave_shouldSaveWithoutAnyExclusiveOption() = + runComposeUiTest { + var answerHolder: List? = null + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "dialog-select-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Choice), + extension = listOf(Extension(url = EXTENSION_DIALOG_URL_ANDROID_FHIR)), + repeats = FhirR4Boolean(value = true), + answerOption = + arrayOf("Coding 1", "Coding 2", "Coding 3").map { option -> + Questionnaire.Item.AnswerOption( + value = + Questionnaire.Item.AnswerOption.Value.Coding( + value = Coding(display = FhirR4String(value = option)), + ), + ) + } + + listOf( + Questionnaire.Item.AnswerOption( + value = + Questionnaire.Item.AnswerOption.Value.Coding( + value = Coding(display = FhirR4String(value = "Coding Exclusive 1")), + ), + extension = + listOf( + Extension( + url = EXTENSION_OPTION_EXCLUSIVE_URL, + value = Extension.Value.Boolean(value = FhirR4Boolean(value = true)), + ), + ), + ), + Questionnaire.Item.AnswerOption( + value = + Questionnaire.Item.AnswerOption.Value.Coding( + value = Coding(display = FhirR4String(value = "Coding Exclusive 2")), + ), + extension = + listOf( + Extension( + url = EXTENSION_OPTION_EXCLUSIVE_URL, + value = Extension.Value.Boolean(value = FhirR4Boolean(value = true)), + ), + ), + ), + ), + ), + responseOptions(), + validationResult = NotValidated, + answersChangedCallback = { _, _, answers, _ -> answerHolder = answers }, + ) + + setContent { QuestionnaireDialogSelect(questionnaireViewItem) } + + onNodeWithTag(MULTI_SELECT_TEXT_FIELD_TAG).performClick() + + onNodeWithText("Coding Exclusive 1").performClick() + onNodeWithText("Coding Exclusive 2").performClick() + onNodeWithText("Coding 1").performClick() + onNodeWithText("Coding 3").performClick() + onNodeWithText("Save").performClick() + + onNodeWithTag(MULTI_SELECT_TEXT_FIELD_TAG) + .assertTextEquals("Coding 1, Coding 3") + .assertIsDisplayed() + + answerHolder!! + .mapNotNull { it.value?.asCoding()?.value?.display?.value } + .shouldContainInOrder("Coding 1", "Coding 3") + } + + @Test + fun multipleChoice_SelectNothing_clickSave_shouldSaveNothing() = runComposeUiTest { + val questionnaireViewItem = + QuestionnaireViewItem( + answerOptions(true, "Coding 1", "Coding 2", "Coding 3", "Coding 4", "Coding 5"), + responseOptions(), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + + setContent { QuestionnaireDialogSelect(questionnaireViewItem) } + + onNodeWithTag(MULTI_SELECT_TEXT_FIELD_TAG).performClick() + + onNodeWithText("Save").performClick() + + // When nothing is selected, the field should be empty + onNodeWithTag(MULTI_SELECT_TEXT_FIELD_TAG).assertTextEquals("").assertIsDisplayed() + questionnaireViewItem.answers.shouldBeEmpty() + } + + @Test + fun multipleChoice_selectMultiple_clickCancel_shouldSaveNothing() = runComposeUiTest { + val questionnaireViewItem = + QuestionnaireViewItem( + answerOptions(true, "Coding 1", "Coding 2", "Coding 3", "Coding 4", "Coding 5"), + responseOptions(), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + + setContent { QuestionnaireDialogSelect(questionnaireViewItem) } + + onNodeWithTag(MULTI_SELECT_TEXT_FIELD_TAG).performClick() + + onNodeWithText("Coding 3").performClick() + onNodeWithText("Coding 1").performClick() + onNodeWithText("Cancel").performClick() + + // When cancelled, nothing should be saved + onNodeWithTag(MULTI_SELECT_TEXT_FIELD_TAG).assertTextEquals("").assertIsDisplayed() + questionnaireViewItem.answers.shouldBeEmpty() + } + + @Test + fun shouldSelectSingleOptionOnChangeInOptionFromDropDown() = runComposeUiTest { + var answerHolder: List? = null + val questionnaireViewItem = + QuestionnaireViewItem( + answerOptions(false, "Coding 1", "Coding 2", "Coding 3"), + responseOptions(), + validationResult = NotValidated, + answersChangedCallback = { _, _, answers, _ -> answerHolder = answers }, + ) + + setContent { QuestionnaireDialogSelect(questionnaireViewItem) } + + onNodeWithTag(MULTI_SELECT_TEXT_FIELD_TAG).performClick() + + onNodeWithText("Coding 2").performClick() + onNodeWithText("Coding 1").performClick() + onNodeWithText("Save").performClick() + + onNodeWithTag(MULTI_SELECT_TEXT_FIELD_TAG).assertTextEquals("Coding 1").assertIsDisplayed() + answerHolder!! + .mapNotNull { it.value?.asCoding()?.value?.display?.value } + .shouldContainInOrder("Coding 1") + } + + @Test + fun singleOption_select_clickSave_shouldSaveSingleOption() = runComposeUiTest { + var answerHolder: List? = null + val questionnaireViewItem = + QuestionnaireViewItem( + answerOptions(false, "Coding 1", "Coding 2", "Coding 3", "Coding 4", "Coding 5"), + responseOptions(), + validationResult = NotValidated, + answersChangedCallback = { _, _, answers, _ -> answerHolder = answers }, + ) + + setContent { QuestionnaireDialogSelect(questionnaireViewItem) } + + onNodeWithTag(MULTI_SELECT_TEXT_FIELD_TAG).performClick() + + onNodeWithText("Coding 2").performClick() + onNodeWithText("Save").performClick() + + onNodeWithTag(MULTI_SELECT_TEXT_FIELD_TAG).assertTextEquals("Coding 2") + answerHolder!! + .mapNotNull { it.value?.asCoding()?.value?.display?.value } + .shouldContainInOrder("Coding 2") + } + + @Test + fun singleOption_selectNothing_clickSave_shouldSaveNothing() = runComposeUiTest { + val questionnaireViewItem = + QuestionnaireViewItem( + answerOptions(false, "Coding 1", "Coding 2", "Coding 3", "Coding 4", "Coding 5"), + responseOptions(), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + + setContent { QuestionnaireDialogSelect(questionnaireViewItem) } + + onNodeWithTag(MULTI_SELECT_TEXT_FIELD_TAG).performClick() + onNodeWithText("Save").performClick() + + // When nothing is selected, the field should be empty + onNodeWithTag(MULTI_SELECT_TEXT_FIELD_TAG).assertTextEquals("").assertIsDisplayed() + questionnaireViewItem.answers.shouldBeEmpty() + } + + @Test + fun bindView_setHintText() = runComposeUiTest { + val hintItem = + Questionnaire.Item( + linkId = FhirR4String(value = "dialog-hint"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Display), + text = FhirR4String(value = "Select code"), + extension = + listOf( + Extension( + url = EXTENSION_ITEM_CONTROL_URL, + value = + Extension.Value.CodeableConcept( + value = + CodeableConcept( + coding = + listOf( + Coding( + system = Uri(value = EXTENSION_ITEM_CONTROL_SYSTEM), + code = Code(value = DisplayItemControlType.FLYOVER.extensionCode), + ), + ), + ), + ), + ), + ), + ) + + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "dialog-select-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Choice), + extension = listOf(Extension(url = EXTENSION_DIALOG_URL_ANDROID_FHIR)), + repeats = FhirR4Boolean(value = true), + answerOption = + arrayOf("Coding 1", "Coding 2", "Coding 3", "Coding 4", "Coding 5").map { option -> + Questionnaire.Item.AnswerOption( + value = + Questionnaire.Item.AnswerOption.Value.Coding( + value = Coding(display = FhirR4String(value = option)), + ), + ) + }, + item = listOf(hintItem), + ), + responseOptions(), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + enabledDisplayItems = listOf(hintItem), + ) + setContent { QuestionnaireDialogSelect(questionnaireViewItem) } + + onNodeWithText("Select code").assertIsDisplayed() + } + + @Test + fun singleOption_select_clickCancel_shouldSaveNothing() = runComposeUiTest { + val questionnaireViewItem = + QuestionnaireViewItem( + answerOptions(false, "Coding 1", "Coding 2", "Coding 3", "Coding 4", "Coding 5"), + responseOptions(), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + + setContent { QuestionnaireDialogSelect(questionnaireViewItem) } + + onNodeWithTag(MULTI_SELECT_TEXT_FIELD_TAG).performClick() + + onNodeWithText("Coding 2").performClick() + onNodeWithText("Cancel").performClick() + + // When cancelled, nothing should be saved + questionnaireViewItem.answers.shouldBeEmpty() + } + + @Test + fun selectOther_shouldScrollDownToShowAddAnotherAnswer() = runComposeUiTest { + val questionnaireItem = + Questionnaire.Item( + linkId = FhirR4String(value = "dialog-select-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Choice), + extension = listOf(Extension(url = EXTENSION_DIALOG_URL_ANDROID_FHIR), openChoiceType), + repeats = FhirR4Boolean(value = true), + answerOption = + arrayOf( + "Coding 1", + "Coding 2", + "Coding 3", + "Coding 4", + "Coding 5", + "Coding 6", + "Coding 7", + "Coding 8", + ) + .map { option -> + Questionnaire.Item.AnswerOption( + value = + Questionnaire.Item.AnswerOption.Value.Coding( + value = Coding(display = FhirR4String(value = option)), + ), + ) + }, + ) + + val questionnaireViewItem = + QuestionnaireViewItem( + questionnaireItem, + responseOptions(), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + + setContent { QuestionnaireDialogSelect(questionnaireViewItem) } + + onNodeWithTag(MULTI_SELECT_TEXT_FIELD_TAG).performClick() + + // Select "Other" option + val otherText = "Other" + onNodeWithTag(OPTION_CHOICE_LIST_TAG).performScrollToNode(hasText(otherText)) + onNodeWithText(otherText).performClick() + + // "Add Another" button should be displayed in multi-select mode + onNodeWithText("Add another answer").assertIsDisplayed() + } + + @Test + fun unselectOther_shouldHideAddAnotherAnswer() = runComposeUiTest { + val questionnaireItem = + Questionnaire.Item( + linkId = FhirR4String(value = "dialog-select-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Choice), + extension = listOf(Extension(url = EXTENSION_DIALOG_URL_ANDROID_FHIR), openChoiceType), + repeats = FhirR4Boolean(value = true), + answerOption = + arrayOf( + "Coding 1", + "Coding 2", + "Coding 3", + "Coding 4", + "Coding 5", + "Coding 6", + "Coding 7", + "Coding 8", + ) + .map { option -> + Questionnaire.Item.AnswerOption( + value = + Questionnaire.Item.AnswerOption.Value.Coding( + value = Coding(display = FhirR4String(value = option)), + ), + ) + }, + ) + + val questionnaireViewItem = + QuestionnaireViewItem( + questionnaireItem, + responseOptions(), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + + setContent { QuestionnaireDialogSelect(questionnaireViewItem) } + + onNodeWithTag(MULTI_SELECT_TEXT_FIELD_TAG).performClick() + + // Select and then unselect "Other" option + val otherText = "Other" + onNodeWithTag(OPTION_CHOICE_LIST_TAG).performScrollToNode(hasText(otherText)) + onNodeWithText(otherText).performClick() + onNodeWithText(otherText).performClick() + + // "Add Another" button should not be displayed when "Other" is unselected + onNodeWithText("Add another answer").assertDoesNotExist() + } + + @Test + fun clickAddAnotherAnswer_shouldScrollDownToShowAddAnotherAnswer() = runComposeUiTest { + val questionnaireItem = + Questionnaire.Item( + linkId = FhirR4String(value = "dialog-select-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Choice), + extension = listOf(Extension(url = EXTENSION_DIALOG_URL_ANDROID_FHIR), openChoiceType), + repeats = FhirR4Boolean(value = true), + answerOption = + arrayOf( + "Coding 1", + "Coding 2", + "Coding 3", + "Coding 4", + "Coding 5", + "Coding 6", + "Coding 7", + "Coding 8", + ) + .map { option -> + Questionnaire.Item.AnswerOption( + value = + Questionnaire.Item.AnswerOption.Value.Coding( + value = Coding(display = FhirR4String(value = option)), + ), + ) + }, + ) + + val questionnaireViewItem = + QuestionnaireViewItem( + questionnaireItem, + responseOptions(), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + + setContent { QuestionnaireDialogSelect(questionnaireViewItem) } + + onNodeWithTag(MULTI_SELECT_TEXT_FIELD_TAG).performClick() + + // Select "Other" option + val otherText = "Other" + onNodeWithTag(OPTION_CHOICE_LIST_TAG).performScrollToNode(hasText(otherText)) + onNodeWithText(otherText).performClick() + + // Click "Add Another" button + val addAnotherText = "Add another answer" + onNodeWithTag(OPTION_CHOICE_LIST_TAG).performScrollToNode(hasText(addAnotherText)) + onNodeWithText(addAnotherText).performClick() + + // "Add Another" button should still be displayed after clicking + onNodeWithText(addAnotherText).assertIsDisplayed() + } + + @Test + fun selectOther_selectExclusive_shouldHideAddAnotherAnswer() = runComposeUiTest { + val questionnaireItem = + Questionnaire.Item( + linkId = FhirR4String(value = "dialog-select-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Choice), + extension = listOf(Extension(url = EXTENSION_DIALOG_URL_ANDROID_FHIR), openChoiceType), + repeats = FhirR4Boolean(value = true), + answerOption = + arrayOf( + "Coding 1", + "Coding 2", + "Coding 3", + "Coding 4", + "Coding 5", + "Coding 6", + "Coding 7", + "Coding 8", + ) + .map { option -> + Questionnaire.Item.AnswerOption( + value = + Questionnaire.Item.AnswerOption.Value.Coding( + value = Coding(display = FhirR4String(value = option)), + ), + ) + } + + listOf( + Questionnaire.Item.AnswerOption( + value = + Questionnaire.Item.AnswerOption.Value.Coding( + value = Coding(display = FhirR4String(value = "Coding Exclusive")), + ), + extension = + listOf( + Extension( + url = EXTENSION_OPTION_EXCLUSIVE_URL, + value = Extension.Value.Boolean(value = FhirR4Boolean(value = true)), + ), + ), + ), + ), + ) + + val questionnaireViewItem = + QuestionnaireViewItem( + questionnaireItem, + responseOptions(), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + + setContent { QuestionnaireDialogSelect(questionnaireViewItem) } + + onNodeWithTag(MULTI_SELECT_TEXT_FIELD_TAG).performClick() + + // Select "Other" option + val otherText = "Other" + onNodeWithTag(OPTION_CHOICE_LIST_TAG).performScrollToNode(hasText(otherText)) + onNodeWithText(otherText).performClick() + + // Select exclusive option + onNodeWithText("Coding Exclusive").performClick() + + // "Add Another" button should not be displayed when exclusive option is selected + onNodeWithText("Add another answer").assertDoesNotExist() + } + + @Test + fun selectOther_clickAddAnotherAnswer_selectExclusive_shouldHideAddAnotherAnswerWithEditText() = + runComposeUiTest { + val questionnaireItem = + Questionnaire.Item( + linkId = FhirR4String(value = "dialog-select-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Choice), + extension = listOf(Extension(url = EXTENSION_DIALOG_URL_ANDROID_FHIR), openChoiceType), + repeats = FhirR4Boolean(value = true), + answerOption = + arrayOf( + "Coding 1", + "Coding 2", + "Coding 3", + "Coding 4", + "Coding 5", + "Coding 6", + "Coding 7", + "Coding 8", + ) + .map { option -> + Questionnaire.Item.AnswerOption( + value = + Questionnaire.Item.AnswerOption.Value.Coding( + value = Coding(display = FhirR4String(value = option)), + ), + ) + } + + listOf( + Questionnaire.Item.AnswerOption( + value = + Questionnaire.Item.AnswerOption.Value.Coding( + value = Coding(display = FhirR4String(value = "Coding Exclusive")), + ), + extension = + listOf( + Extension( + url = EXTENSION_OPTION_EXCLUSIVE_URL, + value = Extension.Value.Boolean(value = FhirR4Boolean(value = true)), + ), + ), + ), + ), + ) + + val questionnaireViewItem = + QuestionnaireViewItem( + questionnaireItem, + responseOptions(), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + + setContent { QuestionnaireDialogSelect(questionnaireViewItem) } + + onNodeWithTag(MULTI_SELECT_TEXT_FIELD_TAG).performClick() + + // Select "Other" option + val otherText = "Other" + onNodeWithTag(OPTION_CHOICE_LIST_TAG).performScrollToNode(hasText(otherText)) + onNodeWithText(otherText).performClick() + + // Click "Add Another" button + onNodeWithText("Add another answer").performClick() + + // Select exclusive option + onNodeWithText("Coding Exclusive").performClick() + + // "Add Another" button and edit text should not be displayed when exclusive option is + // selected + onAllNodes(hasTestTag(OTHER_OPTION_TEXT_FIELD_TAG)).assertCountEquals(0) + onNodeWithText("Add another answer").assertDoesNotExist() + } + + @Test + fun shouldHideErrorTextviewInHeader() = runComposeUiTest { + val questionnaireItem = + Questionnaire.Item( + linkId = FhirR4String(value = "dialog-select-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Choice), + extension = listOf(Extension(url = EXTENSION_DIALOG_URL_ANDROID_FHIR), openChoiceType), + repeats = FhirR4Boolean(value = true), + answerOption = + arrayOf("Coding 1").map { option -> + Questionnaire.Item.AnswerOption( + value = + Questionnaire.Item.AnswerOption.Value.Coding( + value = Coding(display = FhirR4String(value = option)), + ), + ) + }, + ) + + val questionnaireViewItem = + QuestionnaireViewItem( + questionnaireItem, + QuestionnaireResponse.Item( + linkId = FhirR4String(value = "dialog-select-item"), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + + setContent { QuestionnaireDialogSelect(questionnaireViewItem) } + onNodeWithTag(ERROR_TEXT_AT_HEADER_TEST_TAG).assertDoesNotExist() + } + + @Test + fun show_asterisk() = runComposeUiTest { + setContent { + QuestionnaireDialogSelect( + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "dialog-select-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Choice), + extension = listOf(Extension(url = EXTENSION_DIALOG_URL_ANDROID_FHIR)), + text = FhirR4String(value = "Question?"), + required = FhirR4Boolean(value = true), + ), + QuestionnaireResponse.Item( + linkId = FhirR4String(value = "dialog-select-item"), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + questionViewTextConfiguration = QuestionTextConfiguration(showAsterisk = true), + ), + ) + } + + onNodeWithTag(QUESTION_HEADER_TAG).assertTextEquals("Question? *") + } + + @Test + fun hide_asterisk() = runComposeUiTest { + setContent { + QuestionnaireDialogSelect( + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "dialog-select-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Choice), + extension = listOf(Extension(url = EXTENSION_DIALOG_URL_ANDROID_FHIR)), + text = FhirR4String(value = "Question?"), + required = FhirR4Boolean(value = true), + ), + QuestionnaireResponse.Item( + linkId = FhirR4String(value = "dialog-select-item"), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + questionViewTextConfiguration = QuestionTextConfiguration(showAsterisk = false), + ), + ) + } + + onNodeWithTag(QUESTION_HEADER_TAG).assertTextEquals("Question?") + } + + @Test + fun show_requiredText() = runComposeUiTest { + setContent { + QuestionnaireDialogSelect( + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "dialog-select-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Choice), + extension = listOf(Extension(url = EXTENSION_DIALOG_URL_ANDROID_FHIR)), + required = FhirR4Boolean(value = true), + text = FhirR4String(value = "Question?"), + ), + QuestionnaireResponse.Item( + linkId = FhirR4String(value = "dialog-select-item"), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + questionViewTextConfiguration = QuestionTextConfiguration(showRequiredText = true), + ), + ) + } + + // The "Required" text should be displayed in the supporting text of the OutlinedTextField + onNodeWithText("Required").assertIsDisplayed() + } + + @Test + fun hide_requiredText() = runComposeUiTest { + setContent { + QuestionnaireDialogSelect( + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "dialog-select-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Choice), + extension = listOf(Extension(url = EXTENSION_DIALOG_URL_ANDROID_FHIR)), + required = FhirR4Boolean(value = true), + text = FhirR4String(value = "Question?"), + ), + QuestionnaireResponse.Item( + linkId = FhirR4String(value = "dialog-select-item"), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + questionViewTextConfiguration = QuestionTextConfiguration(showRequiredText = false), + ), + ) + } + + // When showRequiredText is false, "Required" text should not be displayed + onNodeWithText("Required").assertDoesNotExist() + } + + @Test + fun shows_optionalText() = runComposeUiTest { + setContent { + QuestionnaireDialogSelect( + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "dialog-select-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Choice), + extension = listOf(Extension(url = EXTENSION_DIALOG_URL_ANDROID_FHIR)), + ), + QuestionnaireResponse.Item( + linkId = FhirR4String(value = "dialog-select-item"), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + questionViewTextConfiguration = QuestionTextConfiguration(showOptionalText = true), + ), + ) + } + + // The "Optional" text should be displayed in the supporting text + onNodeWithText("Optional").assertIsDisplayed() + } + + @Test + fun hide_optionalText() = runComposeUiTest { + setContent { + QuestionnaireDialogSelect( + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "dialog-select-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Choice), + extension = listOf(Extension(url = EXTENSION_DIALOG_URL_ANDROID_FHIR)), + ), + QuestionnaireResponse.Item( + linkId = FhirR4String(value = "dialog-select-item"), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + questionViewTextConfiguration = QuestionTextConfiguration(showOptionalText = false), + ), + ) + } + + // When showOptionalText is false, "Optional" text should not be displayed + onNodeWithText("Optional").assertDoesNotExist() + } + + @Test + fun multipleChoice_doNotShowErrorInitially() = runComposeUiTest { + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "dialog-select-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Choice), + extension = listOf(Extension(url = EXTENSION_DIALOG_URL_ANDROID_FHIR)), + repeats = FhirR4Boolean(value = true), + required = FhirR4Boolean(value = true), + answerOption = + arrayOf("Coding 1", "Coding 2", "Coding 3", "Coding 4", "Coding 5").map { option -> + Questionnaire.Item.AnswerOption( + value = + Questionnaire.Item.AnswerOption.Value.Coding( + value = Coding(display = FhirR4String(value = option)), + ), + ) + }, + ), + responseOptions(), + validationResult = NotValidated, + answersChangedCallback = { _, _, answers, _ -> }, + ) + + setContent { QuestionnaireDialogSelect(questionnaireViewItem) } + onNodeWithTag(MULTI_SELECT_TEXT_FIELD_TAG) + .assert( + SemanticsMatcher.keyNotDefined( + SemanticsProperties.Error, + ), + ) + } + + @Test + fun multipleChoice_unselectSelectedAnswer_showErrorWhenNoAnswerIsSelected() = runComposeUiTest { + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "dialog-select-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Choice), + extension = listOf(Extension(url = EXTENSION_DIALOG_URL_ANDROID_FHIR)), + repeats = FhirR4Boolean(value = false), + required = FhirR4Boolean(value = true), + answerOption = + arrayOf("Coding 1", "Coding 2").map { option -> + Questionnaire.Item.AnswerOption( + value = + Questionnaire.Item.AnswerOption.Value.Coding( + value = Coding(display = FhirR4String(value = option)), + ), + ) + }, + ), + responseOptions(), + validationResult = Invalid(listOf("Missing answer for required field.")), + answersChangedCallback = { _, _, answers, _ -> }, + ) + + setContent { QuestionnaireDialogSelect(questionnaireViewItem) } + + onNodeWithTag(MULTI_SELECT_TEXT_FIELD_TAG).performClick() + + onNode(hasTestTag(OPTION_CHOICE_TAG) and hasText("Coding 2")).performClick() + onNodeWithText("Save").performClick() + + onNodeWithTag(MULTI_SELECT_TEXT_FIELD_TAG) + .assert( + SemanticsMatcher.expectValue( + SemanticsProperties.EditableText, + AnnotatedString("Coding 2"), + ), + ) + .assertIsDisplayed() + + onNodeWithTag(MULTI_SELECT_TEXT_FIELD_TAG).performClick() + + onNode(hasTestTag(OPTION_CHOICE_TAG) and hasText("Coding 2")).performClick() + onNodeWithText("Save").performClick() + + onNodeWithTag(MULTI_SELECT_TEXT_FIELD_TAG) + .assert( + SemanticsMatcher.expectValue( + SemanticsProperties.EditableText, + AnnotatedString(""), + ), + ) + .assertIsDisplayed() + onNodeWithTag(MULTI_SELECT_TEXT_FIELD_TAG) + .assert( + SemanticsMatcher.expectValue( + SemanticsProperties.Error, + "Missing answer for required field.", + ), + ) + } + + @Test + fun emptyResponseOptions_showNoneSelected() = runComposeUiTest { + setContent { + QuestionnaireDialogSelect( + QuestionnaireViewItem( + answerOptions(false, "Coding 1", "Coding 2"), + responseOptions(), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ), + ) + } + + onNodeWithTag(MULTI_SELECT_TEXT_FIELD_TAG).assertTextEquals("").assertIsDisplayed() + } + + @Test + fun selectedResponseOptions_showSelectedOptions() = runComposeUiTest { + setContent { + QuestionnaireDialogSelect( + QuestionnaireViewItem( + answerOptions(false, "Coding 1", "Coding 2", "Coding 3"), + responseOptions( + "Coding 1", + "Coding 3", + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ), + ) + } + + onNodeWithTag(MULTI_SELECT_TEXT_FIELD_TAG).assertTextEquals("Coding 1, Coding 3") + } + + @Test + fun displayValidationResult_error_shouldShowErrorMessage() = runComposeUiTest { + setContent { + QuestionnaireDialogSelect( + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "dialog-select-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Choice), + extension = listOf(Extension(url = EXTENSION_DIALOG_URL_ANDROID_FHIR)), + required = FhirR4Boolean(value = true), + ), + QuestionnaireResponse.Item( + linkId = FhirR4String(value = "dialog-select-item"), + ), + validationResult = Invalid(listOf("Missing answer for required field.")), + answersChangedCallback = { _, _, _, _ -> }, + ), + ) + } + + onNodeWithTag(MULTI_SELECT_TEXT_FIELD_TAG) + .assert( + SemanticsMatcher.expectValue( + SemanticsProperties.Error, + "Missing answer for required field.", + ), + ) + } + + @Test + fun displayValidationResult_noError_shouldShowNoErrorMessage() = runComposeUiTest { + setContent { + QuestionnaireDialogSelect( + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "dialog-select-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Choice), + extension = listOf(Extension(url = EXTENSION_DIALOG_URL_ANDROID_FHIR)), + answerOption = + listOf( + Questionnaire.Item.AnswerOption( + value = + Questionnaire.Item.AnswerOption.Value.Coding( + value = Coding(display = FhirR4String(value = "display")), + ), + ), + ), + required = FhirR4Boolean(value = true), + ), + QuestionnaireResponse.Item( + linkId = FhirR4String(value = "dialog-select-item"), + answer = + listOf( + QuestionnaireResponse.Item.Answer( + value = + QuestionnaireResponse.Item.Answer.Value.Coding( + value = Coding(display = FhirR4String(value = "display")), + ), + ), + ), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ), + ) + } + + onNodeWithTag(MULTI_SELECT_TEXT_FIELD_TAG) + .assert( + SemanticsMatcher.keyNotDefined( + SemanticsProperties.Error, + ), + ) + } + + @Test + fun bind_readOnly_shouldDisableView() = runComposeUiTest { + setContent { + QuestionnaireDialogSelect( + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "dialog-select-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Choice), + extension = listOf(Extension(url = EXTENSION_DIALOG_URL_ANDROID_FHIR)), + readOnly = FhirR4Boolean(value = true), + ), + QuestionnaireResponse.Item( + linkId = FhirR4String(value = "dialog-select-item"), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ), + ) + } + + onNodeWithTag(MULTI_SELECT_TEXT_FIELD_TAG).assertIsNotEnabled() + } + + private val openChoiceType = + Extension( + url = EXTENSION_ITEM_CONTROL_URL, + value = + Extension.Value.CodeableConcept( + value = + CodeableConcept( + coding = + listOf( + Coding( + code = Code(value = ItemControlTypes.OPEN_CHOICE.extensionCode), + system = Uri(value = EXTENSION_ITEM_CONTROL_SYSTEM), + display = FhirR4String(value = "Open Choice"), + ), + ), + ), + ), + ) + + private fun answerOptions(multiSelect: Boolean, vararg options: String) = + Questionnaire.Item( + linkId = FhirR4String(value = "dialog-select-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Choice), + extension = listOf(Extension(url = EXTENSION_DIALOG_URL_ANDROID_FHIR)), + repeats = FhirR4Boolean(value = multiSelect), + answerOption = + options.map { option -> + Questionnaire.Item.AnswerOption( + value = + Questionnaire.Item.AnswerOption.Value.Coding( + value = Coding(display = FhirR4String(value = option)), + ), + ) + }, + ) + + private fun responseOptions(vararg responses: String) = + QuestionnaireResponse.Item( + linkId = FhirR4String(value = "dialog-select-item"), + answer = + responses.map { + QuestionnaireResponse.Item.Answer( + value = + QuestionnaireResponse.Item.Answer.Value.Coding( + value = Coding(display = FhirR4String(value = it)), + ), + ) + }, + ) +} diff --git a/datacapture/src/commonTest/kotlin/dev/ohs/fhir/datacapture/views/factories/DisplayViewFactoryTest.kt b/datacapture/src/commonTest/kotlin/dev/ohs/fhir/datacapture/views/factories/DisplayViewFactoryTest.kt new file mode 100644 index 000000000..6bac0cf95 --- /dev/null +++ b/datacapture/src/commonTest/kotlin/dev/ohs/fhir/datacapture/views/factories/DisplayViewFactoryTest.kt @@ -0,0 +1,78 @@ +/* + * Copyright 2025-2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.ohs.fhir.datacapture.views.factories + +import androidx.compose.runtime.Composable +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.runComposeUiTest +import com.google.fhir.model.r4.Enumeration +import com.google.fhir.model.r4.Questionnaire +import com.google.fhir.model.r4.QuestionnaireResponse +import dev.ohs.fhir.datacapture.extensions.FhirR4String +import dev.ohs.fhir.datacapture.theme.QuestionnaireTheme +import dev.ohs.fhir.datacapture.validation.NotValidated +import dev.ohs.fhir.datacapture.views.QuestionnaireViewItem +import dev.ohs.fhir.datacapture.views.components.ERROR_TEXT_AT_HEADER_TEST_TAG +import dev.ohs.fhir.datacapture.views.components.QUESTION_HEADER_TAG +import kotlin.test.Test + +@OptIn(ExperimentalTestApi::class) +class DisplayViewFactoryTest { + + @Composable + fun QuestionnaireDisplayView(questionnaireViewItem: QuestionnaireViewItem) { + QuestionnaireTheme { DisplayViewFactory.Content(questionnaireViewItem) } + } + + @Test + fun shouldSetQuestionHeader() = runComposeUiTest { + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "display-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Display), + text = FhirR4String(value = "Display"), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "display-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + setContent { QuestionnaireDisplayView(questionnaireViewItem) } + + onNodeWithTag(QUESTION_HEADER_TAG).assertTextEquals("Display") + } + + @Test + fun hidesErrorTextviewInTheHeader() = runComposeUiTest { + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "display-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Display), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "display-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + + setContent { QuestionnaireDisplayView(questionnaireViewItem) } + + onNodeWithTag(ERROR_TEXT_AT_HEADER_TEST_TAG).assertDoesNotExist() + } +} diff --git a/datacapture/src/commonTest/kotlin/dev/ohs/fhir/datacapture/views/factories/DropDownViewFactoryTest.kt b/datacapture/src/commonTest/kotlin/dev/ohs/fhir/datacapture/views/factories/DropDownViewFactoryTest.kt new file mode 100644 index 000000000..44b089145 --- /dev/null +++ b/datacapture/src/commonTest/kotlin/dev/ohs/fhir/datacapture/views/factories/DropDownViewFactoryTest.kt @@ -0,0 +1,1587 @@ +/* + * Copyright 2025-2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.ohs.fhir.datacapture.views.factories + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.test.assert +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.hasAnyAncestor +import androidx.compose.ui.test.hasAnyDescendant +import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.hasTextExactly +import androidx.compose.ui.test.isPopup +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextReplacement +import androidx.compose.ui.test.runComposeUiTest +import androidx.compose.ui.text.AnnotatedString +import com.google.fhir.model.r4.Attachment +import com.google.fhir.model.r4.Base64Binary +import com.google.fhir.model.r4.Canonical +import com.google.fhir.model.r4.Code +import com.google.fhir.model.r4.CodeableConcept +import com.google.fhir.model.r4.Coding +import com.google.fhir.model.r4.Enumeration +import com.google.fhir.model.r4.Extension +import com.google.fhir.model.r4.Questionnaire +import com.google.fhir.model.r4.QuestionnaireResponse +import com.google.fhir.model.r4.Reference +import com.google.fhir.model.r4.Uri +import dev.ohs.fhir.datacapture.extensions.EXTENSION_ITEM_ANSWER_MEDIA +import dev.ohs.fhir.datacapture.extensions.EXTENSION_ITEM_CONTROL_SYSTEM +import dev.ohs.fhir.datacapture.extensions.EXTENSION_ITEM_CONTROL_URL +import dev.ohs.fhir.datacapture.extensions.FhirR4Boolean +import dev.ohs.fhir.datacapture.extensions.FhirR4String +import dev.ohs.fhir.datacapture.extensions.toAnnotatedString +import dev.ohs.fhir.datacapture.extensions.toQuestionnaireResponseItemAnswer +import dev.ohs.fhir.datacapture.theme.QuestionnaireTheme +import dev.ohs.fhir.datacapture.validation.Invalid +import dev.ohs.fhir.datacapture.validation.NotValidated +import dev.ohs.fhir.datacapture.views.QuestionTextConfiguration +import dev.ohs.fhir.datacapture.views.QuestionnaireViewItem +import dev.ohs.fhir.datacapture.views.components.CLEAR_TEXT_ICON_BUTTON_TAG +import dev.ohs.fhir.datacapture.views.components.DROP_DOWN_ANSWER_MENU_ITEM_TAG +import dev.ohs.fhir.datacapture.views.components.DROP_DOWN_TEXT_FIELD_LEADING_ICON_TAG +import dev.ohs.fhir.datacapture.views.components.DROP_DOWN_TEXT_FIELD_TAG +import dev.ohs.fhir.datacapture.views.components.ERROR_TEXT_AT_HEADER_TEST_TAG +import dev.ohs.fhir.datacapture.views.components.QUESTION_HEADER_TAG +import io.kotest.matchers.collections.shouldBeEmpty +import io.kotest.matchers.collections.shouldHaveSize +import io.kotest.matchers.nulls.shouldNotBeNull +import io.kotest.matchers.shouldBe +import kotlin.io.encoding.ExperimentalEncodingApi +import kotlin.test.Test + +@OptIn(ExperimentalTestApi::class) +class DropDownViewFactoryTest { + + @Composable + fun QuestionnaireDropDownView(questionnaireViewItem: QuestionnaireViewItem) { + QuestionnaireTheme { DropDownViewFactory.Content(questionnaireViewItem) } + } + + @Test + fun shouldSetQuestionHeader() = runComposeUiTest { + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "drop-down-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Choice), + extension = + listOf( + Extension( + url = EXTENSION_ITEM_CONTROL_URL, + value = + Extension.Value.CodeableConcept( + value = + CodeableConcept( + coding = + listOf( + Coding( + system = Uri(value = EXTENSION_ITEM_CONTROL_SYSTEM), + code = Code(value = "drop-down"), + ), + ), + ), + ), + ), + ), + text = FhirR4String(value = "Question?"), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "drop-down-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + + setContent { QuestionnaireDropDownView(questionnaireViewItem) } + + onNodeWithTag(QUESTION_HEADER_TAG).assertTextEquals("Question?") + } + + @Test + fun shouldPopulateDropDown() = runComposeUiTest { + val answerOption = + Questionnaire.Item.AnswerOption( + value = + Questionnaire.Item.AnswerOption.Value.Coding( + value = + Coding(code = Code(value = "test-code"), display = FhirR4String(value = "Test Code")), + ), + ) + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "drop-down-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Choice), + extension = + listOf( + Extension( + url = EXTENSION_ITEM_CONTROL_URL, + value = + Extension.Value.CodeableConcept( + value = + CodeableConcept( + coding = + listOf( + Coding( + system = Uri(value = EXTENSION_ITEM_CONTROL_SYSTEM), + code = Code(value = "drop-down"), + ), + ), + ), + ), + ), + ), + answerOption = listOf(answerOption), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "drop-down-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + + setContent { QuestionnaireDropDownView(questionnaireViewItem) } + onNodeWithTag(DROP_DOWN_TEXT_FIELD_TAG).performClick() + onNode(isPopup()) + .assert( + hasAnyDescendant( + hasTestTag(DROP_DOWN_ANSWER_MENU_ITEM_TAG) and hasTextExactly("Test Code"), + ), + ) + } + + @Test + fun shouldPopulateDropdownWithDisplayForReferenceValueType() = runComposeUiTest { + val answerOption = + Questionnaire.Item.AnswerOption( + value = + Questionnaire.Item.AnswerOption.Value.Reference( + value = + Reference( + reference = FhirR4String(value = "Patient/123"), + display = FhirR4String(value = "John Doe"), + ), + ), + ) + + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "drop-down-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Choice), + extension = + listOf( + Extension( + url = EXTENSION_ITEM_CONTROL_URL, + value = + Extension.Value.CodeableConcept( + value = + CodeableConcept( + coding = + listOf( + Coding( + system = Uri(value = EXTENSION_ITEM_CONTROL_SYSTEM), + code = Code(value = "drop-down"), + ), + ), + ), + ), + ), + ), + answerOption = listOf(answerOption), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "drop-down-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + + setContent { QuestionnaireDropDownView(questionnaireViewItem) } + onNodeWithTag(DROP_DOWN_TEXT_FIELD_TAG).performClick() + onNode(isPopup()) + .assert( + hasAnyDescendant( + hasTestTag(DROP_DOWN_ANSWER_MENU_ITEM_TAG) and hasTextExactly("John Doe"), + ), + ) + } + + @Test + fun shouldPopulateDropdownWithTypeAndIdForReferenceValueTypeIfMissingDisplay() = + runComposeUiTest { + val answerOption = + Questionnaire.Item.AnswerOption( + value = + Questionnaire.Item.AnswerOption.Value.Reference( + value = Reference(reference = FhirR4String(value = "Patient/123")), + ), + ) + + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "drop-down-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Choice), + extension = + listOf( + Extension( + url = EXTENSION_ITEM_CONTROL_URL, + value = + Extension.Value.CodeableConcept( + value = + CodeableConcept( + coding = + listOf( + Coding( + system = Uri(value = EXTENSION_ITEM_CONTROL_SYSTEM), + code = Code(value = "drop-down"), + ), + ), + ), + ), + ), + ), + answerOption = listOf(answerOption), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "drop-down-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + + setContent { QuestionnaireDropDownView(questionnaireViewItem) } + onNodeWithTag(DROP_DOWN_TEXT_FIELD_TAG).performClick() + onNode(isPopup()) + .assert( + hasAnyDescendant( + hasTestTag(DROP_DOWN_ANSWER_MENU_ITEM_TAG) and hasTextExactly("Patient/123"), + ), + ) + } + + @Test + fun shouldSetDropDownOptionToCodeIfValueCodingDisplayEmpty() = runComposeUiTest { + val answerOption = + Questionnaire.Item.AnswerOption( + value = + Questionnaire.Item.AnswerOption.Value.Coding( + value = Coding(code = Code(value = "test-code")), + ), + ) + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "drop-down-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Choice), + extension = + listOf( + Extension( + url = EXTENSION_ITEM_CONTROL_URL, + value = + Extension.Value.CodeableConcept( + value = + CodeableConcept( + coding = + listOf( + Coding( + system = Uri(value = EXTENSION_ITEM_CONTROL_SYSTEM), + code = Code(value = "drop-down"), + ), + ), + ), + ), + ), + ), + answerOption = listOf(answerOption), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "drop-down-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + setContent { QuestionnaireDropDownView(questionnaireViewItem) } + onNodeWithTag(DROP_DOWN_TEXT_FIELD_TAG).performClick() + onNode(isPopup()) + .assert( + hasAnyDescendant( + hasTestTag(DROP_DOWN_ANSWER_MENU_ITEM_TAG) and hasTextExactly("test-code"), + ), + ) + } + + @Test + fun shouldSetSelectedTextEmptyIfAnswerNull() = runComposeUiTest { + val answerOption = + Questionnaire.Item.AnswerOption( + value = + Questionnaire.Item.AnswerOption.Value.Coding( + value = + Coding(code = Code(value = "test-code"), display = FhirR4String(value = "Test Code")), + ), + ) + + setContent { + QuestionnaireDropDownView( + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "drop-down-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Choice), + extension = + listOf( + Extension( + url = EXTENSION_ITEM_CONTROL_URL, + value = + Extension.Value.CodeableConcept( + value = + CodeableConcept( + coding = + listOf( + Coding( + system = Uri(value = EXTENSION_ITEM_CONTROL_SYSTEM), + code = Code(value = "drop-down"), + ), + ), + ), + ), + ), + ), + answerOption = listOf(answerOption), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "drop-down-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ), + ) + } + + onNodeWithTag(DROP_DOWN_TEXT_FIELD_TAG) + .assert( + SemanticsMatcher.expectValue( + SemanticsProperties.EditableText, + AnnotatedString(""), + ), + ) + } + + @Test + fun shouldSelectedTextToDisplayIfAnswerNotNull() = runComposeUiTest { + val answerOption = + Questionnaire.Item.AnswerOption( + value = + Questionnaire.Item.AnswerOption.Value.Coding( + value = + Coding(code = Code(value = "test-code"), display = FhirR4String(value = "Test Code")), + ), + ) + val fakeAnswerValueSetResolver = { uri: String -> + if (uri == "http://coding-value-set-url") { + listOf(answerOption) + } else { + emptyList() + } + } + val questionnaireItem = + Questionnaire.Item( + linkId = FhirR4String(value = "drop-down-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Choice), + extension = + listOf( + Extension( + url = EXTENSION_ITEM_CONTROL_URL, + value = + Extension.Value.CodeableConcept( + value = + CodeableConcept( + coding = + listOf( + Coding( + system = Uri(value = EXTENSION_ITEM_CONTROL_SYSTEM), + code = Code(value = "drop-down"), + ), + ), + ), + ), + ), + ), + answerValueSet = Canonical(value = "http://coding-value-set-url"), + ) + setContent { + QuestionnaireDropDownView( + QuestionnaireViewItem( + questionnaireItem, + QuestionnaireResponse.Item( + linkId = FhirR4String(value = "drop-down-item"), + answer = listOf(answerOption.toQuestionnaireResponseItemAnswer()), + ), + enabledAnswerOptions = + fakeAnswerValueSetResolver.invoke(questionnaireItem.answerValueSet?.value!!), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ), + ) + } + + onNodeWithTag(DROP_DOWN_TEXT_FIELD_TAG) + .assert( + SemanticsMatcher.expectValue( + SemanticsProperties.EditableText, + "Test Code".toAnnotatedString(), + ), + ) + } + + @Test + fun shouldSelectedTextToDisplayIfAnswerNotNullAndDisplayMatchesMoreThanOneOption() = + runComposeUiTest { + val answerOption1 = + Questionnaire.Item.AnswerOption( + value = + Questionnaire.Item.AnswerOption.Value.Reference( + value = + Reference( + reference = FhirR4String(value = "Patient/1234"), + display = FhirR4String(value = "John"), + ), + ), + ) + + val answerOption2 = + Questionnaire.Item.AnswerOption( + value = + Questionnaire.Item.AnswerOption.Value.Reference( + value = + Reference( + reference = FhirR4String(value = "Patient/6789"), + display = FhirR4String(value = "John"), + ), + ), + ) + + setContent { + QuestionnaireDropDownView( + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "drop-down-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Choice), + extension = + listOf( + Extension( + url = EXTENSION_ITEM_CONTROL_URL, + value = + Extension.Value.CodeableConcept( + value = + CodeableConcept( + coding = + listOf( + Coding( + system = Uri(value = EXTENSION_ITEM_CONTROL_SYSTEM), + code = Code(value = "drop-down"), + ), + ), + ), + ), + ), + ), + answerOption = listOf(answerOption1, answerOption2), + ), + QuestionnaireResponse.Item( + linkId = FhirR4String(value = "drop-down-item"), + answer = listOf(answerOption2.toQuestionnaireResponseItemAnswer()), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ), + ) + } + + onNodeWithTag(DROP_DOWN_TEXT_FIELD_TAG) + .assert( + SemanticsMatcher.expectValue( + SemanticsProperties.EditableText, + "John".toAnnotatedString(), + ), + ) + } + + @Test + fun displayValidationResult_error_shouldShowErrorMessage() = runComposeUiTest { + setContent { + QuestionnaireDropDownView( + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "drop-down-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Choice), + extension = + listOf( + Extension( + url = EXTENSION_ITEM_CONTROL_URL, + value = + Extension.Value.CodeableConcept( + value = + CodeableConcept( + coding = + listOf( + Coding( + system = Uri(value = EXTENSION_ITEM_CONTROL_SYSTEM), + code = Code(value = "drop-down"), + ), + ), + ), + ), + ), + ), + required = FhirR4Boolean(value = true), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "drop-down-item")), + validationResult = Invalid(listOf("Missing answer for required field.")), + answersChangedCallback = { _, _, _, _ -> }, + ), + ) + } + onNodeWithTag(DROP_DOWN_TEXT_FIELD_TAG) + .assert( + SemanticsMatcher.expectValue( + SemanticsProperties.Error, + "Missing answer for required field.", + ), + ) + } + + @Test + fun displayValidationResult_noError_shouldShowNoErrorMessage() = runComposeUiTest { + setContent { + QuestionnaireDropDownView( + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "drop-down-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Choice), + extension = + listOf( + Extension( + url = EXTENSION_ITEM_CONTROL_URL, + value = + Extension.Value.CodeableConcept( + value = + CodeableConcept( + coding = + listOf( + Coding( + system = Uri(value = EXTENSION_ITEM_CONTROL_SYSTEM), + code = Code(value = "drop-down"), + ), + ), + ), + ), + ), + ), + answerOption = + listOf( + Questionnaire.Item.AnswerOption( + value = + Questionnaire.Item.AnswerOption.Value.Coding( + value = Coding(display = FhirR4String(value = "display")), + ), + ), + ), + required = FhirR4Boolean(value = true), + ), + QuestionnaireResponse.Item( + linkId = FhirR4String(value = "drop-down-item"), + answer = + listOf( + QuestionnaireResponse.Item.Answer( + value = + QuestionnaireResponse.Item.Answer.Value.Coding( + value = Coding(display = FhirR4String(value = "display")), + ), + ), + ), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ), + ) + } + + onNodeWithTag(DROP_DOWN_TEXT_FIELD_TAG) + .assert( + SemanticsMatcher.keyNotDefined( + SemanticsProperties.Error, + ), + ) + } + + @Test + fun hidesErrorTextviewInTheHeader() = runComposeUiTest { + setContent { + QuestionnaireDropDownView( + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "drop-down-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Choice), + extension = + listOf( + Extension( + url = EXTENSION_ITEM_CONTROL_URL, + value = + Extension.Value.CodeableConcept( + value = + CodeableConcept( + coding = + listOf( + Coding( + system = Uri(value = EXTENSION_ITEM_CONTROL_SYSTEM), + code = Code(value = "drop-down"), + ), + ), + ), + ), + ), + ), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "drop-down-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ), + ) + } + + onNodeWithTag(ERROR_TEXT_AT_HEADER_TEST_TAG).assertDoesNotExist() + } + + @Test + fun shouldHideClearIconWhenTextIsEmpty() = runComposeUiTest { + val answerOption = + Questionnaire.Item.AnswerOption( + value = + Questionnaire.Item.AnswerOption.Value.Coding( + value = Coding(code = Code(value = "code"), display = FhirR4String(value = "display")), + ), + ) + + setContent { + QuestionnaireDropDownView( + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "drop-down-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Choice), + extension = + listOf( + Extension( + url = EXTENSION_ITEM_CONTROL_URL, + value = + Extension.Value.CodeableConcept( + value = + CodeableConcept( + coding = + listOf( + Coding( + system = Uri(value = EXTENSION_ITEM_CONTROL_SYSTEM), + code = Code(value = "drop-down"), + ), + ), + ), + ), + ), + ), + answerOption = listOf(answerOption), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "drop-down-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ), + ) + } + + onNodeWithTag(CLEAR_TEXT_ICON_BUTTON_TAG).assertDoesNotExist() + } + + @Test + fun shouldShowClearIconWhenTextIsNotEmpty() = runComposeUiTest { + val answerOption = + Questionnaire.Item.AnswerOption( + value = + Questionnaire.Item.AnswerOption.Value.Coding( + value = Coding(code = Code(value = "code"), display = FhirR4String(value = "display")), + ), + ) + + setContent { + QuestionnaireDropDownView( + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "drop-down-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Choice), + extension = + listOf( + Extension( + url = EXTENSION_ITEM_CONTROL_URL, + value = + Extension.Value.CodeableConcept( + value = + CodeableConcept( + coding = + listOf( + Coding( + system = Uri(value = EXTENSION_ITEM_CONTROL_SYSTEM), + code = Code(value = "drop-down"), + ), + ), + ), + ), + ), + ), + answerOption = listOf(answerOption), + ), + QuestionnaireResponse.Item( + linkId = FhirR4String(value = "drop-down-item"), + answer = listOf(answerOption.toQuestionnaireResponseItemAnswer()), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ), + ) + } + + onNodeWithTag(CLEAR_TEXT_ICON_BUTTON_TAG).assertIsDisplayed() + } + + @Test + fun bind_readOnly_shouldDisableView() = runComposeUiTest { + setContent { + QuestionnaireDropDownView( + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "drop-down-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Choice), + extension = + listOf( + Extension( + url = EXTENSION_ITEM_CONTROL_URL, + value = + Extension.Value.CodeableConcept( + value = + CodeableConcept( + coding = + listOf( + Coding( + system = Uri(value = EXTENSION_ITEM_CONTROL_SYSTEM), + code = Code(value = "drop-down"), + ), + ), + ), + ), + ), + ), + readOnly = FhirR4Boolean(value = true), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "drop-down-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ), + ) + } + + onNodeWithTag(DROP_DOWN_TEXT_FIELD_TAG).assertIsNotEnabled() + } + + @Test + fun shouldClearAutoCompleteTextView() = runComposeUiTest { + val questionnaireViewItem = + QuestionnaireViewItem( + answerOptions("Coding 1", "Coding 2", "Coding 3", "Coding 4", "Coding 5"), + responseOptions(), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + + setContent { QuestionnaireDropDownView(questionnaireViewItem) } + onNodeWithTag(DROP_DOWN_TEXT_FIELD_TAG).performClick() + onNode( + hasTestTag(DROP_DOWN_ANSWER_MENU_ITEM_TAG) and + hasTextExactly("-") and + hasAnyAncestor(isPopup()), + ) + .assertIsDisplayed() + .performClick() + onNodeWithTag(DROP_DOWN_TEXT_FIELD_TAG) + .assert( + SemanticsMatcher.expectValue( + SemanticsProperties.EditableText, + AnnotatedString("-"), + ), + ) + questionnaireViewItem.answers.shouldBeEmpty() + } + + @Test + fun shouldSetDropDownValueToAutoCompleteTextView() = runComposeUiTest { + var answerHolder: List? = null + val questionnaireViewItem = + QuestionnaireViewItem( + answerOptions("Coding 1", "Coding 2", "Coding 3", "Coding 4", "Coding 5"), + responseOptions(), + validationResult = NotValidated, + answersChangedCallback = { _, _, answers, _ -> answerHolder = answers }, + ) + setContent { QuestionnaireDropDownView(questionnaireViewItem) } + onNodeWithTag(DROP_DOWN_TEXT_FIELD_TAG).performClick() + onNode( + hasTestTag(DROP_DOWN_ANSWER_MENU_ITEM_TAG) and + hasTextExactly("Coding 3") and + hasAnyAncestor(isPopup()), + ) + .assertIsDisplayed() + .performClick() + onNodeWithTag(DROP_DOWN_TEXT_FIELD_TAG) + .assert( + SemanticsMatcher.expectValue( + SemanticsProperties.EditableText, + AnnotatedString("Coding 3"), + ), + ) + waitUntil { answerHolder != null } + + answerHolder?.single()?.value?.asCoding()?.value?.display?.value.shouldBe("Coding 3") + } + + @Test + fun shouldClearAutoCompleteTextViewOnRebindingView() = runComposeUiTest { + var answerHolder: List? = null + var questionnaireViewItem by + mutableStateOf( + QuestionnaireViewItem( + answerOptions("Coding 1", "Coding 2", "Coding 3", "Coding 4", "Coding 5"), + responseOptions(), + validationResult = NotValidated, + answersChangedCallback = { _, _, answers, _ -> answerHolder = answers }, + ), + ) + + setContent { QuestionnaireDropDownView(questionnaireViewItem) } + onNodeWithTag(DROP_DOWN_TEXT_FIELD_TAG).performClick() + + onNode( + hasTestTag(DROP_DOWN_ANSWER_MENU_ITEM_TAG) and + hasTextExactly("Coding 3") and + hasAnyAncestor(isPopup()), + ) + .assertIsDisplayed() + .performClick() + onNodeWithTag(DROP_DOWN_TEXT_FIELD_TAG) + .assert( + SemanticsMatcher.expectValue( + SemanticsProperties.EditableText, + AnnotatedString("Coding 3"), + ), + ) + waitUntil { answerHolder != null } + answerHolder.shouldNotBeNull() + val newQuestionnaireResponseItem = responseOptions().copy(answer = answerHolder!!) + questionnaireViewItem = + questionnaireViewItem.copy(questionnaireResponseItem = newQuestionnaireResponseItem) + + onNodeWithTag(CLEAR_TEXT_ICON_BUTTON_TAG).assertIsDisplayed() + + // Reset QuestionnaireViewItem + questionnaireViewItem = + questionnaireViewItem.copy(questionnaireResponseItem = responseOptions()) + + onNodeWithTag(DROP_DOWN_TEXT_FIELD_TAG) + .assert( + SemanticsMatcher.expectValue( + SemanticsProperties.EditableText, + AnnotatedString(""), + ), + ) + onNodeWithTag(DROP_DOWN_TEXT_FIELD_LEADING_ICON_TAG).assertDoesNotExist() + } + + @Test + fun shouldSetImageToAutoCompleteTextViewOnItemSelection() = runComposeUiTest { + var answerHolder: List? = null + val questionnaireViewItem = + QuestionnaireViewItem( + createAnswerOptions( + "Coding 1", + "Coding 2", + "Coding 3", + "Coding 4", + "Coding 5", + hasImageExtension = true, + ), + responseOptions(), + validationResult = NotValidated, + answersChangedCallback = { _, _, answers, _ -> answerHolder = answers }, + ) + setContent { QuestionnaireDropDownView(questionnaireViewItem) } + onNodeWithTag(DROP_DOWN_TEXT_FIELD_TAG).performClick() + + onNode( + hasTestTag(DROP_DOWN_ANSWER_MENU_ITEM_TAG) and + hasTextExactly("Coding 3") and + hasAnyAncestor(isPopup()), + ) + .assertIsDisplayed() + .performClick() + onNodeWithTag(DROP_DOWN_TEXT_FIELD_TAG) + .assert( + SemanticsMatcher.expectValue( + SemanticsProperties.EditableText, + AnnotatedString("Coding 3"), + ), + ) + onNodeWithTag(DROP_DOWN_TEXT_FIELD_LEADING_ICON_TAG, useUnmergedTree = true).assertIsDisplayed() + } + + @Test + fun shouldSetDropDownValueStringToAutoCompleteTextView() = runComposeUiTest { + var answerHolder: List? = null + val questionnaireViewItem = + QuestionnaireViewItem( + createAnswerOptions("Coding 1", "Coding 2", "Coding 3", "Coding 4", "Coding 5"), + responseValueStringOptions(), + validationResult = NotValidated, + answersChangedCallback = { _, _, answers, _ -> answerHolder = answers }, + ) + + setContent { QuestionnaireDropDownView(questionnaireViewItem) } + onNodeWithTag(DROP_DOWN_TEXT_FIELD_TAG).performClick() + onNode( + hasTestTag(DROP_DOWN_ANSWER_MENU_ITEM_TAG) and + hasTextExactly("Coding 1") and + hasAnyAncestor(isPopup()), + ) + .assertIsDisplayed() + .performClick() + onNodeWithTag(DROP_DOWN_TEXT_FIELD_TAG) + .assert( + SemanticsMatcher.expectValue( + SemanticsProperties.EditableText, + AnnotatedString("Coding 1"), + ), + ) + waitUntil { answerHolder != null } + + answerHolder?.single()?.value?.asString()?.value?.value.shouldBe("Coding 1") + } + + @Test + fun shouldReturnNonFilteredDropDownMenuItems() = runComposeUiTest { + val questionnaireViewItem = + QuestionnaireViewItem( + createAnswerOptions("Coding 1", "Coding 2", "Coding 3", "Add", "Subtract"), + responseValueStringOptions(), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + + setContent { QuestionnaireDropDownView(questionnaireViewItem) } + onNodeWithTag(DROP_DOWN_TEXT_FIELD_TAG).performClick() + onAllNodes(hasTestTag(DROP_DOWN_ANSWER_MENU_ITEM_TAG) and hasAnyAncestor(isPopup())) + .assertCountEquals(6) // +1 cause of '-' menu item + } + + @Test + fun shouldReturnFilteredDropDownMenuItems() = runComposeUiTest { + val questionnaireViewItem = + QuestionnaireViewItem( + createAnswerOptions("Coding 1", "Coding 2", "Coding 3", "Add", "Subtract"), + responseValueStringOptions(), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + setContent { QuestionnaireDropDownView(questionnaireViewItem) } + onNodeWithTag(DROP_DOWN_TEXT_FIELD_TAG).performClick() + + onNodeWithTag(DROP_DOWN_TEXT_FIELD_TAG).performTextReplacement("Coding") + + onAllNodes(hasTestTag(DROP_DOWN_ANSWER_MENU_ITEM_TAG) and hasAnyAncestor(isPopup())) + .assertCountEquals(3) + } + + @Test + fun shouldPreventTypingWhenAnswerIsSelectedInAutoCompleteDropdown() = runComposeUiTest { + val preselectedAnswer = + QuestionnaireResponse.Item( + linkId = FhirR4String(value = "drop-down-item"), + answer = + listOf( + QuestionnaireResponse.Item.Answer( + value = + QuestionnaireResponse.Item.Answer.Value.String( + value = FhirR4String(value = "Coding 1"), + ), + ), + ), + ) + + val questionnaireItem = + QuestionnaireViewItem( + createAnswerOptions("Coding 1", "Coding 2", "Coding 3"), + preselectedAnswer, + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + + setContent { QuestionnaireDropDownView(questionnaireItem) } + onNodeWithTag(DROP_DOWN_TEXT_FIELD_TAG).performClick() + onNodeWithTag(DROP_DOWN_TEXT_FIELD_TAG) + .assert(SemanticsMatcher.expectValue(SemanticsProperties.IsEditable, false)) + + onNodeWithTag(DROP_DOWN_TEXT_FIELD_TAG) + .assert( + SemanticsMatcher.expectValue( + SemanticsProperties.EditableText, + AnnotatedString("Coding 1"), + ), + ) + } + + @Test + fun shouldSelectAndClearAnswerInAutoCompleteDropdown() = runComposeUiTest { + var selectedAnswers: List? = null + val answerOptions = listOf("Coding 1", "Coding 2", "Coding 3") + + var questionnaireItem by + mutableStateOf( + QuestionnaireViewItem( + createAnswerOptions(*answerOptions.toTypedArray()), + responseValueStringOptions(), + validationResult = NotValidated, + answersChangedCallback = { _, _, answers, _ -> selectedAnswers = answers }, + ), + ) + + setContent { QuestionnaireDropDownView(questionnaireItem) } + onNodeWithTag(DROP_DOWN_TEXT_FIELD_TAG).performClick() + // Test selection flow + onNode( + hasTestTag(DROP_DOWN_ANSWER_MENU_ITEM_TAG) and + hasTextExactly("Coding 1") and + hasAnyAncestor(isPopup()), + ) + .assertIsDisplayed() + .performClick() + waitUntil { selectedAnswers != null } + + selectedAnswers!!.shouldHaveSize(1) + selectedAnswers!!.first().value?.asString()?.value?.value.shouldBe("Coding 1") + + selectedAnswers = null + // Test clearing flow + questionnaireItem = + QuestionnaireViewItem( + createAnswerOptions(*answerOptions.toTypedArray()), + responseValueStringOptions() + .copy( + answer = + listOf( + QuestionnaireResponse.Item.Answer( + value = + QuestionnaireResponse.Item.Answer.Value.String( + value = FhirR4String(value = "Coding 1"), + ), + ), + ), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, answers, _ -> selectedAnswers = answers }, + ) + + onNodeWithTag(DROP_DOWN_TEXT_FIELD_TAG) + .assert( + SemanticsMatcher.expectValue( + SemanticsProperties.EditableText, + AnnotatedString("Coding 1"), + ), + ) + onNodeWithTag(CLEAR_TEXT_ICON_BUTTON_TAG).performClick() + waitUntil { selectedAnswers != null } + + selectedAnswers.shouldBeEmpty() + } + + @Test + fun shouldReturnFilteredWithNoResultsDropDownMenuItems() = runComposeUiTest { + val questionnaireViewItem = + QuestionnaireViewItem( + createAnswerOptions("Coding 1", "Coding 2", "Coding 3", "Add", "Subtract"), + responseValueStringOptions(), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + + setContent { QuestionnaireDropDownView(questionnaireViewItem) } + onNodeWithTag(DROP_DOWN_TEXT_FIELD_TAG).performClick() + + onNodeWithTag(DROP_DOWN_TEXT_FIELD_TAG).performTextReplacement("Division") + + onAllNodes(hasTestTag(DROP_DOWN_ANSWER_MENU_ITEM_TAG) and hasAnyAncestor(isPopup())) + .assertCountEquals(0) + } + + @Test + fun shouldSetCorrectDropDownValueToAutoCompleteTextViewForDifferentAnswerOptionsWithSimilarDisplayString() = + runComposeUiTest { + val questionnaireItem = + Questionnaire.Item( + linkId = FhirR4String(value = "drop-down-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Choice), + extension = + listOf( + Extension( + url = EXTENSION_ITEM_CONTROL_URL, + value = + Extension.Value.CodeableConcept( + value = + CodeableConcept( + coding = + listOf( + Coding( + system = Uri(value = EXTENSION_ITEM_CONTROL_SYSTEM), + code = Code(value = "drop-down"), + ), + ), + ), + ), + ), + ), + answerOption = + listOf( + Questionnaire.Item.AnswerOption( + value = + Questionnaire.Item.AnswerOption.Value.Reference( + value = + Reference( + id = "ref_1", + display = FhirR4String(value = "Reference"), + ), + ), + ), + Questionnaire.Item.AnswerOption( + value = + Questionnaire.Item.AnswerOption.Value.Reference( + value = + Reference( + id = "ref_2", + display = FhirR4String(value = "Reference"), + ), + ), + ), + ), + ) + + var answerHolder: List? = null + val questionnaireViewItem = + QuestionnaireViewItem( + questionnaireItem, + responseValueStringOptions(), + validationResult = NotValidated, + answersChangedCallback = { _, _, answers, _ -> answerHolder = answers }, + ) + + setContent { QuestionnaireDropDownView(questionnaireViewItem) } + onNodeWithTag(DROP_DOWN_TEXT_FIELD_TAG).performClick() + + onAllNodes( + hasTestTag(DROP_DOWN_ANSWER_MENU_ITEM_TAG) and + hasTextExactly("Reference") and + hasAnyAncestor(isPopup()), + )[1] // at position 2 + .performClick() + onNodeWithTag(DROP_DOWN_TEXT_FIELD_TAG) + .assert( + SemanticsMatcher.expectValue( + SemanticsProperties.EditableText, + AnnotatedString("Reference"), + ), + ) + + answerHolder!!.single().value?.asReference()?.value?.display?.value.shouldBe("Reference") + answerHolder.single().value?.asReference()?.value?.id.shouldBe("ref_2") + } + + @Test + fun showsAsterisk() = runComposeUiTest { + setContent { + QuestionnaireDropDownView( + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "drop-down-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Choice), + extension = + listOf( + Extension( + url = EXTENSION_ITEM_CONTROL_URL, + value = + Extension.Value.CodeableConcept( + value = + CodeableConcept( + coding = + listOf( + Coding( + system = Uri(value = EXTENSION_ITEM_CONTROL_SYSTEM), + code = Code(value = "drop-down"), + ), + ), + ), + ), + ), + ), + text = FhirR4String(value = "Question?"), + required = FhirR4Boolean(value = true), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "drop-down-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + questionViewTextConfiguration = QuestionTextConfiguration(showAsterisk = true), + ), + ) + } + + onNodeWithTag(QUESTION_HEADER_TAG).assertTextEquals("Question? *") + } + + @Test + fun hideAsterisk() = runComposeUiTest { + setContent { + QuestionnaireDropDownView( + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "drop-down-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Choice), + extension = + listOf( + Extension( + url = EXTENSION_ITEM_CONTROL_URL, + value = + Extension.Value.CodeableConcept( + value = + CodeableConcept( + coding = + listOf( + Coding( + system = Uri(value = EXTENSION_ITEM_CONTROL_SYSTEM), + code = Code(value = "drop-down"), + ), + ), + ), + ), + ), + ), + text = FhirR4String(value = "Question?"), + required = FhirR4Boolean(value = true), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "drop-down-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + questionViewTextConfiguration = QuestionTextConfiguration(showAsterisk = false), + ), + ) + } + + onNodeWithTag(QUESTION_HEADER_TAG).assertTextEquals("Question?") + } + + @Test + fun showsRequiredText() = runComposeUiTest { + setContent { + QuestionnaireDropDownView( + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "drop-down-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Choice), + extension = + listOf( + Extension( + url = EXTENSION_ITEM_CONTROL_URL, + value = + Extension.Value.CodeableConcept( + value = + CodeableConcept( + coding = + listOf( + Coding( + system = Uri(value = EXTENSION_ITEM_CONTROL_SYSTEM), + code = Code(value = "drop-down"), + ), + ), + ), + ), + ), + ), + required = FhirR4Boolean(value = true), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "drop-down-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + questionViewTextConfiguration = QuestionTextConfiguration(showRequiredText = true), + ), + ) + } + + onNodeWithTag(DROP_DOWN_TEXT_FIELD_TAG) + .assertTextEquals("Required", includeEditableText = false) + } + + @Test + fun hideRequiredText() = runComposeUiTest { + setContent { + QuestionnaireDropDownView( + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "drop-down-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Choice), + extension = + listOf( + Extension( + url = EXTENSION_ITEM_CONTROL_URL, + value = + Extension.Value.CodeableConcept( + value = + CodeableConcept( + coding = + listOf( + Coding( + system = Uri(value = EXTENSION_ITEM_CONTROL_SYSTEM), + code = Code(value = "drop-down"), + ), + ), + ), + ), + ), + ), + required = FhirR4Boolean(value = true), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "drop-down-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + questionViewTextConfiguration = QuestionTextConfiguration(showRequiredText = false), + ), + ) + } + + onNodeWithText("Required", substring = true).assertDoesNotExist() + } + + @Test + fun showsOptionalText() = runComposeUiTest { + setContent { + QuestionnaireDropDownView( + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "drop-down-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Choice), + extension = + listOf( + Extension( + url = EXTENSION_ITEM_CONTROL_URL, + value = + Extension.Value.CodeableConcept( + value = + CodeableConcept( + coding = + listOf( + Coding( + system = Uri(value = EXTENSION_ITEM_CONTROL_SYSTEM), + code = Code(value = "drop-down"), + ), + ), + ), + ), + ), + ), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "drop-down-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + questionViewTextConfiguration = QuestionTextConfiguration(showOptionalText = true), + ), + ) + } + + onNodeWithTag(DROP_DOWN_TEXT_FIELD_TAG) + .assertTextEquals("Optional", includeEditableText = false) + } + + @Test + fun hideOptionalText() = runComposeUiTest { + setContent { + QuestionnaireDropDownView( + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "drop-down-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Choice), + extension = + listOf( + Extension( + url = EXTENSION_ITEM_CONTROL_URL, + value = + Extension.Value.CodeableConcept( + value = + CodeableConcept( + coding = + listOf( + Coding( + system = Uri(value = EXTENSION_ITEM_CONTROL_SYSTEM), + code = Code(value = "drop-down"), + ), + ), + ), + ), + ), + ), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "drop-down-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + questionViewTextConfiguration = QuestionTextConfiguration(showOptionalText = false), + ), + ) + } + + onNodeWithText("Optional", substring = true).assertDoesNotExist() + } + + private fun answerOptions(vararg options: String) = + Questionnaire.Item( + linkId = FhirR4String(value = "drop-down-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Choice), + extension = + listOf( + Extension( + url = EXTENSION_ITEM_CONTROL_URL, + value = + Extension.Value.CodeableConcept( + value = + CodeableConcept( + coding = + listOf( + Coding( + system = Uri(value = EXTENSION_ITEM_CONTROL_SYSTEM), + code = Code(value = "drop-down"), + ), + ), + ), + ), + ), + ), + answerOption = + options.map { option -> + Questionnaire.Item.AnswerOption( + value = + Questionnaire.Item.AnswerOption.Value.Coding( + value = + Coding( + code = Code(value = option.replace(" ", "_")), + display = FhirR4String(value = option), + ), + ), + ) + }, + ) + + private fun responseOptions(vararg responses: String) = + QuestionnaireResponse.Item( + linkId = FhirR4String(value = "drop-down-item"), + answer = + responses.map { response -> + QuestionnaireResponse.Item.Answer( + value = + QuestionnaireResponse.Item.Answer.Value.Coding( + value = + Coding( + code = Code(value = response.replace(" ", "_")), + display = FhirR4String(value = response), + ), + ), + ) + }, + ) + + private fun createAnswerOptions(vararg options: String, hasImageExtension: Boolean = false) = + Questionnaire.Item( + linkId = FhirR4String(value = "drop-down-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Choice), + extension = + listOf( + Extension( + url = EXTENSION_ITEM_CONTROL_URL, + value = + Extension.Value.CodeableConcept( + value = + CodeableConcept( + coding = + listOf( + Coding( + system = Uri(value = EXTENSION_ITEM_CONTROL_SYSTEM), + code = Code(value = "drop-down"), + ), + ), + ), + ), + ), + ), + answerOption = + options.map { option -> + Questionnaire.Item.AnswerOption( + value = + Questionnaire.Item.AnswerOption.Value.String(value = FhirR4String(value = option)), + extension = if (hasImageExtension) listOf(itemAnswerMediaExtension) else emptyList(), + ) + }, + ) + + private fun responseValueStringOptions(vararg responses: String) = + QuestionnaireResponse.Item( + linkId = FhirR4String(value = "drop-down-item"), + answer = + responses.map { response -> + QuestionnaireResponse.Item.Answer( + value = + QuestionnaireResponse.Item.Answer.Value.String( + value = FhirR4String(value = response), + ), + ) + }, + ) + + @OptIn(ExperimentalEncodingApi::class) + private val itemAnswerMediaExtension = + Extension( + url = EXTENSION_ITEM_ANSWER_MEDIA, + value = + Extension.Value.Attachment( + value = + Attachment( + contentType = Code(value = "image/png"), + data = + Base64Binary( + value = + "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", + ), + ), + ), + ) +} diff --git a/datacapture/src/commonTest/kotlin/dev/ohs/fhir/datacapture/views/factories/EditTextDecimalViewFactoryTest.kt b/datacapture/src/commonTest/kotlin/dev/ohs/fhir/datacapture/views/factories/EditTextDecimalViewFactoryTest.kt new file mode 100644 index 000000000..de225f550 --- /dev/null +++ b/datacapture/src/commonTest/kotlin/dev/ohs/fhir/datacapture/views/factories/EditTextDecimalViewFactoryTest.kt @@ -0,0 +1,661 @@ +/* + * Copyright 2025-2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.ohs.fhir.datacapture.views.factories + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performTextReplacement +import androidx.compose.ui.test.runComposeUiTest +import com.google.fhir.model.r4.Code +import com.google.fhir.model.r4.Coding +import com.google.fhir.model.r4.Decimal +import com.google.fhir.model.r4.Enumeration +import com.google.fhir.model.r4.Extension +import com.google.fhir.model.r4.Questionnaire +import com.google.fhir.model.r4.QuestionnaireResponse +import com.ionspin.kotlin.bignum.decimal.toBigDecimal +import dev.ohs.fhir.datacapture.extensions.FhirR4Boolean +import dev.ohs.fhir.datacapture.extensions.FhirR4String +import dev.ohs.fhir.datacapture.theme.QuestionnaireTheme +import dev.ohs.fhir.datacapture.validation.Invalid +import dev.ohs.fhir.datacapture.validation.NotValidated +import dev.ohs.fhir.datacapture.views.QuestionTextConfiguration +import dev.ohs.fhir.datacapture.views.QuestionnaireViewItem +import dev.ohs.fhir.datacapture.views.components.EDIT_TEXT_FIELD_TEST_TAG +import dev.ohs.fhir.datacapture.views.components.ERROR_TEXT_AT_HEADER_TEST_TAG +import dev.ohs.fhir.datacapture.views.components.QUESTION_HEADER_TAG +import dev.ohs.fhir.datacapture.views.components.UNIT_TEXT_TEST_TAG +import io.kotest.matchers.collections.shouldBeEmpty +import io.kotest.matchers.shouldBe +import kotlin.test.Test + +@OptIn(ExperimentalTestApi::class) +class EditTextDecimalViewFactoryTest { + @Composable + fun QuestionnaireEditTextDecimalView(questionnaireViewItem: QuestionnaireViewItem) { + QuestionnaireTheme { EditTextDecimalViewFactory.Content(questionnaireViewItem) } + } + + @Test + fun shouldSetQuestionnaireHeader() = runComposeUiTest { + setContent { + QuestionnaireEditTextDecimalView( + questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "decimal-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Decimal), + text = FhirR4String(value = "Question?"), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "decimal-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ), + ) + } + + onNodeWithTag(QUESTION_HEADER_TAG).assertTextEquals("Question?") + } + + @Test + fun shouldSetInputText() = runComposeUiTest { + setContent { + QuestionnaireEditTextDecimalView( + questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "decimal-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Decimal), + ), + QuestionnaireResponse.Item( + linkId = FhirR4String(value = "decimal-item"), + answer = + listOf( + QuestionnaireResponse.Item.Answer( + value = + QuestionnaireResponse.Item.Answer.Value.Decimal( + value = Decimal(value = "1.1".toBigDecimal()), + ), + ), + ), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ), + ) + } + + onNodeWithTag(EDIT_TEXT_FIELD_TEST_TAG).assertTextEquals("1.1") + } + + @Test + fun shouldSetInputTextToEmpty() = runComposeUiTest { + var questionnaireViewItem by + mutableStateOf( + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "decimal-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Decimal), + ), + QuestionnaireResponse.Item( + linkId = FhirR4String(value = "decimal-item"), + answer = + listOf( + QuestionnaireResponse.Item.Answer( + value = + QuestionnaireResponse.Item.Answer.Value.Decimal( + value = Decimal(value = "1.1".toBigDecimal()), + ), + ), + ), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ), + ) + + setContent { QuestionnaireEditTextDecimalView(questionnaireViewItem) } + + onNodeWithTag(EDIT_TEXT_FIELD_TEST_TAG).assertTextEquals("1.1") + + questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "decimal-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Decimal), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "decimal-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + + onNodeWithTag(EDIT_TEXT_FIELD_TEST_TAG).assertTextEquals("") + } + + @Test + fun shouldSetUnitText() = runComposeUiTest { + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "decimal-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Decimal), + extension = + listOf( + Extension( + url = "http://hl7.org/fhir/StructureDefinition/questionnaire-unit", + value = + Extension.Value.Coding( + value = Coding(code = Code(value = "kg")), + ), + ), + ), + ), + QuestionnaireResponse.Item( + linkId = FhirR4String(value = "decimal-item"), + answer = + listOf( + QuestionnaireResponse.Item.Answer( + value = + QuestionnaireResponse.Item.Answer.Value.Decimal( + value = Decimal(value = "1.1".toBigDecimal()), + ), + ), + ), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + + setContent { + QuestionnaireEditTextDecimalView( + questionnaireViewItem, + ) + } + + onNodeWithTag(UNIT_TEXT_TEST_TAG).assertIsDisplayed().assertTextEquals("kg") + } + + @Test + fun shouldClearUnitText() = runComposeUiTest { + var questionnaireViewItem by + mutableStateOf( + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "decimal-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Decimal), + extension = + listOf( + Extension( + url = "http://hl7.org/fhir/StructureDefinition/questionnaire-unit", + value = + Extension.Value.Coding( + value = Coding(code = Code("kg")), + ), + ), + ), + ), + QuestionnaireResponse.Item( + linkId = FhirR4String(value = "decimal-item"), + answer = + listOf( + QuestionnaireResponse.Item.Answer( + value = + QuestionnaireResponse.Item.Answer.Value.Decimal( + value = Decimal(value = "1.1".toBigDecimal()), + ), + ), + ), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ), + ) + + setContent { QuestionnaireEditTextDecimalView(questionnaireViewItem) } + questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "decimal-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Decimal), + ), + QuestionnaireResponse.Item( + linkId = FhirR4String(value = "decimal-item"), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + + onNodeWithTag(EDIT_TEXT_FIELD_TEST_TAG).assertTextEquals("") + onNodeWithTag(UNIT_TEXT_TEST_TAG).assertDoesNotExist() + } + + @Test + fun shouldSetQuestionnaireResponseItemAnswerIfTextIsValid() = runComposeUiTest { + var answers: List? = null + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "decimal-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Decimal), + ), + QuestionnaireResponse.Item( + linkId = FhirR4String(value = "decimal-item"), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, result, _ -> answers = result }, + ) + + setContent { QuestionnaireEditTextDecimalView(questionnaireViewItem) } + onNodeWithTag(EDIT_TEXT_FIELD_TEST_TAG).performTextReplacement("1.1") + waitUntil { answers != null } + + answers!!.single().value?.asDecimal()?.value?.value.shouldBe(1.1.toBigDecimal()) + } + + @Test + fun shouldSetQuestionnaireResponseItemAnswerToEmpty() = runComposeUiTest { + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "decimal-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Decimal), + ), + QuestionnaireResponse.Item( + linkId = FhirR4String(value = "decimal-item"), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + setContent { QuestionnaireEditTextDecimalView(questionnaireViewItem) } + onNodeWithTag(EDIT_TEXT_FIELD_TEST_TAG).performTextReplacement("") + waitForIdle() + + questionnaireViewItem.answers.shouldBeEmpty() + } + + @Test + fun shouldSetDraftAnswerIfTextIsInvalid() = runComposeUiTest { + var draftAnswer: Any? = null + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "decimal-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Decimal), + ), + QuestionnaireResponse.Item( + linkId = FhirR4String(value = "decimal-item"), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, result -> draftAnswer = result }, + ) + setContent { QuestionnaireEditTextDecimalView(questionnaireViewItem) } + onNodeWithTag(EDIT_TEXT_FIELD_TEST_TAG).performTextReplacement("1.1.1.1") + waitUntil { draftAnswer != null } + + (draftAnswer as String).shouldBe("1.1.1.1") + } + + @Test + fun displayValidationResultShouldShowNoErrorMessage() = runComposeUiTest { + setContent { + QuestionnaireEditTextDecimalView( + questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "decimal-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Decimal), + extension = + listOf( + Extension( + url = "http://hl7.org/fhir/StructureDefinition/minValue", + value = Extension.Value.Decimal(value = Decimal(value = "2.2".toBigDecimal())), + ), + Extension( + url = "http://hl7.org/fhir/StructureDefinition/maxValue", + value = Extension.Value.Decimal(value = Decimal(value = "4.4".toBigDecimal())), + ), + ), + ), + QuestionnaireResponse.Item( + linkId = FhirR4String(value = "decimal-item"), + answer = + listOf( + QuestionnaireResponse.Item.Answer( + value = + QuestionnaireResponse.Item.Answer.Value.Decimal( + value = Decimal(value = "3.3".toBigDecimal()), + ), + ), + ), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ), + ) + } + + onNodeWithContentDescription("Error").assertDoesNotExist() + } + + @Test + fun displayValidationResultShouldShowErrorMessage() = runComposeUiTest { + setContent { + QuestionnaireEditTextDecimalView( + questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "decimal-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Decimal), + extension = + listOf( + Extension( + url = "http://hl7.org/fhir/StructureDefinition/minValue", + value = Extension.Value.Decimal(value = Decimal(value = "2.1".toBigDecimal())), + ), + Extension( + url = "http://hl7.org/fhir/StructureDefinition/maxValue", + value = Extension.Value.Decimal(value = Decimal(value = "4.2".toBigDecimal())), + ), + ), + ), + QuestionnaireResponse.Item( + linkId = FhirR4String(value = "decimal-item"), + answer = + listOf( + QuestionnaireResponse.Item.Answer( + value = + QuestionnaireResponse.Item.Answer.Value.Decimal( + value = Decimal(value = "1.1".toBigDecimal()), + ), + ), + ), + ), + validationResult = Invalid(listOf("Minimum value allowed is:2.1")), + answersChangedCallback = { _, _, _, _ -> }, + ), + ) + } + + onNodeWithContentDescription("Error").assertIsDisplayed() + onNodeWithText("Minimum value allowed is:2.1").assertIsDisplayed() + } + + @Test + fun hidesErrorTextviewInTheHeader() = runComposeUiTest { + setContent { + QuestionnaireEditTextDecimalView( + questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "decimal-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Decimal), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "decimal-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ), + ) + } + + onNodeWithTag(ERROR_TEXT_AT_HEADER_TEST_TAG).assertDoesNotExist() + } + + @Test + fun bindReadOnlyShouldDisableView() = runComposeUiTest { + setContent { + QuestionnaireEditTextDecimalView( + questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "decimal-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Decimal), + readOnly = FhirR4Boolean(value = true), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "decimal-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ), + ) + } + + onNodeWithTag(EDIT_TEXT_FIELD_TEST_TAG).assertIsNotEnabled() + } + + @Test + fun showAsterisk() = runComposeUiTest { + setContent { + QuestionnaireEditTextDecimalView( + questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "decimal-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Decimal), + text = FhirR4String(value = "Question?"), + required = FhirR4Boolean(value = true), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "decimal-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + questionViewTextConfiguration = QuestionTextConfiguration(showAsterisk = true), + ), + ) + } + + onNodeWithTag(QUESTION_HEADER_TAG).assertTextEquals("Question? *") + } + + @Test + fun hideAsterisk() = runComposeUiTest { + setContent { + QuestionnaireEditTextDecimalView( + questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "decimal-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Decimal), + text = FhirR4String(value = "Question?"), + required = FhirR4Boolean(value = true), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "decimal-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + questionViewTextConfiguration = QuestionTextConfiguration(showAsterisk = false), + ), + ) + } + + onNodeWithTag(QUESTION_HEADER_TAG).assertTextEquals("Question?") + } + + @Test + fun showsRequiredText() = runComposeUiTest { + setContent { + QuestionnaireEditTextDecimalView( + questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "decimal-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Decimal), + required = FhirR4Boolean(value = true), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "decimal-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + questionViewTextConfiguration = QuestionTextConfiguration(showRequiredText = true), + ), + ) + } + + onNodeWithText("Required", substring = true).assertIsDisplayed() + } + + @Test + fun hideRequiredText() = runComposeUiTest { + setContent { + QuestionnaireEditTextDecimalView( + questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "decimal-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Decimal), + required = FhirR4Boolean(value = true), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "decimal-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + questionViewTextConfiguration = QuestionTextConfiguration(showRequiredText = false), + ), + ) + } + + onNodeWithText("Required").assertDoesNotExist() + } + + @Test + fun showOptionalText() = runComposeUiTest { + setContent { + QuestionnaireEditTextDecimalView( + questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "decimal-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Decimal), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "decimal-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + questionViewTextConfiguration = QuestionTextConfiguration(showOptionalText = true), + ), + ) + } + + onNodeWithText("Optional", substring = true).assertIsDisplayed() + } + + @Test + fun hideOptionalText() = runComposeUiTest { + setContent { + QuestionnaireEditTextDecimalView( + questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "decimal-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Decimal), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "decimal-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + questionViewTextConfiguration = QuestionTextConfiguration(showOptionalText = false), + ), + ) + } + + onNodeWithText("Optional").assertDoesNotExist() + } + + @Test + fun bindAgainShouldRemovePreviousText() = runComposeUiTest { + var questionnaireViewItem by + mutableStateOf( + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "decimal-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Decimal), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "decimal-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + draftAnswer = "1.1.1.1", + ), + ) + + setContent { QuestionnaireEditTextDecimalView(questionnaireViewItem) } + + onNodeWithTag(EDIT_TEXT_FIELD_TEST_TAG, useUnmergedTree = true).assertTextEquals("1.1.1.1") + + questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "decimal-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Decimal), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "decimal-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + + onNodeWithTag(EDIT_TEXT_FIELD_TEST_TAG, useUnmergedTree = true).assertTextEquals("") + } + + @Test + fun displaysCorrectTextOnQuestionnaireViewItemAnswerUpdate() = runComposeUiTest { + var questionnaireViewItem by + mutableStateOf( + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "decimal-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Decimal), + text = FhirR4String(value = "Weight"), + ), + QuestionnaireResponse.Item( + linkId = FhirR4String(value = "decimal-item"), + answer = + listOf( + QuestionnaireResponse.Item.Answer( + value = + QuestionnaireResponse.Item.Answer.Value.Decimal( + value = Decimal(value = "124.5".toBigDecimal()), + ), + ), + ), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ), + ) + + setContent { QuestionnaireEditTextDecimalView(questionnaireViewItem) } + onNodeWithTag(EDIT_TEXT_FIELD_TEST_TAG).assertTextEquals("124.5") + + questionnaireViewItem = + questionnaireViewItem.copy( + questionnaireResponseItem = + QuestionnaireResponse.Item( + linkId = FhirR4String(value = "decimal-item"), + answer = + listOf( + QuestionnaireResponse.Item.Answer( + value = + QuestionnaireResponse.Item.Answer.Value.Decimal( + value = Decimal(value = "124.578".toBigDecimal()), + ), + ), + ), + ), + ) + onNodeWithTag(EDIT_TEXT_FIELD_TEST_TAG).assertTextEquals("124.578") + } +} diff --git a/datacapture/src/commonTest/kotlin/dev/ohs/fhir/datacapture/views/factories/EditTextIntegerViewFactoryTest.kt b/datacapture/src/commonTest/kotlin/dev/ohs/fhir/datacapture/views/factories/EditTextIntegerViewFactoryTest.kt new file mode 100644 index 000000000..a789200a0 --- /dev/null +++ b/datacapture/src/commonTest/kotlin/dev/ohs/fhir/datacapture/views/factories/EditTextIntegerViewFactoryTest.kt @@ -0,0 +1,543 @@ +/* + * Copyright 2025-2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.ohs.fhir.datacapture.views.factories + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performTextReplacement +import androidx.compose.ui.test.runComposeUiTest +import com.google.fhir.model.r4.Enumeration +import com.google.fhir.model.r4.Extension +import com.google.fhir.model.r4.Integer +import com.google.fhir.model.r4.Questionnaire +import com.google.fhir.model.r4.QuestionnaireResponse +import dev.ohs.fhir.datacapture.extensions.FhirR4Boolean +import dev.ohs.fhir.datacapture.extensions.FhirR4String +import dev.ohs.fhir.datacapture.theme.QuestionnaireTheme +import dev.ohs.fhir.datacapture.validation.Invalid +import dev.ohs.fhir.datacapture.validation.NotValidated +import dev.ohs.fhir.datacapture.views.QuestionTextConfiguration +import dev.ohs.fhir.datacapture.views.QuestionnaireViewItem +import dev.ohs.fhir.datacapture.views.components.EDIT_TEXT_FIELD_TEST_TAG +import dev.ohs.fhir.datacapture.views.components.ERROR_TEXT_AT_HEADER_TEST_TAG +import dev.ohs.fhir.datacapture.views.components.QUESTION_HEADER_TAG +import io.kotest.matchers.shouldBe +import kotlin.test.Test + +@OptIn(ExperimentalTestApi::class) +class EditTextIntegerViewFactoryTest { + + @Composable + fun QuestionnaireEditTextIntegerView(questionnaireViewItem: QuestionnaireViewItem) { + QuestionnaireTheme { EditTextIntegerViewFactory.Content(questionnaireViewItem) } + } + + @Test + fun shouldSetQuestionnaireHeader() = runComposeUiTest { + setContent { + QuestionnaireEditTextIntegerView( + questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "integer-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Integer), + text = FhirR4String(value = "Question?"), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "integer-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ), + ) + } + + onNodeWithTag(QUESTION_HEADER_TAG).assertTextEquals("Question?") + } + + @Test + fun shouldSetInputText() = runComposeUiTest { + setContent { + QuestionnaireEditTextIntegerView( + questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "integer-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Integer), + ), + QuestionnaireResponse.Item( + linkId = FhirR4String(value = "integer-item"), + answer = + listOf( + QuestionnaireResponse.Item.Answer( + value = + QuestionnaireResponse.Item.Answer.Value.Integer(value = Integer(value = 5)), + ), + ), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ), + ) + } + + onNodeWithTag(EDIT_TEXT_FIELD_TEST_TAG).assertTextEquals("5") + } + + @Test + fun shouldSetInputTextToEmpty() = runComposeUiTest { + var questionnaireViewItem by + mutableStateOf( + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "integer-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Integer), + ), + QuestionnaireResponse.Item( + linkId = FhirR4String(value = "integer-item"), + answer = + listOf( + QuestionnaireResponse.Item.Answer( + value = + QuestionnaireResponse.Item.Answer.Value.Integer(value = Integer(value = 5)), + ), + ), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ), + ) + + setContent { QuestionnaireEditTextIntegerView(questionnaireViewItem) } + + onNodeWithTag(EDIT_TEXT_FIELD_TEST_TAG).assertTextEquals("5") + + questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "integer-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Integer), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "integer-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + + onNodeWithTag(EDIT_TEXT_FIELD_TEST_TAG).assertTextEquals("") + } + + @Test + fun shouldSetQuestionnaireResponseItemAnswerIfTextIsValid() = runComposeUiTest { + var answers: List? = null + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "integer-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Integer), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "integer-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, result, _ -> answers = result }, + ) + + setContent { QuestionnaireEditTextIntegerView(questionnaireViewItem) } + onNodeWithTag(EDIT_TEXT_FIELD_TEST_TAG).performTextReplacement("13") + waitUntil { answers != null } + + answers!!.single().value!!.asInteger()!!.value.value.shouldBe(13) + } + + @Test + fun shouldSetQuestionnaireResponseItemAnswerToEmpty() = runComposeUiTest { + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "integer-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Integer), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "integer-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + setContent { QuestionnaireEditTextIntegerView(questionnaireViewItem) } + onNodeWithTag(EDIT_TEXT_FIELD_TEST_TAG).performTextReplacement("") + waitForIdle() + + questionnaireViewItem.answers.isEmpty() + } + + @Test + fun shouldSetDraftAnswerIfTextIsInvalid() = runComposeUiTest { + var draftAnswer: Any? = null + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "integer-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Integer), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "integer-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, result -> draftAnswer = result }, + ) + setContent { QuestionnaireEditTextIntegerView(questionnaireViewItem) } + // The character in 1O2 is the letter O, not the number 0 + onNodeWithTag(EDIT_TEXT_FIELD_TEST_TAG).performTextReplacement("1O2") + waitUntil { draftAnswer != null } + (draftAnswer as String).shouldBe("1O2") + } + + @Test + fun displayValidationResultShouldShowNoErrorMessage() = runComposeUiTest { + setContent { + QuestionnaireEditTextIntegerView( + questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "integer-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Integer), + extension = + listOf( + Extension( + url = "http://hl7.org/fhir/StructureDefinition/minValue", + value = Extension.Value.Integer(value = Integer(value = 2)), + ), + Extension( + url = "http://hl7.org/fhir/StructureDefinition/maxValue", + value = Extension.Value.Integer(value = Integer(value = 4)), + ), + ), + ), + QuestionnaireResponse.Item( + linkId = FhirR4String(value = "integer-item"), + answer = + listOf( + QuestionnaireResponse.Item.Answer( + value = + QuestionnaireResponse.Item.Answer.Value.Integer(value = Integer(value = 3)), + ), + ), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ), + ) + } + + onNodeWithContentDescription("Error").assertDoesNotExist() + } + + @Test + fun displayValidationResultShouldShowErrorMessage() = runComposeUiTest { + setContent { + QuestionnaireEditTextIntegerView( + questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "integer-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Integer), + extension = + listOf( + Extension( + url = "http://hl7.org/fhir/StructureDefinition/minValue", + value = Extension.Value.Integer(value = Integer(value = 2)), + ), + Extension( + url = "http://hl7.org/fhir/StructureDefinition/maxValue", + value = Extension.Value.Integer(value = Integer(value = 4)), + ), + ), + ), + QuestionnaireResponse.Item( + linkId = FhirR4String(value = "integer-item"), + answer = + listOf( + QuestionnaireResponse.Item.Answer( + value = + QuestionnaireResponse.Item.Answer.Value.Integer(value = Integer(value = 1)), + ), + ), + ), + validationResult = Invalid(listOf("Minimum value allowed is:2")), + answersChangedCallback = { _, _, _, _ -> }, + ), + ) + } + + onNodeWithContentDescription("Error").assertIsDisplayed() + onNodeWithText("Minimum value allowed is:2").assertIsDisplayed() + } + + @Test + fun hidesErrorTextviewInTheHeader() = runComposeUiTest { + setContent { + QuestionnaireEditTextIntegerView( + questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "integer-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Integer), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "integer-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ), + ) + } + + onNodeWithTag(ERROR_TEXT_AT_HEADER_TEST_TAG).assertDoesNotExist() + } + + @Test + fun bindReadOnlyShouldDisableView() = runComposeUiTest { + setContent { + QuestionnaireEditTextIntegerView( + questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "integer-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Integer), + readOnly = FhirR4Boolean(value = true), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "integer-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ), + ) + } + + onNodeWithTag(EDIT_TEXT_FIELD_TEST_TAG).assertIsNotEnabled() + } + + @Test + fun showAsterisk() = runComposeUiTest { + setContent { + QuestionnaireEditTextIntegerView( + questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "integer-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Integer), + text = FhirR4String(value = "Question?"), + required = FhirR4Boolean(value = true), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "integer-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + questionViewTextConfiguration = QuestionTextConfiguration(showAsterisk = true), + ), + ) + } + + onNodeWithTag(QUESTION_HEADER_TAG).assertTextEquals("Question? *") + } + + @Test + fun hideAsterisk() = runComposeUiTest { + setContent { + QuestionnaireEditTextIntegerView( + questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "integer-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Integer), + text = FhirR4String(value = "Question?"), + required = FhirR4Boolean(value = true), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "integer-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + questionViewTextConfiguration = QuestionTextConfiguration(showAsterisk = false), + ), + ) + } + + onNodeWithTag(QUESTION_HEADER_TAG).assertTextEquals("Question?") + } + + @Test + fun showsRequiredText() = runComposeUiTest { + setContent { + QuestionnaireEditTextIntegerView( + questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "integer-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Integer), + required = FhirR4Boolean(value = true), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "integer-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + questionViewTextConfiguration = QuestionTextConfiguration(showRequiredText = true), + ), + ) + } + + onNodeWithText("Required", substring = true).assertIsDisplayed() + } + + @Test + fun hideRequiredText() = runComposeUiTest { + setContent { + QuestionnaireEditTextIntegerView( + questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "integer-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Integer), + required = FhirR4Boolean(value = true), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "integer-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + questionViewTextConfiguration = QuestionTextConfiguration(showRequiredText = false), + ), + ) + } + + onNodeWithText("Required").assertDoesNotExist() + } + + @Test + fun showOptionalText() = runComposeUiTest { + setContent { + QuestionnaireEditTextIntegerView( + questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "integer-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Integer), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "integer-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + questionViewTextConfiguration = QuestionTextConfiguration(showOptionalText = true), + ), + ) + } + + onNodeWithText("Optional", substring = true).assertIsDisplayed() + } + + @Test + fun hideOptionalText() = runComposeUiTest { + setContent { + QuestionnaireEditTextIntegerView( + questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "integer-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Integer), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "integer-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + questionViewTextConfiguration = QuestionTextConfiguration(showOptionalText = false), + ), + ) + } + + onNodeWithText("Optional").assertDoesNotExist() + } + + @Test + fun bindAgainShouldRemovePreviousText() = runComposeUiTest { + var questionnaireViewItem by + mutableStateOf( + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "integer-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Integer), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "integer-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + draftAnswer = "9999999999", + ), + ) + + setContent { QuestionnaireEditTextIntegerView(questionnaireViewItem) } + + onNodeWithTag(EDIT_TEXT_FIELD_TEST_TAG, useUnmergedTree = true).assertTextEquals("9999999999") + + questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "integer-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Integer), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "integer-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + + onNodeWithTag(EDIT_TEXT_FIELD_TEST_TAG, useUnmergedTree = true).assertTextEquals("") + } + + @Test + fun displaysCorrectTextOnQuestionnaireViewItemAnswerUpdate() = runComposeUiTest { + var questionnaireViewItem by + mutableStateOf( + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "integer-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Integer), + text = FhirR4String(value = "Age"), + ), + QuestionnaireResponse.Item( + linkId = FhirR4String(value = "integer-item"), + answer = + listOf( + QuestionnaireResponse.Item.Answer( + value = + QuestionnaireResponse.Item.Answer.Value.Integer(value = Integer(value = 12)), + ), + ), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ), + ) + + setContent { QuestionnaireEditTextIntegerView(questionnaireViewItem) } + onNodeWithTag(EDIT_TEXT_FIELD_TEST_TAG).assertTextEquals("12") + + questionnaireViewItem = + questionnaireViewItem.copy( + questionnaireResponseItem = + QuestionnaireResponse.Item( + linkId = FhirR4String(value = "integer-item"), + answer = + listOf( + QuestionnaireResponse.Item.Answer( + value = + QuestionnaireResponse.Item.Answer.Value.Integer(value = Integer(value = 120)), + ), + ), + ), + ) + + onNodeWithTag(EDIT_TEXT_FIELD_TEST_TAG).assertTextEquals("120") + } +} diff --git a/datacapture/src/commonTest/kotlin/dev/ohs/fhir/datacapture/views/factories/EditTextMultiLineViewFactoryTest.kt b/datacapture/src/commonTest/kotlin/dev/ohs/fhir/datacapture/views/factories/EditTextMultiLineViewFactoryTest.kt new file mode 100644 index 000000000..ec441d413 --- /dev/null +++ b/datacapture/src/commonTest/kotlin/dev/ohs/fhir/datacapture/views/factories/EditTextMultiLineViewFactoryTest.kt @@ -0,0 +1,448 @@ +/* + * Copyright 2025-2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.ohs.fhir.datacapture.views.factories + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performTextReplacement +import androidx.compose.ui.test.runComposeUiTest +import com.google.fhir.model.r4.Enumeration +import com.google.fhir.model.r4.Extension +import com.google.fhir.model.r4.Integer +import com.google.fhir.model.r4.Questionnaire +import com.google.fhir.model.r4.QuestionnaireResponse +import dev.ohs.fhir.datacapture.extensions.FhirR4Boolean +import dev.ohs.fhir.datacapture.extensions.FhirR4String +import dev.ohs.fhir.datacapture.theme.QuestionnaireTheme +import dev.ohs.fhir.datacapture.validation.Invalid +import dev.ohs.fhir.datacapture.validation.NotValidated +import dev.ohs.fhir.datacapture.views.QuestionTextConfiguration +import dev.ohs.fhir.datacapture.views.QuestionnaireViewItem +import dev.ohs.fhir.datacapture.views.components.EDIT_TEXT_FIELD_TEST_TAG +import dev.ohs.fhir.datacapture.views.components.ERROR_TEXT_AT_HEADER_TEST_TAG +import dev.ohs.fhir.datacapture.views.components.QUESTION_HEADER_TAG +import io.kotest.matchers.collections.shouldBeEmpty +import io.kotest.matchers.shouldBe +import kotlin.test.Test + +@OptIn(ExperimentalTestApi::class) +class EditTextMultiLineViewFactoryTest { + + @Composable + fun QuestionnaireEditTextMultiLineView(questionnaireViewItem: QuestionnaireViewItem) { + QuestionnaireTheme { EditTextMultiLineViewFactory.Content(questionnaireViewItem) } + } + + @Test + fun shouldSetQuestionnaireHeader() = runComposeUiTest { + setContent { + QuestionnaireEditTextMultiLineView( + questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "text-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Text), + text = FhirR4String(value = "Question?"), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "text-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ), + ) + } + + onNodeWithTag(QUESTION_HEADER_TAG).assertTextEquals("Question?") + } + + @Test + fun shouldSetInputText() = runComposeUiTest { + setContent { + QuestionnaireEditTextMultiLineView( + questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "text-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Text), + ), + QuestionnaireResponse.Item( + linkId = FhirR4String(value = "text-item"), + answer = + listOf( + QuestionnaireResponse.Item.Answer( + value = + QuestionnaireResponse.Item.Answer.Value.String( + value = FhirR4String(value = "Answer"), + ), + ), + ), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ), + ) + } + + onNodeWithTag(EDIT_TEXT_FIELD_TEST_TAG).assertTextEquals("Answer") + } + + @Test + fun shouldSetInputTextToEmpty() = runComposeUiTest { + var questionnaireViewItem by + mutableStateOf( + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "text-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Text), + ), + QuestionnaireResponse.Item( + linkId = FhirR4String(value = "text-item"), + answer = + listOf( + QuestionnaireResponse.Item.Answer( + value = + QuestionnaireResponse.Item.Answer.Value.String( + value = FhirR4String(value = "Answer"), + ), + ), + ), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ), + ) + + setContent { QuestionnaireEditTextMultiLineView(questionnaireViewItem) } + + onNodeWithTag(EDIT_TEXT_FIELD_TEST_TAG).assertTextEquals("Answer") + + questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "text-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Text), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "text-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + + onNodeWithTag(EDIT_TEXT_FIELD_TEST_TAG).assertTextEquals("") + } + + @Test + fun shouldSetQuestionnaireResponseItemAnswer() = runComposeUiTest { + var answers: List? = null + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "text-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Text), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "text-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, result, _ -> answers = result }, + ) + + setContent { QuestionnaireEditTextMultiLineView(questionnaireViewItem) } + onNodeWithTag(EDIT_TEXT_FIELD_TEST_TAG).performTextReplacement("Answer") + waitUntil { answers != null } + + answers!!.single().value!!.asString()!!.value.value!!.shouldBe("Answer") + } + + @Test + fun shouldSetQuestionnaireResponseItemAnswerToEmpty() = runComposeUiTest { + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "text-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Text), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "text-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + setContent { QuestionnaireEditTextMultiLineView(questionnaireViewItem) } + onNodeWithTag(EDIT_TEXT_FIELD_TEST_TAG).performTextReplacement("") + waitForIdle() + + questionnaireViewItem.answers.shouldBeEmpty() + } + + @Test + fun displayValidationResultNoErrorShouldShowNoErrorMessage() = runComposeUiTest { + setContent { + QuestionnaireEditTextMultiLineView( + questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "text-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Text), + extension = + listOf( + Extension( + url = "http://hl7.org/fhir/StructureDefinition/minLength", + value = Extension.Value.Integer(value = Integer(value = 10)), + ), + ), + ), + QuestionnaireResponse.Item( + linkId = FhirR4String(value = "text-item"), + answer = + listOf( + QuestionnaireResponse.Item.Answer( + value = + QuestionnaireResponse.Item.Answer.Value.String( + value = FhirR4String(value = "hello there"), + ), + ), + ), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ), + ) + } + + onNodeWithContentDescription("Error").assertDoesNotExist() + } + + @Test + fun displayValidationResultErrorShouldShowErrorMessage() = runComposeUiTest { + setContent { + QuestionnaireEditTextMultiLineView( + questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "text-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Text), + extension = + listOf( + Extension( + url = "http://hl7.org/fhir/StructureDefinition/minLength", + value = Extension.Value.Integer(value = Integer(value = 10)), + ), + ), + ), + QuestionnaireResponse.Item( + linkId = FhirR4String(value = "text-item"), + answer = + listOf( + QuestionnaireResponse.Item.Answer( + value = + QuestionnaireResponse.Item.Answer.Value.String( + value = FhirR4String(value = "hello"), + ), + ), + ), + ), + validationResult = + Invalid( + listOf("The minimum number of characters that are permitted in the answer is: 10"), + ), + answersChangedCallback = { _, _, _, _ -> }, + ), + ) + } + + onNodeWithContentDescription("Error").assertIsDisplayed() + onNodeWithText("The minimum number of characters that are permitted in the answer is: 10") + .assertIsDisplayed() + } + + @Test + fun hidesErrorTextviewInTheHeader() = runComposeUiTest { + setContent { + QuestionnaireEditTextMultiLineView( + questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "text-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Text), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "text-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ), + ) + } + + onNodeWithTag(ERROR_TEXT_AT_HEADER_TEST_TAG).assertDoesNotExist() + } + + @Test + fun bindReadOnlyShouldDisableView() = runComposeUiTest { + setContent { + QuestionnaireEditTextMultiLineView( + questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "text-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Text), + readOnly = FhirR4Boolean(value = true), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "text-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ), + ) + } + + onNodeWithTag(EDIT_TEXT_FIELD_TEST_TAG).assertIsNotEnabled() + } + + @Test + fun showAsterisk() = runComposeUiTest { + setContent { + QuestionnaireEditTextMultiLineView( + questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "text-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Text), + text = FhirR4String(value = "Question?"), + required = FhirR4Boolean(value = true), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "text-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + questionViewTextConfiguration = QuestionTextConfiguration(showAsterisk = true), + ), + ) + } + + onNodeWithTag(QUESTION_HEADER_TAG).assertTextEquals("Question? *") + } + + @Test + fun hideAsterisk() = runComposeUiTest { + setContent { + QuestionnaireEditTextMultiLineView( + questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "text-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Text), + text = FhirR4String(value = "Question?"), + required = FhirR4Boolean(value = true), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "text-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + questionViewTextConfiguration = QuestionTextConfiguration(showAsterisk = false), + ), + ) + } + + onNodeWithTag(QUESTION_HEADER_TAG).assertTextEquals("Question?") + } + + @Test + fun showsRequiredText() = runComposeUiTest { + setContent { + QuestionnaireEditTextMultiLineView( + questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "text-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Text), + required = FhirR4Boolean(value = true), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "text-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + questionViewTextConfiguration = QuestionTextConfiguration(showRequiredText = true), + ), + ) + } + + onNodeWithText("Required", substring = true).assertIsDisplayed() + } + + @Test + fun hideRequiredText() = runComposeUiTest { + setContent { + QuestionnaireEditTextMultiLineView( + questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "text-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Text), + required = FhirR4Boolean(value = true), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "text-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + questionViewTextConfiguration = QuestionTextConfiguration(showRequiredText = false), + ), + ) + } + + onNodeWithText("Required").assertDoesNotExist() + } + + @Test + fun showOptionalText() = runComposeUiTest { + setContent { + QuestionnaireEditTextMultiLineView( + questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "text-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Text), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "text-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + questionViewTextConfiguration = QuestionTextConfiguration(showOptionalText = true), + ), + ) + } + + onNodeWithText("Optional", substring = true).assertIsDisplayed() + } + + @Test + fun hideOptionalText() = runComposeUiTest { + setContent { + QuestionnaireEditTextMultiLineView( + questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "text-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Text), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "text-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + questionViewTextConfiguration = QuestionTextConfiguration(showOptionalText = false), + ), + ) + } + + onNodeWithText("Optional").assertDoesNotExist() + } +} diff --git a/datacapture/src/commonTest/kotlin/dev/ohs/fhir/datacapture/views/factories/EditTextPhoneNumberViewFactoryTest.kt b/datacapture/src/commonTest/kotlin/dev/ohs/fhir/datacapture/views/factories/EditTextPhoneNumberViewFactoryTest.kt new file mode 100644 index 000000000..5a34515a2 --- /dev/null +++ b/datacapture/src/commonTest/kotlin/dev/ohs/fhir/datacapture/views/factories/EditTextPhoneNumberViewFactoryTest.kt @@ -0,0 +1,452 @@ +/* + * Copyright 2023-2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.ohs.fhir.datacapture.views.factories + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performTextReplacement +import androidx.compose.ui.test.runComposeUiTest +import com.google.fhir.model.r4.Code +import com.google.fhir.model.r4.CodeableConcept +import com.google.fhir.model.r4.Coding +import com.google.fhir.model.r4.Enumeration +import com.google.fhir.model.r4.Extension +import com.google.fhir.model.r4.Questionnaire +import com.google.fhir.model.r4.QuestionnaireResponse +import com.google.fhir.model.r4.Uri +import dev.ohs.fhir.datacapture.extensions.EXTENSION_ITEM_CONTROL_SYSTEM +import dev.ohs.fhir.datacapture.extensions.EXTENSION_ITEM_CONTROL_URL +import dev.ohs.fhir.datacapture.extensions.FhirR4Boolean +import dev.ohs.fhir.datacapture.extensions.FhirR4Integer +import dev.ohs.fhir.datacapture.extensions.FhirR4String +import dev.ohs.fhir.datacapture.theme.QuestionnaireTheme +import dev.ohs.fhir.datacapture.validation.Invalid +import dev.ohs.fhir.datacapture.validation.NotValidated +import dev.ohs.fhir.datacapture.views.QuestionnaireViewItem +import dev.ohs.fhir.datacapture.views.components.EDIT_TEXT_FIELD_TEST_TAG +import dev.ohs.fhir.datacapture.views.components.QUESTION_HEADER_TAG +import io.kotest.matchers.collections.shouldBeEmpty +import io.kotest.matchers.shouldBe +import kotlin.test.Test + +@OptIn(ExperimentalTestApi::class) +class EditTextPhoneNumberViewFactoryTest { + @Composable + fun QuestionnaireEditTextPhoneNumberView(questionnaireViewItem: QuestionnaireViewItem) { + QuestionnaireTheme { EditTextPhoneNumberViewFactory.Content(questionnaireViewItem) } + } + + @Test + fun shouldSetTextViewText() = runComposeUiTest { + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "phone-number-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Integer), + extension = + listOf( + Extension( + url = EXTENSION_ITEM_CONTROL_URL, + value = + Extension.Value.CodeableConcept( + value = + CodeableConcept( + coding = + listOf( + Coding( + system = Uri(value = EXTENSION_ITEM_CONTROL_SYSTEM), + code = Code(value = "phone-number"), + ), + ), + ), + ), + ), + ), + text = FhirR4String(value = "Question?"), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "phone-number-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + + setContent { QuestionnaireEditTextPhoneNumberView(questionnaireViewItem) } + + onNodeWithTag(QUESTION_HEADER_TAG).assertTextEquals("Question?") + } + + @Test + fun shouldSetInputText() = runComposeUiTest { + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "phone-number-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Integer), + extension = + listOf( + Extension( + url = EXTENSION_ITEM_CONTROL_URL, + value = + Extension.Value.CodeableConcept( + value = + CodeableConcept( + coding = + listOf( + Coding( + system = Uri(value = EXTENSION_ITEM_CONTROL_SYSTEM), + code = Code(value = "phone-number"), + ), + ), + ), + ), + ), + ), + text = FhirR4String(value = "Question?"), + ), + QuestionnaireResponse.Item( + linkId = FhirR4String(value = "phone-number-item"), + answer = + listOf( + QuestionnaireResponse.Item.Answer( + value = + QuestionnaireResponse.Item.Answer.Value.String( + value = FhirR4String(value = "+12345678910"), + ), + ), + ), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + + setContent { QuestionnaireEditTextPhoneNumberView(questionnaireViewItem) } + + onNodeWithTag(EDIT_TEXT_FIELD_TEST_TAG).assertTextEquals("+12345678910") + } + + @Test + fun shouldSetInputTextToEmpty() = runComposeUiTest { + var questionnaireViewItem by + mutableStateOf( + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "phone-number-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Integer), + extension = + listOf( + Extension( + url = EXTENSION_ITEM_CONTROL_URL, + value = + Extension.Value.CodeableConcept( + value = + CodeableConcept( + coding = + listOf( + Coding( + system = Uri(value = EXTENSION_ITEM_CONTROL_SYSTEM), + code = Code(value = "phone-number"), + ), + ), + ), + ), + ), + ), + ), + QuestionnaireResponse.Item( + linkId = FhirR4String(value = "phone-number-item"), + answer = + listOf( + QuestionnaireResponse.Item.Answer( + value = + QuestionnaireResponse.Item.Answer.Value.String( + value = FhirR4String(value = "+12345678910"), + ), + ), + ), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ), + ) + + setContent { QuestionnaireEditTextPhoneNumberView(questionnaireViewItem) } + + questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "phone-number-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Integer), + extension = + listOf( + Extension( + url = EXTENSION_ITEM_CONTROL_URL, + value = + Extension.Value.CodeableConcept( + value = + CodeableConcept( + coding = + listOf( + Coding( + system = Uri(value = EXTENSION_ITEM_CONTROL_SYSTEM), + code = Code(value = "phone-number"), + ), + ), + ), + ), + ), + ), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "phone-number-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + + onNodeWithTag(EDIT_TEXT_FIELD_TEST_TAG).assertTextEquals("") + } + + @Test + fun shouldSetQuestionnaireResponseItemAnswer() = runComposeUiTest { + var answers: List? = null + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "phone-number-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Integer), + extension = + listOf( + Extension( + url = EXTENSION_ITEM_CONTROL_URL, + value = + Extension.Value.CodeableConcept( + value = + CodeableConcept( + coding = + listOf( + Coding( + system = Uri(value = EXTENSION_ITEM_CONTROL_SYSTEM), + code = Code(value = "phone-number"), + ), + ), + ), + ), + ), + ), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "phone-number-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, newAnswers, _ -> answers = newAnswers }, + ) + setContent { QuestionnaireEditTextPhoneNumberView(questionnaireViewItem) } + + onNodeWithTag(EDIT_TEXT_FIELD_TEST_TAG).performTextReplacement("+12345678910") + + waitUntil { answers != null } + + answers!!.single().value?.asString()?.value?.value.shouldBe("+12345678910") + } + + @Test + fun shouldSetQuestionnaireResponseItemAnswerToEmpty() = runComposeUiTest { + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "phone-number-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Integer), + extension = + listOf( + Extension( + url = EXTENSION_ITEM_CONTROL_URL, + value = + Extension.Value.CodeableConcept( + value = + CodeableConcept( + coding = + listOf( + Coding( + system = Uri(value = EXTENSION_ITEM_CONTROL_SYSTEM), + code = Code(value = "phone-number"), + ), + ), + ), + ), + ), + ), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "phone-number-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + + setContent { QuestionnaireEditTextPhoneNumberView(questionnaireViewItem) } + onNodeWithTag(EDIT_TEXT_FIELD_TEST_TAG).performTextReplacement("") + waitForIdle() + questionnaireViewItem.answers.shouldBeEmpty() + } + + @Test + fun displayValidationResult_noError_shouldShowNoErrorMessage() = runComposeUiTest { + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "phone-number-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Integer), + extension = + listOf( + Extension( + url = EXTENSION_ITEM_CONTROL_URL, + value = + Extension.Value.CodeableConcept( + value = + CodeableConcept( + coding = + listOf( + Coding( + system = Uri(value = EXTENSION_ITEM_CONTROL_SYSTEM), + code = Code(value = "phone-number"), + ), + ), + ), + ), + ), + Extension( + url = "http://hl7.org/fhir/StructureDefinition/minLength", + value = Extension.Value.Integer(value = FhirR4Integer(value = 10)), + ), + ), + ), + QuestionnaireResponse.Item( + linkId = FhirR4String(value = "phone-number-item"), + answer = + listOf( + QuestionnaireResponse.Item.Answer( + value = + QuestionnaireResponse.Item.Answer.Value.String( + value = FhirR4String(value = "hello there"), + ), + ), + ), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + + setContent { QuestionnaireEditTextPhoneNumberView(questionnaireViewItem) } + + onNodeWithContentDescription("Error").assertDoesNotExist() + } + + @Test + fun displayValidationResult_error_shouldShowErrorMessage() = runComposeUiTest { + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "phone-number-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Integer), + extension = + listOf( + Extension( + url = EXTENSION_ITEM_CONTROL_URL, + value = + Extension.Value.CodeableConcept( + value = + CodeableConcept( + coding = + listOf( + Coding( + system = Uri(value = EXTENSION_ITEM_CONTROL_SYSTEM), + code = Code(value = "phone-number"), + ), + ), + ), + ), + ), + ), + maxLength = FhirR4Integer(value = 10), + ), + QuestionnaireResponse.Item( + linkId = FhirR4String(value = "phone-number-item"), + answer = + listOf( + QuestionnaireResponse.Item.Answer( + value = + QuestionnaireResponse.Item.Answer.Value.String( + value = FhirR4String(value = "+1234567891011"), + ), + ), + ), + ), + validationResult = + Invalid( + listOf("The maximum number of characters that are permitted in the answer is: 10"), + ), + answersChangedCallback = { _, _, _, _ -> }, + ) + + setContent { QuestionnaireEditTextPhoneNumberView(questionnaireViewItem) } + + onNodeWithContentDescription("Error").assertIsDisplayed() + + onNodeWithText("The maximum number of characters that are permitted in the answer is: 10") + .assertIsDisplayed() + } + + @Test + fun bind_readOnly_shouldDisableView() = runComposeUiTest { + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "phone-number-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Integer), + extension = + listOf( + Extension( + url = EXTENSION_ITEM_CONTROL_URL, + value = + Extension.Value.CodeableConcept( + value = + CodeableConcept( + coding = + listOf( + Coding( + system = Uri(value = EXTENSION_ITEM_CONTROL_SYSTEM), + code = Code(value = "phone-number"), + ), + ), + ), + ), + ), + ), + readOnly = FhirR4Boolean(value = true), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "phone-number-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + + setContent { QuestionnaireEditTextPhoneNumberView(questionnaireViewItem) } + + onNodeWithTag(EDIT_TEXT_FIELD_TEST_TAG).assertIsNotEnabled() + } +} diff --git a/datacapture/src/commonTest/kotlin/dev/ohs/fhir/datacapture/views/factories/EditTextSingleLineViewFactoryTest.kt b/datacapture/src/commonTest/kotlin/dev/ohs/fhir/datacapture/views/factories/EditTextSingleLineViewFactoryTest.kt new file mode 100644 index 000000000..29c328579 --- /dev/null +++ b/datacapture/src/commonTest/kotlin/dev/ohs/fhir/datacapture/views/factories/EditTextSingleLineViewFactoryTest.kt @@ -0,0 +1,498 @@ +/* + * Copyright 2025-2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.ohs.fhir.datacapture.views.factories + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performTextReplacement +import androidx.compose.ui.test.runComposeUiTest +import com.google.fhir.model.r4.Enumeration +import com.google.fhir.model.r4.Extension +import com.google.fhir.model.r4.Integer +import com.google.fhir.model.r4.Questionnaire +import com.google.fhir.model.r4.QuestionnaireResponse +import dev.ohs.fhir.datacapture.extensions.FhirR4Boolean +import dev.ohs.fhir.datacapture.extensions.FhirR4String +import dev.ohs.fhir.datacapture.theme.QuestionnaireTheme +import dev.ohs.fhir.datacapture.validation.Invalid +import dev.ohs.fhir.datacapture.validation.NotValidated +import dev.ohs.fhir.datacapture.views.QuestionTextConfiguration +import dev.ohs.fhir.datacapture.views.QuestionnaireViewItem +import dev.ohs.fhir.datacapture.views.components.EDIT_TEXT_FIELD_TEST_TAG +import dev.ohs.fhir.datacapture.views.components.ERROR_TEXT_AT_HEADER_TEST_TAG +import dev.ohs.fhir.datacapture.views.components.QUESTION_HEADER_TAG +import io.kotest.matchers.collections.shouldBeEmpty +import io.kotest.matchers.shouldBe +import kotlin.test.Test + +@OptIn(ExperimentalTestApi::class) +class EditTextSingleLineViewFactoryTest { + + @Composable + fun QuestionnaireEditTextSingleLineView(questionnaireViewItem: QuestionnaireViewItem) { + QuestionnaireTheme { EditTextSingleLineViewFactory.Content(questionnaireViewItem) } + } + + @Test + fun shouldSetQuestionnaireHeader() = runComposeUiTest { + setContent { + QuestionnaireEditTextSingleLineView( + questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "string-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.String), + text = FhirR4String(value = "Question?"), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "string-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ), + ) + } + + onNodeWithTag(QUESTION_HEADER_TAG).assertTextEquals("Question?") + } + + @Test + fun shouldSetInputText() = runComposeUiTest { + setContent { + QuestionnaireEditTextSingleLineView( + questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "string-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.String), + ), + QuestionnaireResponse.Item( + linkId = FhirR4String(value = "string-item"), + answer = + listOf( + QuestionnaireResponse.Item.Answer( + value = + QuestionnaireResponse.Item.Answer.Value.String( + value = FhirR4String(value = "Answer"), + ), + ), + ), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ), + ) + } + + onNodeWithTag(EDIT_TEXT_FIELD_TEST_TAG).assertTextEquals("Answer") + } + + @Test + fun shouldSetInputTextToEmpty() = runComposeUiTest { + var questionnaireViewItem by + mutableStateOf( + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "string-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.String), + ), + QuestionnaireResponse.Item( + linkId = FhirR4String(value = "string-item"), + answer = + listOf( + QuestionnaireResponse.Item.Answer( + value = + QuestionnaireResponse.Item.Answer.Value.String( + value = FhirR4String(value = "Answer"), + ), + ), + ), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ), + ) + + setContent { QuestionnaireEditTextSingleLineView(questionnaireViewItem) } + + onNodeWithTag(EDIT_TEXT_FIELD_TEST_TAG).assertTextEquals("Answer") + + questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "string-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.String), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "string-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + + onNodeWithTag(EDIT_TEXT_FIELD_TEST_TAG).assertTextEquals("") + } + + @Test + fun shouldSetQuestionnaireResponseItemAnswer() = runComposeUiTest { + var answers: List? = null + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "string-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.String), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "string-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, result, _ -> answers = result }, + ) + + setContent { QuestionnaireEditTextSingleLineView(questionnaireViewItem) } + onNodeWithTag(EDIT_TEXT_FIELD_TEST_TAG).performTextReplacement("Answer") + waitUntil { answers != null } + + answers!!.single().value!!.asString()!!.value.value!!.shouldBe("Answer") + } + + @Test + fun shouldSetQuestionnaireResponseItemAnswerToEmpty() = runComposeUiTest { + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "string-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.String), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "string-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + setContent { QuestionnaireEditTextSingleLineView(questionnaireViewItem) } + onNodeWithTag(EDIT_TEXT_FIELD_TEST_TAG).performTextReplacement("") + waitForIdle() + + questionnaireViewItem.answers.shouldBeEmpty() + } + + @Test + fun displayValidationResult_noError_shouldShowNoErrorMessage() = runComposeUiTest { + setContent { + QuestionnaireEditTextSingleLineView( + questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "string-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.String), + extension = + listOf( + Extension( + url = "http://hl7.org/fhir/StructureDefinition/minLength", + value = Extension.Value.Integer(value = Integer(value = 10)), + ), + ), + ), + QuestionnaireResponse.Item( + linkId = FhirR4String(value = "string-item"), + answer = + listOf( + QuestionnaireResponse.Item.Answer( + value = + QuestionnaireResponse.Item.Answer.Value.String( + value = FhirR4String(value = "hello there"), + ), + ), + ), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ), + ) + } + + onNodeWithContentDescription("Error").assertDoesNotExist() + } + + @Test + fun displayValidationResult_error_shouldShowErrorMessage() = runComposeUiTest { + setContent { + QuestionnaireEditTextSingleLineView( + questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "string-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.String), + extension = + listOf( + Extension( + url = "http://hl7.org/fhir/StructureDefinition/minLength", + value = Extension.Value.Integer(value = Integer(value = 10)), + ), + ), + ), + QuestionnaireResponse.Item( + linkId = FhirR4String(value = "string-item"), + answer = + listOf( + QuestionnaireResponse.Item.Answer( + value = + QuestionnaireResponse.Item.Answer.Value.String( + value = FhirR4String(value = "hello"), + ), + ), + ), + ), + validationResult = + Invalid( + listOf("The minimum number of characters that are permitted in the answer is: 10"), + ), + answersChangedCallback = { _, _, _, _ -> }, + ), + ) + } + + onNodeWithContentDescription("Error").assertIsDisplayed() + onNodeWithText("The minimum number of characters that are permitted in the answer is: 10") + .assertIsDisplayed() + } + + @Test + fun hidesErrorTextviewInTheHeader() = runComposeUiTest { + setContent { + QuestionnaireEditTextSingleLineView( + questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "string-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.String), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "string-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ), + ) + } + + onNodeWithTag(ERROR_TEXT_AT_HEADER_TEST_TAG).assertDoesNotExist() + } + + @Test + fun bindReadOnlyShouldDisableView() = runComposeUiTest { + setContent { + QuestionnaireEditTextSingleLineView( + questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "string-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.String), + readOnly = FhirR4Boolean(value = true), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "string-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ), + ) + } + + onNodeWithTag(EDIT_TEXT_FIELD_TEST_TAG).assertIsNotEnabled() + } + + @Test + fun showAsterisk() = runComposeUiTest { + setContent { + QuestionnaireEditTextSingleLineView( + questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "string-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.String), + text = FhirR4String(value = "Question?"), + required = FhirR4Boolean(value = true), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "string-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + questionViewTextConfiguration = QuestionTextConfiguration(showAsterisk = true), + ), + ) + } + + onNodeWithTag(QUESTION_HEADER_TAG).assertTextEquals("Question? *") + } + + @Test + fun hideAsterisk() = runComposeUiTest { + setContent { + QuestionnaireEditTextSingleLineView( + questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "string-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.String), + text = FhirR4String(value = "Question?"), + required = FhirR4Boolean(value = true), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "string-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + questionViewTextConfiguration = QuestionTextConfiguration(showAsterisk = false), + ), + ) + } + + onNodeWithTag(QUESTION_HEADER_TAG).assertTextEquals("Question?") + } + + @Test + fun showsRequiredText() = runComposeUiTest { + setContent { + QuestionnaireEditTextSingleLineView( + questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "string-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.String), + required = FhirR4Boolean(value = true), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "string-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + questionViewTextConfiguration = QuestionTextConfiguration(showRequiredText = true), + ), + ) + } + + onNodeWithText("Required", substring = true).assertIsDisplayed() + } + + @Test + fun hideRequiredText() = runComposeUiTest { + setContent { + QuestionnaireEditTextSingleLineView( + questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "string-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.String), + required = FhirR4Boolean(value = true), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "string-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + questionViewTextConfiguration = QuestionTextConfiguration(showRequiredText = false), + ), + ) + } + + onNodeWithText("Required").assertDoesNotExist() + } + + @Test + fun showOptionalText() = runComposeUiTest { + setContent { + QuestionnaireEditTextSingleLineView( + questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "string-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.String), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "string-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + questionViewTextConfiguration = QuestionTextConfiguration(showOptionalText = true), + ), + ) + } + + onNodeWithText("Optional", substring = true).assertIsDisplayed() + } + + @Test + fun hideOptionalText() = runComposeUiTest { + setContent { + QuestionnaireEditTextSingleLineView( + questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "string-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.String), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "string-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + questionViewTextConfiguration = QuestionTextConfiguration(showOptionalText = false), + ), + ) + } + + onNodeWithText("Optional").assertDoesNotExist() + } + + @Test + fun displaysCorrectTextOnQuestionnaireViewItemAnswerUpdate() = runComposeUiTest { + var questionnaireViewItem by + mutableStateOf( + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "string-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.String), + text = FhirR4String(value = "First Name"), + ), + QuestionnaireResponse.Item( + linkId = FhirR4String(value = "string-item"), + answer = + listOf( + QuestionnaireResponse.Item.Answer( + value = + QuestionnaireResponse.Item.Answer.Value.String( + value = FhirR4String(value = "Jane"), + ), + ), + ), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ), + ) + + setContent { QuestionnaireEditTextSingleLineView(questionnaireViewItem) } + onNodeWithTag(EDIT_TEXT_FIELD_TEST_TAG).assertTextEquals("Jane") + + questionnaireViewItem = + questionnaireViewItem.copy( + questionnaireResponseItem = + QuestionnaireResponse.Item( + linkId = FhirR4String(value = "string-item"), + answer = + listOf( + QuestionnaireResponse.Item.Answer( + value = + QuestionnaireResponse.Item.Answer.Value.String( + value = FhirR4String(value = "Janette"), + ), + ), + ), + ), + ) + + onNodeWithTag(EDIT_TEXT_FIELD_TEST_TAG).assertTextEquals("Janette") + } +} diff --git a/datacapture/src/commonTest/kotlin/dev/ohs/fhir/datacapture/views/factories/EditTextViewFactoryDelegateTest.kt b/datacapture/src/commonTest/kotlin/dev/ohs/fhir/datacapture/views/factories/EditTextViewFactoryDelegateTest.kt new file mode 100644 index 000000000..3603ebfdd --- /dev/null +++ b/datacapture/src/commonTest/kotlin/dev/ohs/fhir/datacapture/views/factories/EditTextViewFactoryDelegateTest.kt @@ -0,0 +1,139 @@ +/* + * Copyright 2025-2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.ohs.fhir.datacapture.views.factories + +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.requestFocus +import androidx.compose.ui.test.runComposeUiTest +import com.google.fhir.model.r4.Decimal +import com.google.fhir.model.r4.Enumeration +import com.google.fhir.model.r4.Questionnaire +import com.google.fhir.model.r4.QuestionnaireResponse +import com.ionspin.kotlin.bignum.decimal.toBigDecimal +import dev.ohs.fhir.datacapture.extensions.FhirR4String +import dev.ohs.fhir.datacapture.generated.resources.Res +import dev.ohs.fhir.datacapture.generated.resources.decimal_format_validation_error_msg +import dev.ohs.fhir.datacapture.theme.QuestionnaireTheme +import dev.ohs.fhir.datacapture.validation.NotValidated +import dev.ohs.fhir.datacapture.views.QuestionnaireViewItem +import dev.ohs.fhir.datacapture.views.components.EDIT_TEXT_FIELD_TEST_TAG +import kotlin.test.Test + +@OptIn(ExperimentalTestApi::class) +class EditTextViewFactoryDelegateTest { + + private var programmaticUpdateCounter = 0 + + val editTextFactory: EditTextViewFactoryDelegate + get() { + return EditTextViewFactoryDelegate( + keyboardOptions = KeyboardOptions.Default, + uiInputText = { + programmaticUpdateCounter += 1 + "$programmaticUpdateCounter" + }, + validationMessageStringRes = Res.string.decimal_format_validation_error_msg, + handleInput = { _, _ -> }, + ) + } + + @Test + fun bindingWhenViewIsInFocusDoesNotProgrammaticallyUpdateEditTextButUpdatesValidationUi() = + runComposeUiTest { + var questionnaireViewItem by + mutableStateOf( + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "edit-text-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Decimal), + ), + QuestionnaireResponse.Item( + linkId = FhirR4String(value = "edit-text-item"), + answer = + listOf( + QuestionnaireResponse.Item.Answer( + value = + QuestionnaireResponse.Item.Answer.Value.Decimal( + value = Decimal(value = 1.toBigDecimal()), + ), + ), + ), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ), + ) + + setContent { QuestionnaireTheme { editTextFactory.Content(questionnaireViewItem) } } + + questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "edit-text-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Decimal), + ), + QuestionnaireResponse.Item( + linkId = FhirR4String(value = "edit-text-item"), + answer = + listOf( + QuestionnaireResponse.Item.Answer( + value = + QuestionnaireResponse.Item.Answer.Value.Decimal( + value = Decimal(value = 1.1.toBigDecimal()), + ), + ), + ), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + + onNodeWithTag(EDIT_TEXT_FIELD_TEST_TAG) + .assertTextEquals("2") // Value of [programmaticUpdateCounter] in the [testViewHolder] + onNodeWithTag(EDIT_TEXT_FIELD_TEST_TAG).requestFocus() + + questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "edit-text-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Decimal), + ), + QuestionnaireResponse.Item( + linkId = FhirR4String(value = "edit-text-item"), + answer = emptyList(), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + draftAnswer = "1.1.", + ) + + onNodeWithTag(EDIT_TEXT_FIELD_TEST_TAG, useUnmergedTree = true) + .assertTextEquals("2") // Since the view is in focus the value will not be updated + + onNodeWithContentDescription("Error").assertIsDisplayed() + onNodeWithText("Invalid number").assertIsDisplayed() + } +} diff --git a/datacapture/src/commonTest/kotlin/dev/ohs/fhir/datacapture/views/factories/GroupViewFactoryTest.kt b/datacapture/src/commonTest/kotlin/dev/ohs/fhir/datacapture/views/factories/GroupViewFactoryTest.kt new file mode 100644 index 000000000..226347b6e --- /dev/null +++ b/datacapture/src/commonTest/kotlin/dev/ohs/fhir/datacapture/views/factories/GroupViewFactoryTest.kt @@ -0,0 +1,194 @@ +/* + * Copyright 2025-2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.ohs.fhir.datacapture.views.factories + +import androidx.compose.runtime.Composable +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.runComposeUiTest +import com.google.fhir.model.r4.Coding +import com.google.fhir.model.r4.Enumeration +import com.google.fhir.model.r4.Questionnaire +import com.google.fhir.model.r4.QuestionnaireResponse +import dev.ohs.fhir.datacapture.extensions.FhirR4Boolean +import dev.ohs.fhir.datacapture.extensions.FhirR4String +import dev.ohs.fhir.datacapture.theme.QuestionnaireTheme +import dev.ohs.fhir.datacapture.validation.Invalid +import dev.ohs.fhir.datacapture.validation.NotValidated +import dev.ohs.fhir.datacapture.views.QuestionnaireViewItem +import dev.ohs.fhir.datacapture.views.components.ERROR_TEXT_TAG +import dev.ohs.fhir.datacapture.views.components.HEADER_TAG +import dev.ohs.fhir.datacapture.views.components.HINT_HEADER_TAG +import dev.ohs.fhir.datacapture.views.components.QUESTION_HEADER_TAG +import kotlin.test.Test + +@OptIn(ExperimentalTestApi::class) +class GroupViewFactoryTest { + + @Composable + fun QuestionnaireGroupView(questionnaireViewItem: QuestionnaireViewItem) { + QuestionnaireTheme { GroupViewFactory.Content(questionnaireViewItem) } + } + + @Test + fun shouldSetQuestionHeader() = runComposeUiTest { + setContent { + QuestionnaireGroupView( + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "group-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Group), + text = FhirR4String(value = "Group header"), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "group-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ), + ) + } + + onNodeWithTag(QUESTION_HEADER_TAG).assertTextEquals("Group header") + } + + @Test + fun displayValidationResult_error_shouldShowErrorMessage() = runComposeUiTest { + setContent { + QuestionnaireGroupView( + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "group-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Group), + required = FhirR4Boolean(value = true), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "group-item")), + validationResult = Invalid(listOf("Missing answer for required field.")), + answersChangedCallback = { _, _, _, _ -> }, + ), + ) + } + + onNodeWithTag(ERROR_TEXT_TAG).assertTextEquals("Missing answer for required field.") + } + + @Test + fun displayValidationResult_noError_shouldShowNoErrorMessage() = runComposeUiTest { + setContent { + QuestionnaireGroupView( + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "group-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Group), + required = FhirR4Boolean(value = true), + answerOption = + listOf( + Questionnaire.Item.AnswerOption( + value = + Questionnaire.Item.AnswerOption.Value.Coding( + value = Coding(display = FhirR4String(value = "display")), + ), + ), + ), + ), + QuestionnaireResponse.Item( + linkId = FhirR4String(value = "group-item"), + answer = + listOf( + QuestionnaireResponse.Item.Answer( + value = + QuestionnaireResponse.Item.Answer.Value.Coding( + value = Coding(display = FhirR4String(value = "display")), + ), + ), + ), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ), + ) + } + + onNodeWithTag(ERROR_TEXT_TAG).assertDoesNotExist() + } + + @Test + fun hintText_nestedDisplayItem_shouldNotShowHintText() = runComposeUiTest { + setContent { + QuestionnaireGroupView( + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "group-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Group), + item = + listOf( + Questionnaire.Item( + linkId = FhirR4String(value = "nested-display-question"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Display), + text = FhirR4String(value = "text"), + ), + ), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "group-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ), + ) + } + + onNodeWithTag(HINT_HEADER_TAG).assertDoesNotExist() + } + + @Test + fun shouldHaveHeaderViewVisible() = runComposeUiTest { + setContent { + QuestionnaireGroupView( + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "group-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Group), + text = FhirR4String(value = "Group header"), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "group-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ), + ) + } + + onNodeWithTag(HEADER_TAG).assertIsDisplayed() + } + + @Test + fun shouldSetHeaderViewVisibilityAsGone() = runComposeUiTest { + setContent { + QuestionnaireGroupView( + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "group-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Group), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "group-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ), + ) + } + + onNodeWithTag(HEADER_TAG).assertDoesNotExist() + } +} diff --git a/datacapture/src/commonTest/kotlin/dev/ohs/fhir/datacapture/views/factories/QuantityViewFactoryTest.kt b/datacapture/src/commonTest/kotlin/dev/ohs/fhir/datacapture/views/factories/QuantityViewFactoryTest.kt new file mode 100644 index 000000000..3d48ea783 --- /dev/null +++ b/datacapture/src/commonTest/kotlin/dev/ohs/fhir/datacapture/views/factories/QuantityViewFactoryTest.kt @@ -0,0 +1,754 @@ +/* + * Copyright 2025-2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.ohs.fhir.datacapture.views.factories + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.hasAnyAncestor +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.isPopup +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextInput +import androidx.compose.ui.test.runComposeUiTest +import com.google.fhir.model.r4.Code +import com.google.fhir.model.r4.Coding +import com.google.fhir.model.r4.Decimal +import com.google.fhir.model.r4.Enumeration +import com.google.fhir.model.r4.Extension +import com.google.fhir.model.r4.Questionnaire +import com.google.fhir.model.r4.QuestionnaireResponse +import com.google.fhir.model.r4.Uri +import com.ionspin.kotlin.bignum.decimal.BigDecimal +import com.ionspin.kotlin.bignum.decimal.toBigDecimal +import dev.ohs.fhir.datacapture.extensions.FhirR4Boolean +import dev.ohs.fhir.datacapture.extensions.FhirR4String +import dev.ohs.fhir.datacapture.theme.QuestionnaireTheme +import dev.ohs.fhir.datacapture.validation.Invalid +import dev.ohs.fhir.datacapture.validation.NotValidated +import dev.ohs.fhir.datacapture.validation.Valid +import dev.ohs.fhir.datacapture.views.QuestionTextConfiguration +import dev.ohs.fhir.datacapture.views.QuestionnaireViewItem +import dev.ohs.fhir.datacapture.views.components.DROP_DOWN_TEXT_FIELD_TAG +import dev.ohs.fhir.datacapture.views.components.EDIT_TEXT_FIELD_TEST_TAG +import dev.ohs.fhir.datacapture.views.components.ERROR_TEXT_AT_HEADER_TEST_TAG +import dev.ohs.fhir.datacapture.views.components.QUESTION_HEADER_TAG +import io.kotest.matchers.nulls.shouldBeNull +import io.kotest.matchers.shouldBe +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +fun quantity( + value: BigDecimal? = null, + unit: String? = null, + code: String? = null, + system: String? = null, +) = + com.google.fhir.model.r4.Quantity( + value = Decimal(value = value), + unit = FhirR4String(value = unit), + code = Code(value = code), + system = Uri(value = system), + ) + +@OptIn(ExperimentalTestApi::class) +class QuantityViewFactoryTest { + @Composable + fun QuestionnaireQuantityView(questionnaireViewItem: QuestionnaireViewItem) { + QuestionnaireTheme { QuantityViewFactory.Content(questionnaireViewItem) } + } + + @Test + fun shouldSetQuestionText() = runComposeUiTest { + val questionnaireViewItem by + mutableStateOf( + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "quantity-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Quantity), + text = FhirR4String(value = "Question?"), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "quantity-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ), + ) + + setContent { QuestionnaireQuantityView(questionnaireViewItem) } + + onNodeWithTag(QUESTION_HEADER_TAG).assertTextEquals("Question?") + } + + @Test + fun shouldSetInputDecimalValue() = runComposeUiTest { + val questionnaireViewItem by + mutableStateOf( + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "quantity-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Quantity), + ), + QuestionnaireResponse.Item( + linkId = FhirR4String(value = "quantity-item"), + answer = + listOf( + QuestionnaireResponse.Item.Answer( + value = + QuestionnaireResponse.Item.Answer.Value.Quantity( + value = + _root_ide_package_.dev.ohs.fhir.datacapture.views.factories.quantity( + value = "5".toBigDecimal(), + ), + ), + ), + ), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ), + ) + + setContent { QuestionnaireQuantityView(questionnaireViewItem) } + + onNodeWithTag(EDIT_TEXT_FIELD_TEST_TAG).assertTextEquals("5") + } + + @Test + fun shouldClearInputDecimalValue() = runComposeUiTest { + var questionnaireViewItem by + mutableStateOf( + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "quantity-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Quantity), + ), + QuestionnaireResponse.Item( + linkId = FhirR4String(value = "quantity-item"), + answer = + listOf( + QuestionnaireResponse.Item.Answer( + value = + QuestionnaireResponse.Item.Answer.Value.Quantity( + value = + _root_ide_package_.dev.ohs.fhir.datacapture.views.factories.quantity( + value = "5".toBigDecimal(), + ), + ), + ), + ), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ), + ) + + setContent { QuestionnaireQuantityView(questionnaireViewItem) } + + onNodeWithTag(EDIT_TEXT_FIELD_TEST_TAG).assertTextEquals("5") + + questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "quantity-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Quantity), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "quantity-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + + onNodeWithTag(EDIT_TEXT_FIELD_TEST_TAG).assertTextEquals("") + } + + @Test + fun shouldSetUnitValue() = runComposeUiTest { + val questionnaireViewItem by + mutableStateOf( + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "quantity-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Quantity), + ), + QuestionnaireResponse.Item( + linkId = FhirR4String(value = "quantity-item"), + answer = + listOf( + QuestionnaireResponse.Item.Answer( + value = + QuestionnaireResponse.Item.Answer.Value.Quantity( + value = + _root_ide_package_.dev.ohs.fhir.datacapture.views.factories.quantity( + unit = "kg", + ), + ), + ), + ), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ), + ) + + setContent { QuestionnaireQuantityView(questionnaireViewItem) } + + onNodeWithTag(DROP_DOWN_TEXT_FIELD_TAG).assertTextEquals("kg") + } + + @Test + fun shouldSetUnitValueFromInitialWhenAnswerIsMissing() = runComposeUiTest { + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "quantity-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Quantity), + initial = + listOf( + Questionnaire.Item.Initial( + value = + Questionnaire.Item.Initial.Value.Quantity( + value = + _root_ide_package_.dev.ohs.fhir.datacapture.views.factories.quantity( + unit = "kg", + code = "kilo", + ), + ), + ), + ), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "quantity-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + + setContent { QuestionnaireQuantityView(questionnaireViewItem) } + + onNodeWithTag(DROP_DOWN_TEXT_FIELD_TAG).assertTextEquals("kg") + } + + @Test + fun shouldClearUnitValue() = runComposeUiTest { + var questionnaireViewItem by + mutableStateOf( + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "quantity-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Quantity), + ), + QuestionnaireResponse.Item( + linkId = FhirR4String(value = "quantity-item"), + answer = + listOf( + QuestionnaireResponse.Item.Answer( + value = + QuestionnaireResponse.Item.Answer.Value.Quantity( + value = + _root_ide_package_.dev.ohs.fhir.datacapture.views.factories.quantity( + unit = "kg", + ), + ), + ), + ), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ), + ) + setContent { QuestionnaireQuantityView(questionnaireViewItem) } + + onNodeWithTag(DROP_DOWN_TEXT_FIELD_TAG).assertTextEquals("kg") + + questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "quantity-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Quantity), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "quantity-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + + onNodeWithTag(DROP_DOWN_TEXT_FIELD_TAG).assertTextEquals("") + } + + @Test + fun shouldDisplayErrorMessageInValidationResult() = runComposeUiTest { + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "quantity-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Quantity), + required = FhirR4Boolean(value = true), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "quantity-item")), + validationResult = Invalid(listOf("Missing answer for required field.")), + answersChangedCallback = { _, _, _, _ -> }, + ) + + setContent { QuestionnaireQuantityView(questionnaireViewItem) } + + onNodeWithContentDescription("Error").assertIsDisplayed() + onNodeWithText("Missing answer for required field.").assertIsDisplayed() + } + + @Test + fun shouldDisplayNoErrorMessageWhenValidationResultIsValid() = runComposeUiTest { + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "quantity-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Quantity), + required = FhirR4Boolean(value = true), + ), + QuestionnaireResponse.Item( + linkId = FhirR4String(value = "quantity-item"), + answer = + listOf( + QuestionnaireResponse.Item.Answer( + value = + QuestionnaireResponse.Item.Answer.Value.Quantity( + value = + _root_ide_package_.dev.ohs.fhir.datacapture.views.factories.quantity( + value = 22.5.toBigDecimal(), + ), + ), + ), + ), + ), + validationResult = Valid, + answersChangedCallback = { _, _, _, _ -> }, + ) + + setContent { QuestionnaireQuantityView(questionnaireViewItem) } + + onNodeWithContentDescription("Error").assertDoesNotExist() + } + + @Test + fun shouldDisableTextInputInReadOnlyMode() = runComposeUiTest { + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "quantity-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Quantity), + readOnly = FhirR4Boolean(value = true), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "quantity-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + + setContent { QuestionnaireQuantityView(questionnaireViewItem) } + + onNodeWithTag(EDIT_TEXT_FIELD_TEST_TAG).assertIsNotEnabled() + } + + @Test + fun shouldDisableUnitInputInReadOnlyMode() = runComposeUiTest { + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "quantity-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Quantity), + readOnly = FhirR4Boolean(value = true), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "quantity-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + + setContent { QuestionnaireQuantityView(questionnaireViewItem) } + + onNodeWithTag(DROP_DOWN_TEXT_FIELD_TAG).assertIsNotEnabled() + } + + @Test + fun shouldAlwaysHideErrorTextviewInTheHeader() = runComposeUiTest { + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "quantity-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Quantity), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "quantity-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + + setContent { QuestionnaireQuantityView(questionnaireViewItem) } + + onNodeWithTag(ERROR_TEXT_AT_HEADER_TEST_TAG).assertDoesNotExist() + } + + @Test + fun shouldSetDraftWithUnit() { + var answerHolder: List? = null + var draftHolder: Any? = null + + val questionnaireViewItem = createQuestionnaireViewItem { answers, draft -> + answerHolder = answers + draftHolder = draft + } + + runComposeUiTest { + setContent { QuestionnaireQuantityView(questionnaireViewItem) } + onNodeWithTag(DROP_DOWN_TEXT_FIELD_TAG).performClick() + onNode(hasText("centimeter") and hasAnyAncestor(isPopup())).assertIsDisplayed().performClick() + onNodeWithTag(DROP_DOWN_TEXT_FIELD_TAG).assertTextEquals("centimeter") + + waitUntil { draftHolder != null } + + with(draftHolder as Coding) { + assertEquals("http://unitofmeasure.com", system?.value) + assertEquals("cm", code?.value) + assertEquals("centimeter", display?.value) + } + assertTrue { answerHolder != null && answerHolder.isEmpty() } + } + } + + @Test + fun shouldSetDraftWithDecimalValue() { + var answerHolder: List? = null + var draftHolder: Any? = null + + val questionnaireViewItem = createQuestionnaireViewItem { answers, draft -> + answerHolder = answers + draftHolder = draft + } + + runComposeUiTest { + setContent { QuestionnaireQuantityView(questionnaireViewItem) } + + onNodeWithTag(EDIT_TEXT_FIELD_TEST_TAG).performClick().performTextInput("22") + waitUntil { draftHolder != null } + onNodeWithTag(EDIT_TEXT_FIELD_TEST_TAG).assertTextEquals("22") + + assertEquals(22.toBigDecimal(), draftHolder as BigDecimal) + assertTrue { answerHolder != null && answerHolder.isEmpty() } + } + } + + @Test + fun draftWithUnitShouldCompleteQuantity() { + var answerHolder: List? = null + var draftHolder: Any? = null + + val questionnaireViewItem = + createQuestionnaireViewItem( + Coding( + system = Uri(value = "http://unitofmeasure.com"), + code = Code(value = "cm"), + display = FhirR4String(value = "centimeter"), + ), + ) { answers, draft, + -> + answerHolder = answers + draftHolder = draft + } + + runComposeUiTest { + setContent { QuestionnaireQuantityView(questionnaireViewItem) } + onNodeWithTag(DROP_DOWN_TEXT_FIELD_TAG).performClick() + + onNodeWithTag(EDIT_TEXT_FIELD_TEST_TAG).performClick().performTextInput("22") + + waitUntil { !answerHolder.isNullOrEmpty() } + + onNodeWithTag(EDIT_TEXT_FIELD_TEST_TAG).assertTextEquals("22") + + with( + answerHolder!!.single().value?.asQuantity()?.value, + ) { + shouldBe( + _root_ide_package_.dev.ohs.fhir.datacapture.views.factories.quantity( + value = 22.0.toBigDecimal(), + unit = "centimeter", + code = "cm", + system = "http://unitofmeasure.com", + ), + ) + } + draftHolder.shouldBeNull() + } + } + + @Test + fun draftWithDecimalValueShouldCompleteQuantity() { + var answerHolder: List? = null + var draftHolder: Any? = null + + val questionnaireViewItem = + createQuestionnaireViewItem(draftAnswer = 22.toBigDecimal()) { answers, draft -> + answerHolder = answers + draftHolder = draft + } + + runComposeUiTest { + setContent { QuestionnaireQuantityView(questionnaireViewItem) } + onNodeWithTag(DROP_DOWN_TEXT_FIELD_TAG).performClick() + + onNode(hasText("centimeter") and hasAnyAncestor(isPopup())).assertIsDisplayed().performClick() + onNodeWithTag(DROP_DOWN_TEXT_FIELD_TAG).assertTextEquals("centimeter") + + waitUntil { !answerHolder.isNullOrEmpty() } + + with( + answerHolder!!.single().value?.asQuantity()?.value, + ) { + shouldBe( + _root_ide_package_.dev.ohs.fhir.datacapture.views.factories.quantity( + value = 22.0.toBigDecimal(), + unit = "centimeter", + code = "cm", + system = "http://unitofmeasure.com", + ), + ) + } + draftHolder.shouldBeNull() + } + } + + @Test + fun shouldShowAsterisk() = runComposeUiTest { + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "quantity-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Quantity), + text = FhirR4String(value = "Question?"), + required = FhirR4Boolean(value = true), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "quantity-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + questionViewTextConfiguration = QuestionTextConfiguration(showAsterisk = true), + ) + + setContent { QuestionnaireQuantityView(questionnaireViewItem) } + onNodeWithTag(QUESTION_HEADER_TAG).assertTextEquals("Question? *") + } + + @Test + fun shouldHideAsterisk() = runComposeUiTest { + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "quantity-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Quantity), + text = FhirR4String(value = "Question?"), + required = FhirR4Boolean(value = true), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "quantity-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + questionViewTextConfiguration = QuestionTextConfiguration(showAsterisk = false), + ) + + setContent { QuestionnaireQuantityView(questionnaireViewItem) } + + onNodeWithTag(QUESTION_HEADER_TAG).assertTextEquals("Question?") + } + + @Test + fun shouldShowRequiredText() = runComposeUiTest { + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "quantity-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Quantity), + required = FhirR4Boolean(value = true), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "quantity-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + questionViewTextConfiguration = QuestionTextConfiguration(showRequiredText = true), + ) + + setContent { QuestionnaireQuantityView(questionnaireViewItem) } + + onNodeWithText("Required", substring = true).assertIsDisplayed() + } + + @Test + fun shouldHideRequiredText() = runComposeUiTest { + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "quantity-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Quantity), + required = FhirR4Boolean(value = true), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "quantity-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + questionViewTextConfiguration = QuestionTextConfiguration(showRequiredText = false), + ) + setContent { QuestionnaireQuantityView(questionnaireViewItem) } + + onNodeWithText("Required").assertDoesNotExist() + } + + @Test + fun shouldShowOptionalText() = runComposeUiTest { + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "quantity-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Quantity), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "quantity-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + questionViewTextConfiguration = QuestionTextConfiguration(showOptionalText = true), + ) + setContent { QuestionnaireQuantityView(questionnaireViewItem) } + + onNodeWithText("Optional", substring = true).assertIsDisplayed() + } + + @Test + fun shouldHideOptionalText() = runComposeUiTest { + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "quantity-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Quantity), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "quantity-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + questionViewTextConfiguration = QuestionTextConfiguration(showOptionalText = false), + ) + setContent { QuestionnaireQuantityView(questionnaireViewItem) } + + onNodeWithText("Optional").assertDoesNotExist() + } + + @Test + fun bindAgainShouldUpdateUIInputText() = runComposeUiTest { + var questionnaireViewItem by + mutableStateOf( + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "quantity-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Quantity), + ), + QuestionnaireResponse.Item( + linkId = FhirR4String(value = "quantity-item"), + answer = + listOf( + QuestionnaireResponse.Item.Answer( + value = + QuestionnaireResponse.Item.Answer.Value.Quantity( + value = + _root_ide_package_.dev.ohs.fhir.datacapture.views.factories.quantity( + value = "5".toBigDecimal(), + ), + ), + ), + ), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ), + ) + + setContent { QuestionnaireQuantityView(questionnaireViewItem) } + + onNodeWithTag(EDIT_TEXT_FIELD_TEST_TAG, useUnmergedTree = true).assertTextEquals("5") + + questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "quantity-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Quantity), + ), + QuestionnaireResponse.Item( + linkId = FhirR4String(value = "quantity-item"), + answer = + listOf( + QuestionnaireResponse.Item.Answer( + value = + QuestionnaireResponse.Item.Answer.Value.Quantity( + value = + _root_ide_package_.dev.ohs.fhir.datacapture.views.factories.quantity( + value = "7".toBigDecimal(), + ), + ), + ), + ), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + + onNodeWithTag(EDIT_TEXT_FIELD_TEST_TAG, useUnmergedTree = true).assertTextEquals("7") + } + + private fun createQuestionnaireViewItem( + draftAnswer: Any? = null, + answersChangedCallback: (List, Any?) -> Unit, + ): QuestionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "quantity-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Quantity), + required = FhirR4Boolean(value = true), + extension = + listOf( + Extension( + url = "http://hl7.org/fhir/StructureDefinition/questionnaire-unitOption", + value = + Extension.Value.Coding( + value = + Coding( + code = Code(value = "cm"), + system = Uri(value = "http://unitofmeasure.com"), + display = FhirR4String(value = "centimeter"), + ), + ), + ), + Extension( + url = "http://hl7.org/fhir/StructureDefinition/questionnaire-unitOption", + value = + Extension.Value.Coding( + value = + Coding( + code = Code(value = "[in_i]"), + system = Uri(value = "http://unitofmeasure.com"), + display = FhirR4String(value = "inch"), + ), + ), + ), + ), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "quantity-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, answers, draft -> answersChangedCallback(answers, draft) }, + draftAnswer = draftAnswer, + ) +} diff --git a/datacapture/src/commonTest/kotlin/dev/ohs/fhir/datacapture/views/factories/RadioGroupViewFactoryTest.kt b/datacapture/src/commonTest/kotlin/dev/ohs/fhir/datacapture/views/factories/RadioGroupViewFactoryTest.kt new file mode 100644 index 000000000..b711bf7f5 --- /dev/null +++ b/datacapture/src/commonTest/kotlin/dev/ohs/fhir/datacapture/views/factories/RadioGroupViewFactoryTest.kt @@ -0,0 +1,1107 @@ +/* + * Copyright 2025-2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.ohs.fhir.datacapture.views.factories + +import androidx.compose.runtime.Composable +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertAll +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsNotSelected +import androidx.compose.ui.test.assertIsSelected +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.isNotEnabled +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.runComposeUiTest +import com.google.fhir.model.r4.Attachment +import com.google.fhir.model.r4.Base64Binary +import com.google.fhir.model.r4.Canonical +import com.google.fhir.model.r4.Code +import com.google.fhir.model.r4.CodeableConcept +import com.google.fhir.model.r4.Coding +import com.google.fhir.model.r4.Enumeration +import com.google.fhir.model.r4.Extension +import com.google.fhir.model.r4.Questionnaire +import com.google.fhir.model.r4.QuestionnaireResponse +import com.google.fhir.model.r4.Uri +import dev.ohs.fhir.datacapture.extensions.ChoiceOrientationTypes +import dev.ohs.fhir.datacapture.extensions.EXTENSION_CHOICE_ORIENTATION_URL +import dev.ohs.fhir.datacapture.extensions.EXTENSION_ITEM_ANSWER_MEDIA +import dev.ohs.fhir.datacapture.extensions.EXTENSION_ITEM_CONTROL_SYSTEM +import dev.ohs.fhir.datacapture.extensions.EXTENSION_ITEM_CONTROL_URL +import dev.ohs.fhir.datacapture.extensions.FhirR4Boolean +import dev.ohs.fhir.datacapture.extensions.FhirR4String +import dev.ohs.fhir.datacapture.theme.QuestionnaireTheme +import dev.ohs.fhir.datacapture.validation.Invalid +import dev.ohs.fhir.datacapture.validation.NotValidated +import dev.ohs.fhir.datacapture.views.QuestionTextConfiguration +import dev.ohs.fhir.datacapture.views.QuestionnaireViewItem +import dev.ohs.fhir.datacapture.views.components.CHOICE_RADIO_BUTTON_IMAGE_TAG +import dev.ohs.fhir.datacapture.views.components.ERROR_TEXT_AT_HEADER_TEST_TAG +import dev.ohs.fhir.datacapture.views.components.QUESTION_HEADER_TAG +import dev.ohs.fhir.datacapture.views.components.REQUIRED_OPTIONAL_HEADER_TEXT_TAG +import io.kotest.matchers.collections.shouldBeEmpty +import io.kotest.matchers.collections.shouldNotBeEmpty +import io.kotest.matchers.comparables.shouldBeLessThan +import io.kotest.matchers.shouldBe +import kotlin.io.encoding.ExperimentalEncodingApi +import kotlin.test.Test + +@OptIn(ExperimentalTestApi::class) +class RadioGroupViewFactoryTest { + + @Composable + fun QuestionnaireRadioGroup(questionnaireViewItem: QuestionnaireViewItem) { + QuestionnaireTheme { RadioGroupViewFactory.Content(questionnaireViewItem) } + } + + @OptIn(ExperimentalEncodingApi::class) + private val itemAnswerMediaExtension = + Extension( + url = EXTENSION_ITEM_ANSWER_MEDIA, + value = + Extension.Value.Attachment( + value = + Attachment( + data = + Base64Binary( + value = + "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", + ), + contentType = Code(value = "image/png"), + ), + ), + ) + + @Test + fun bind_shouldSetQuestionHeader() = runComposeUiTest { + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "radio-group-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Choice), + extension = + listOf( + Extension( + url = EXTENSION_ITEM_CONTROL_URL, + value = + Extension.Value.CodeableConcept( + value = + CodeableConcept( + coding = + listOf( + Coding( + system = Uri(value = EXTENSION_ITEM_CONTROL_SYSTEM), + code = Code(value = "radio-button"), + ), + ), + ), + ), + ), + ), + text = FhirR4String(value = "Question?"), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "radio-group-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + + setContent { QuestionnaireRadioGroup(questionnaireViewItem) } + + onNodeWithTag(QUESTION_HEADER_TAG).assertTextEquals("Question?") + } + + @Test + fun bind_vertical_shouldCreateRadioButtons() = runComposeUiTest { + val questionnaire = + Questionnaire.Item( + linkId = FhirR4String(value = "radio-group-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Choice), + extension = + listOf( + Extension( + url = EXTENSION_ITEM_CONTROL_URL, + value = + Extension.Value.CodeableConcept( + value = + CodeableConcept( + coding = + listOf( + Coding( + system = Uri(value = EXTENSION_ITEM_CONTROL_SYSTEM), + code = Code(value = "radio-button"), + ), + ), + ), + ), + ), + Extension( + url = EXTENSION_CHOICE_ORIENTATION_URL, + value = + Extension.Value.Code( + value = Code(value = ChoiceOrientationTypes.VERTICAL.extensionCode), + ), + ), + ), + answerOption = + listOf( + Questionnaire.Item.AnswerOption( + value = + Questionnaire.Item.AnswerOption.Value.Coding( + value = Coding(display = FhirR4String(value = "Coding 1")), + ), + ), + Questionnaire.Item.AnswerOption( + value = + Questionnaire.Item.AnswerOption.Value.Coding( + value = Coding(display = FhirR4String(value = "Coding 2")), + ), + ), + ), + ) + + setContent { + QuestionnaireRadioGroup( + QuestionnaireViewItem( + questionnaire, + QuestionnaireResponse.Item(linkId = FhirR4String(value = "radio-group-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ), + ) + } + + onAllNodes(hasTestTag(RADIO_OPTION_TAG)).assertCountEquals(2) + questionnaire.answerOption.forEachIndexed { index, opt -> + onAllNodes(hasTestTag(RADIO_OPTION_TAG))[index].assertTextEquals( + opt.value.asCoding()?.value?.display?.value!!, + ) + + val node = onAllNodes(hasTestTag(RADIO_OPTION_TAG))[index] + node.assertTextEquals(opt.value.asCoding()?.value?.display?.value!!) + + // Verify fills max width + val semanticsNode = node.fetchSemanticsNode() + val nodeWidth = semanticsNode.size.width + val parentWidth = + semanticsNode.layoutInfo.coordinates.parentLayoutCoordinates?.size?.width ?: 0 + nodeWidth.shouldBe(parentWidth) + } + } + + @Test + fun bind_horizontal_shouldCreateRadioButtons() = runComposeUiTest { + val questionnaire = + Questionnaire.Item( + linkId = FhirR4String(value = "radio-group-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Choice), + extension = + listOf( + Extension( + url = EXTENSION_ITEM_CONTROL_URL, + value = + Extension.Value.CodeableConcept( + value = + CodeableConcept( + coding = + listOf( + Coding( + system = Uri(value = EXTENSION_ITEM_CONTROL_SYSTEM), + code = Code(value = "radio-button"), + ), + ), + ), + ), + ), + Extension( + url = EXTENSION_CHOICE_ORIENTATION_URL, + value = + Extension.Value.Code( + value = Code(value = ChoiceOrientationTypes.HORIZONTAL.extensionCode), + ), + ), + ), + answerOption = + listOf( + Questionnaire.Item.AnswerOption( + value = + Questionnaire.Item.AnswerOption.Value.Coding( + value = Coding(display = FhirR4String(value = "Coding 1")), + ), + ), + Questionnaire.Item.AnswerOption( + value = + Questionnaire.Item.AnswerOption.Value.Coding( + value = Coding(display = FhirR4String(value = "Coding 2")), + ), + ), + ), + ) + + setContent { + QuestionnaireRadioGroup( + QuestionnaireViewItem( + questionnaire, + QuestionnaireResponse.Item(linkId = FhirR4String(value = "radio-group-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ), + ) + } + + onAllNodes(hasTestTag(RADIO_OPTION_TAG)).assertCountEquals(2) + questionnaire.answerOption.forEachIndexed { index, opt -> + onAllNodes(hasTestTag(RADIO_OPTION_TAG))[index].assertTextEquals( + opt.value.asCoding()?.value?.display?.value!!, + ) + + val node = onAllNodes(hasTestTag(RADIO_OPTION_TAG))[index] + node.assertTextEquals(opt.value.asCoding()?.value?.display?.value!!) + + // Verify it doesn't fill max width + val semanticsNode = node.fetchSemanticsNode() + val nodeWidth = semanticsNode.size.width + val parentWidth = + semanticsNode.layoutInfo.coordinates.parentLayoutCoordinates?.size?.width ?: 0 + nodeWidth.shouldBeLessThan(parentWidth) + } + } + + @Test + fun bind_noAnswer_shouldLeaveRadioButtonsUnchecked() = runComposeUiTest { + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "radio-group-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Choice), + extension = + listOf( + Extension( + url = EXTENSION_ITEM_CONTROL_URL, + value = + Extension.Value.CodeableConcept( + value = + CodeableConcept( + coding = + listOf( + Coding( + system = Uri(value = EXTENSION_ITEM_CONTROL_SYSTEM), + code = Code(value = "radio-button"), + ), + ), + ), + ), + ), + ), + answerOption = + listOf( + Questionnaire.Item.AnswerOption( + value = + Questionnaire.Item.AnswerOption.Value.Coding( + value = Coding(display = FhirR4String(value = "Coding 1")), + ), + ), + ), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "radio-group-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + + setContent { QuestionnaireRadioGroup(questionnaireViewItem) } + + onNode(hasTestTag(RADIO_OPTION_TAG) and hasText("Coding 1")).assertIsNotSelected() + } + + @Test + fun bind_withImageInItemAnswerMediaExtension_shouldShowImageAfterRadioButtons() = + runComposeUiTest { + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "radio-group-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Choice), + extension = + listOf( + Extension( + url = EXTENSION_ITEM_CONTROL_URL, + value = + Extension.Value.CodeableConcept( + value = + CodeableConcept( + coding = + listOf( + Coding( + system = Uri(value = EXTENSION_ITEM_CONTROL_SYSTEM), + code = Code(value = "radio-button"), + ), + ), + ), + ), + ), + ), + answerOption = + listOf( + Questionnaire.Item.AnswerOption( + extension = listOf(itemAnswerMediaExtension), + value = + Questionnaire.Item.AnswerOption.Value.String( + value = FhirR4String(value = "Test Code"), + ), + ), + ), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "radio-group-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + + setContent { QuestionnaireRadioGroup(questionnaireViewItem) } + onNodeWithTag(CHOICE_RADIO_BUTTON_IMAGE_TAG, useUnmergedTree = true).assertIsDisplayed() + } + + @Test + fun bind_answer_shouldCheckRadioButton() = runComposeUiTest { + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "radio-group-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Choice), + extension = + listOf( + Extension( + url = EXTENSION_ITEM_CONTROL_URL, + value = + Extension.Value.CodeableConcept( + value = + CodeableConcept( + coding = + listOf( + Coding( + system = Uri(value = EXTENSION_ITEM_CONTROL_SYSTEM), + code = Code(value = "radio-button"), + ), + ), + ), + ), + ), + ), + answerOption = + listOf( + Questionnaire.Item.AnswerOption( + value = + Questionnaire.Item.AnswerOption.Value.Coding( + value = Coding(display = FhirR4String(value = "Coding 1")), + ), + ), + Questionnaire.Item.AnswerOption( + value = + Questionnaire.Item.AnswerOption.Value.Coding( + value = Coding(display = FhirR4String(value = "Coding 2")), + ), + ), + ), + ), + QuestionnaireResponse.Item( + linkId = FhirR4String(value = "radio-group-item"), + answer = + listOf( + QuestionnaireResponse.Item.Answer( + value = + QuestionnaireResponse.Item.Answer.Value.Coding( + value = Coding(display = FhirR4String(value = "Coding 1")), + ), + ), + ), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, _v, _ -> }, + ) + + setContent { QuestionnaireRadioGroup(questionnaireViewItem) } + + onNode(hasTestTag(RADIO_OPTION_TAG) and hasText("Coding 1")).assertIsSelected() + + onNode(hasTestTag(RADIO_OPTION_TAG) and hasText("Coding 2")).assertIsNotSelected() + } + + @Test + fun click_shouldSetQuestionnaireResponseItemAnswer() = runComposeUiTest { + var answerHolder: List? = null + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "radio-group-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Choice), + extension = + listOf( + Extension( + url = EXTENSION_ITEM_CONTROL_URL, + value = + Extension.Value.CodeableConcept( + value = + CodeableConcept( + coding = + listOf( + Coding( + system = Uri(value = EXTENSION_ITEM_CONTROL_SYSTEM), + code = Code(value = "radio-button"), + ), + ), + ), + ), + ), + ), + answerOption = + listOf( + Questionnaire.Item.AnswerOption( + value = + Questionnaire.Item.AnswerOption.Value.Coding( + value = Coding(display = FhirR4String(value = "Coding 1")), + ), + ), + ), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "radio-group-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, answers, _ -> answerHolder = answers }, + ) + setContent { QuestionnaireRadioGroup(questionnaireViewItem) } + onNode(hasTestTag(RADIO_OPTION_TAG) and hasText("Coding 1")).performClick() + waitUntil { answerHolder != null } + + answerHolder!!.single().value?.asCoding()?.value?.display?.value.shouldBe("Coding 1") + } + + @Test + fun click_shouldCheckRadioButton() = runComposeUiTest { + val fakeAnswerValueSetResolver = { uri: String -> + if (uri == "http://coding-value-set-url") { + listOf( + Questionnaire.Item.AnswerOption( + value = + Questionnaire.Item.AnswerOption.Value.Coding( + value = Coding(display = FhirR4String(value = "Coding 1")), + ), + ), + Questionnaire.Item.AnswerOption( + value = + Questionnaire.Item.AnswerOption.Value.Coding( + value = Coding(display = FhirR4String(value = "Coding 2")), + ), + ), + ) + } else { + emptyList() + } + } + val questionnaireItem = + Questionnaire.Item( + linkId = FhirR4String(value = "radio-group-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Choice), + extension = + listOf( + Extension( + url = EXTENSION_ITEM_CONTROL_URL, + value = + Extension.Value.CodeableConcept( + value = + CodeableConcept( + coding = + listOf( + Coding( + system = Uri(value = EXTENSION_ITEM_CONTROL_SYSTEM), + code = Code(value = "radio-button"), + ), + ), + ), + ), + ), + ), + answerValueSet = Canonical(value = "http://coding-value-set-url"), + ) + + val questionnaireViewItem = + QuestionnaireViewItem( + questionnaireItem, + QuestionnaireResponse.Item(linkId = FhirR4String(value = "radio-group-item")), + enabledAnswerOptions = + fakeAnswerValueSetResolver.invoke(questionnaireItem.answerValueSet?.value!!), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + setContent { QuestionnaireRadioGroup(questionnaireViewItem) } + onNode(hasTestTag(RADIO_OPTION_TAG) and hasText("Coding 1")).performClick() + + onNode(hasTestTag(RADIO_OPTION_TAG) and hasText("Coding 1")).assertIsSelected() + onNode(hasTestTag(RADIO_OPTION_TAG) and hasText("Coding 2")).assertIsNotSelected() + } + + @Test + fun click_shouldCheckOtherRadioButton() = runComposeUiTest { + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "radio-group-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Choice), + extension = + listOf( + Extension( + url = EXTENSION_ITEM_CONTROL_URL, + value = + Extension.Value.CodeableConcept( + value = + CodeableConcept( + coding = + listOf( + Coding( + system = Uri(value = EXTENSION_ITEM_CONTROL_SYSTEM), + code = Code(value = "radio-button"), + ), + ), + ), + ), + ), + ), + answerOption = + listOf( + Questionnaire.Item.AnswerOption( + value = + Questionnaire.Item.AnswerOption.Value.Coding( + value = Coding(display = FhirR4String(value = "Coding 1")), + ), + ), + Questionnaire.Item.AnswerOption( + value = + Questionnaire.Item.AnswerOption.Value.Coding( + value = Coding(display = FhirR4String(value = "Coding 2")), + ), + ), + ), + ), + QuestionnaireResponse.Item( + linkId = FhirR4String(value = "radio-group-item"), + answer = + listOf( + QuestionnaireResponse.Item.Answer( + value = + QuestionnaireResponse.Item.Answer.Value.Coding( + value = Coding(display = FhirR4String(value = "Coding 1")), + ), + ), + ), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + + setContent { QuestionnaireRadioGroup(questionnaireViewItem) } + + onNode(hasTestTag(RADIO_OPTION_TAG) and hasText("Coding 1")).assertIsSelected() + + onNode(hasTestTag(RADIO_OPTION_TAG) and hasText("Coding 2")).performClick() + + onNode(hasTestTag(RADIO_OPTION_TAG) and hasText("Coding 1")).assertIsNotSelected() + onNode(hasTestTag(RADIO_OPTION_TAG) and hasText("Coding 2")).assertIsSelected() + } + + @Test + fun unselectRadioButtonIfSelectedRadioButtonIsClicked() = runComposeUiTest { + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "radio-group-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Choice), + extension = + listOf( + Extension( + url = EXTENSION_ITEM_CONTROL_URL, + value = + Extension.Value.CodeableConcept( + value = + CodeableConcept( + coding = + listOf( + Coding( + system = Uri(value = EXTENSION_ITEM_CONTROL_SYSTEM), + code = Code(value = "radio-button"), + ), + ), + ), + ), + ), + ), + answerOption = + listOf( + Questionnaire.Item.AnswerOption( + value = + Questionnaire.Item.AnswerOption.Value.Coding( + value = Coding(display = FhirR4String(value = "Coding 1")), + ), + ), + ), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "radio-group-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + + setContent { QuestionnaireRadioGroup(questionnaireViewItem) } + + onNode(hasTestTag(RADIO_OPTION_TAG) and hasText("Coding 1")).performClick() + onNode(hasTestTag(RADIO_OPTION_TAG) and hasText("Coding 1")).assertIsSelected() + + onNode(hasTestTag(RADIO_OPTION_TAG) and hasText("Coding 1")).performClick() + onNode(hasTestTag(RADIO_OPTION_TAG) and hasText("Coding 1")).assertIsNotSelected() + } + + @Test + fun clearTheAnswerIfSelectedRadioButtonIsClicked() = runComposeUiTest { + var answersHolder: List? = null + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "radio-group-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Choice), + extension = + listOf( + Extension( + url = EXTENSION_ITEM_CONTROL_URL, + value = + Extension.Value.CodeableConcept( + value = + CodeableConcept( + coding = + listOf( + Coding( + system = Uri(value = EXTENSION_ITEM_CONTROL_SYSTEM), + code = Code(value = "radio-button"), + ), + ), + ), + ), + ), + ), + answerOption = + listOf( + Questionnaire.Item.AnswerOption( + value = + Questionnaire.Item.AnswerOption.Value.Coding( + value = Coding(display = FhirR4String(value = "Coding 1")), + ), + ), + ), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "radio-group-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, answers, _ -> answersHolder = answers }, + ) + + setContent { QuestionnaireRadioGroup(questionnaireViewItem) } + onNode(hasTestTag(RADIO_OPTION_TAG) and hasText("Coding 1")).performClick() + waitUntil { answersHolder != null } + answersHolder.shouldNotBeEmpty() + + answersHolder = null + onNode(hasTestTag(RADIO_OPTION_TAG) and hasText("Coding 1")).performClick() + waitUntil { answersHolder != null } + + answersHolder.shouldBeEmpty() + } + + @Test + fun displayValidationResult_error_shouldShowErrorMessage() = runComposeUiTest { + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "radio-group-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Choice), + extension = + listOf( + Extension( + url = EXTENSION_ITEM_CONTROL_URL, + value = + Extension.Value.CodeableConcept( + value = + CodeableConcept( + coding = + listOf( + Coding( + system = Uri(value = EXTENSION_ITEM_CONTROL_SYSTEM), + code = Code(value = "radio-button"), + ), + ), + ), + ), + ), + ), + required = FhirR4Boolean(value = true), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "radio-group-item")), + validationResult = Invalid(listOf("Missing answer for required field.")), + answersChangedCallback = { _, _, _, _ -> }, + ) + + setContent { QuestionnaireRadioGroup(questionnaireViewItem) } + onNodeWithTag(ERROR_TEXT_AT_HEADER_TEST_TAG) + .assertTextEquals("Missing answer for required field.") + } + + @Test + fun displayValidationResult_noError_shouldShowNoErrorMessage() = runComposeUiTest { + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "radio-group-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Choice), + extension = + listOf( + Extension( + url = EXTENSION_ITEM_CONTROL_URL, + value = + Extension.Value.CodeableConcept( + value = + CodeableConcept( + coding = + listOf( + Coding( + system = Uri(value = EXTENSION_ITEM_CONTROL_SYSTEM), + code = Code(value = "radio-button"), + ), + ), + ), + ), + ), + ), + required = FhirR4Boolean(value = true), + answerOption = + listOf( + Questionnaire.Item.AnswerOption( + value = + Questionnaire.Item.AnswerOption.Value.Coding( + value = Coding(display = FhirR4String(value = "display")), + ), + ), + ), + ), + QuestionnaireResponse.Item( + linkId = FhirR4String(value = "radio-group-item"), + answer = + listOf( + QuestionnaireResponse.Item.Answer( + value = + QuestionnaireResponse.Item.Answer.Value.Coding( + value = Coding(display = FhirR4String(value = "display")), + ), + ), + ), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + + setContent { QuestionnaireRadioGroup(questionnaireViewItem) } + + onNodeWithTag(ERROR_TEXT_AT_HEADER_TEST_TAG).assertDoesNotExist() + } + + @Test + fun bind_readOnly_shouldDisableView() = runComposeUiTest { + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "radio-group-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Choice), + extension = + listOf( + Extension( + url = EXTENSION_ITEM_CONTROL_URL, + value = + Extension.Value.CodeableConcept( + value = + CodeableConcept( + coding = + listOf( + Coding( + system = Uri(value = EXTENSION_ITEM_CONTROL_SYSTEM), + code = Code(value = "radio-button"), + ), + ), + ), + ), + ), + ), + answerOption = + listOf( + Questionnaire.Item.AnswerOption( + value = + Questionnaire.Item.AnswerOption.Value.Coding( + value = Coding(display = FhirR4String(value = "Coding 1")), + ), + ), + ), + readOnly = FhirR4Boolean(value = true), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "radio-group-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + + setContent { QuestionnaireRadioGroup(questionnaireViewItem) } + onAllNodes(hasTestTag(RADIO_OPTION_TAG)).assertAll(isNotEnabled()) + } + + @Test + fun showAsterisk() = runComposeUiTest { + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "radio-group-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Choice), + extension = + listOf( + Extension( + url = EXTENSION_ITEM_CONTROL_URL, + value = + Extension.Value.CodeableConcept( + value = + CodeableConcept( + coding = + listOf( + Coding( + system = Uri(value = EXTENSION_ITEM_CONTROL_SYSTEM), + code = Code(value = "radio-button"), + ), + ), + ), + ), + ), + ), + text = FhirR4String(value = "Question"), + required = FhirR4Boolean(value = true), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "radio-group-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + questionViewTextConfiguration = QuestionTextConfiguration(showAsterisk = true), + ) + + setContent { QuestionnaireRadioGroup(questionnaireViewItem) } + + onNodeWithTag(QUESTION_HEADER_TAG).assertTextEquals("Question *") + } + + @Test + fun hideAsterisk() = runComposeUiTest { + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "radio-group-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Choice), + extension = + listOf( + Extension( + url = EXTENSION_ITEM_CONTROL_URL, + value = + Extension.Value.CodeableConcept( + value = + CodeableConcept( + coding = + listOf( + Coding( + system = Uri(value = EXTENSION_ITEM_CONTROL_SYSTEM), + code = Code(value = "radio-button"), + ), + ), + ), + ), + ), + ), + text = FhirR4String(value = "Question"), + required = FhirR4Boolean(value = true), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "radio-group-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + questionViewTextConfiguration = QuestionTextConfiguration(showAsterisk = false), + ) + + setContent { QuestionnaireRadioGroup(questionnaireViewItem) } + onNodeWithTag(QUESTION_HEADER_TAG).assertTextEquals("Question") + } + + @Test + fun showRequiredText() = runComposeUiTest { + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "radio-group-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Choice), + extension = + listOf( + Extension( + url = EXTENSION_ITEM_CONTROL_URL, + value = + Extension.Value.CodeableConcept( + value = + CodeableConcept( + coding = + listOf( + Coding( + system = Uri(value = EXTENSION_ITEM_CONTROL_SYSTEM), + code = Code(value = "radio-button"), + ), + ), + ), + ), + ), + ), + required = FhirR4Boolean(value = true), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "radio-group-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + questionViewTextConfiguration = QuestionTextConfiguration(showRequiredText = true), + ) + setContent { QuestionnaireRadioGroup(questionnaireViewItem) } + + onNodeWithTag(REQUIRED_OPTIONAL_HEADER_TEXT_TAG).assertTextEquals("Required") + } + + @Test + fun hideRequiredText() = runComposeUiTest { + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "radio-group-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Choice), + extension = + listOf( + Extension( + url = EXTENSION_ITEM_CONTROL_URL, + value = + Extension.Value.CodeableConcept( + value = + CodeableConcept( + coding = + listOf( + Coding( + system = Uri(value = EXTENSION_ITEM_CONTROL_SYSTEM), + code = Code(value = "radio-button"), + ), + ), + ), + ), + ), + ), + required = FhirR4Boolean(value = true), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "radio-group-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + questionViewTextConfiguration = QuestionTextConfiguration(showRequiredText = false), + ) + + setContent { QuestionnaireRadioGroup(questionnaireViewItem) } + + onNodeWithTag(REQUIRED_OPTIONAL_HEADER_TEXT_TAG).assertDoesNotExist() + onNodeWithText("Required").assertDoesNotExist() + } + + @Test + fun showOptionalText() = runComposeUiTest { + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "radio-group-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Choice), + extension = + listOf( + Extension( + url = EXTENSION_ITEM_CONTROL_URL, + value = + Extension.Value.CodeableConcept( + value = + CodeableConcept( + coding = + listOf( + Coding( + system = Uri(value = EXTENSION_ITEM_CONTROL_SYSTEM), + code = Code(value = "radio-button"), + ), + ), + ), + ), + ), + ), + text = FhirR4String(value = "Question"), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "radio-group-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + questionViewTextConfiguration = QuestionTextConfiguration(showOptionalText = true), + ) + + setContent { QuestionnaireRadioGroup(questionnaireViewItem) } + + onNodeWithTag(REQUIRED_OPTIONAL_HEADER_TEXT_TAG).assertTextEquals("Optional") + } + + @Test + fun hideOptionalText() = runComposeUiTest { + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "radio-group-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Choice), + extension = + listOf( + Extension( + url = EXTENSION_ITEM_CONTROL_URL, + value = + Extension.Value.CodeableConcept( + value = + CodeableConcept( + coding = + listOf( + Coding( + system = Uri(value = EXTENSION_ITEM_CONTROL_SYSTEM), + code = Code(value = "radio-button"), + ), + ), + ), + ), + ), + ), + text = FhirR4String(value = "Question"), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "radio-group-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + questionViewTextConfiguration = QuestionTextConfiguration(showOptionalText = false), + ) + + setContent { QuestionnaireRadioGroup(questionnaireViewItem) } + + onNodeWithTag(REQUIRED_OPTIONAL_HEADER_TEXT_TAG).assertDoesNotExist() + onNodeWithText("Optional").assertDoesNotExist() + } +} diff --git a/datacapture/src/commonTest/kotlin/dev/ohs/fhir/datacapture/views/factories/SliderViewFactoryTest.kt b/datacapture/src/commonTest/kotlin/dev/ohs/fhir/datacapture/views/factories/SliderViewFactoryTest.kt new file mode 100644 index 000000000..663d4c579 --- /dev/null +++ b/datacapture/src/commonTest/kotlin/dev/ohs/fhir/datacapture/views/factories/SliderViewFactoryTest.kt @@ -0,0 +1,1060 @@ +/* + * Copyright 2025-2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.ohs.fhir.datacapture.views.factories + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.semantics.ProgressBarRangeInfo +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.assertRangeInfoEquals +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performSemanticsAction +import androidx.compose.ui.test.runComposeUiTest +import com.google.fhir.model.r4.Code +import com.google.fhir.model.r4.CodeableConcept +import com.google.fhir.model.r4.Coding +import com.google.fhir.model.r4.Enumeration +import com.google.fhir.model.r4.Extension +import com.google.fhir.model.r4.Questionnaire +import com.google.fhir.model.r4.QuestionnaireResponse +import com.google.fhir.model.r4.Uri +import dev.ohs.fhir.datacapture.extensions.EXTENSION_ITEM_CONTROL_SYSTEM +import dev.ohs.fhir.datacapture.extensions.EXTENSION_ITEM_CONTROL_URL +import dev.ohs.fhir.datacapture.extensions.EXTENSION_SLIDER_STEP_VALUE_URL +import dev.ohs.fhir.datacapture.extensions.FhirR4Boolean +import dev.ohs.fhir.datacapture.extensions.FhirR4Integer +import dev.ohs.fhir.datacapture.extensions.FhirR4String +import dev.ohs.fhir.datacapture.theme.QuestionnaireTheme +import dev.ohs.fhir.datacapture.validation.Invalid +import dev.ohs.fhir.datacapture.validation.NotValidated +import dev.ohs.fhir.datacapture.views.QuestionTextConfiguration +import dev.ohs.fhir.datacapture.views.QuestionnaireViewItem +import dev.ohs.fhir.datacapture.views.components.ERROR_TEXT_AT_HEADER_TEST_TAG +import dev.ohs.fhir.datacapture.views.components.ERROR_TEXT_TAG +import dev.ohs.fhir.datacapture.views.components.QUESTION_HEADER_TAG +import dev.ohs.fhir.datacapture.views.components.SLIDER_TAG +import io.kotest.matchers.shouldBe +import kotlin.test.Test +import kotlin.test.assertFailsWith + +@OptIn(ExperimentalTestApi::class) +class SliderViewFactoryTest { + @Composable + fun QuestionnaireSliderView(questionnaireViewItem: QuestionnaireViewItem) { + QuestionnaireTheme { SliderViewFactory.Content(questionnaireViewItem) } + } + + @Test + fun shouldSetQuestionHeader() = runComposeUiTest { + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "slider-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Integer), + extension = + listOf( + Extension( + url = EXTENSION_ITEM_CONTROL_URL, + value = + Extension.Value.CodeableConcept( + value = + CodeableConcept( + coding = + listOf( + Coding( + system = Uri(value = EXTENSION_ITEM_CONTROL_SYSTEM), + code = Code(value = "slider"), + ), + ), + ), + ), + ), + ), + text = FhirR4String(value = "Question?"), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "slider-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + + setContent { QuestionnaireSliderView(questionnaireViewItem) } + + onNodeWithTag(QUESTION_HEADER_TAG).assertTextEquals("Question?") + } + + @Test + fun shouldSetSliderValue() = runComposeUiTest { + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "slider-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Integer), + extension = + listOf( + Extension( + url = EXTENSION_ITEM_CONTROL_URL, + value = + Extension.Value.CodeableConcept( + value = + CodeableConcept( + coding = + listOf( + Coding( + system = Uri(value = EXTENSION_ITEM_CONTROL_SYSTEM), + code = Code(value = "slider"), + ), + ), + ), + ), + ), + ), + ), + QuestionnaireResponse.Item( + linkId = FhirR4String(value = "slider-item"), + answer = + listOf( + QuestionnaireResponse.Item.Answer( + value = + QuestionnaireResponse.Item.Answer.Value.Integer( + value = FhirR4Integer(value = 10), + ), + ), + ), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + + setContent { QuestionnaireSliderView(questionnaireViewItem) } + + onNodeWithTag(SLIDER_TAG) + .assertRangeInfoEquals(ProgressBarRangeInfo(current = 10f, range = 0f..100f, steps = 99)) + } + + @Test + fun stepSizeShouldComeFromTheSliderStepValueExtension() = runComposeUiTest { + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "slider-step-value"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Integer), + extension = + listOf( + Extension( + url = EXTENSION_ITEM_CONTROL_URL, + value = + Extension.Value.CodeableConcept( + value = + CodeableConcept( + coding = + listOf( + Coding( + system = Uri(value = EXTENSION_ITEM_CONTROL_SYSTEM), + code = Code(value = "slider"), + ), + ), + ), + ), + ), + Extension( + url = EXTENSION_SLIDER_STEP_VALUE_URL, + value = Extension.Value.Integer(value = FhirR4Integer(value = 10)), + ), + ), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "slider-step-value")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + + setContent { QuestionnaireSliderView(questionnaireViewItem) } + + val sliderStepsFromStepSize10: Int = 100.div(10) - 1 + + onNodeWithTag(SLIDER_TAG) + .assertRangeInfoEquals( + ProgressBarRangeInfo(current = 0f, range = 0f..100f, steps = sliderStepsFromStepSize10), + ) + } + + @Test + fun stepSizeShouldBe1IfTheSliderStepValueExtensionIsNotPresent() = runComposeUiTest { + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "slider-step-value"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Integer), + extension = + listOf( + Extension( + url = EXTENSION_ITEM_CONTROL_URL, + value = + Extension.Value.CodeableConcept( + value = + CodeableConcept( + coding = + listOf( + Coding( + system = Uri(value = EXTENSION_ITEM_CONTROL_SYSTEM), + code = Code(value = "slider"), + ), + ), + ), + ), + ), + ), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "slider-step-value")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + + setContent { QuestionnaireSliderView(questionnaireViewItem) } + + val sliderStepsWithStepSize1: Int = 100 - 1 + + onNodeWithTag(SLIDER_TAG) + .assertRangeInfoEquals( + ProgressBarRangeInfo(current = 0f, range = 0f..100f, steps = sliderStepsWithStepSize1), + ) + } + + @Test + fun sliderValueToShouldComeFromTheMaxValueExtension() = runComposeUiTest { + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "slider-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Integer), + extension = + listOf( + Extension( + url = EXTENSION_ITEM_CONTROL_URL, + value = + Extension.Value.CodeableConcept( + value = + CodeableConcept( + coding = + listOf( + Coding( + system = Uri(value = EXTENSION_ITEM_CONTROL_SYSTEM), + code = Code(value = "slider"), + ), + ), + ), + ), + ), + Extension( + url = "http://hl7.org/fhir/StructureDefinition/maxValue", + value = Extension.Value.Integer(value = FhirR4Integer(value = 200)), + ), + ), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "slider-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + + setContent { QuestionnaireSliderView(questionnaireViewItem) } + + onNodeWithTag(SLIDER_TAG) + .assertRangeInfoEquals(ProgressBarRangeInfo(current = 0f, range = 0f..200f, steps = 199)) + } + + @Test + fun sliderValueToShouldBeSetToDefaultValueIfMaxValueExtensionIsNotPresent() = runComposeUiTest { + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "slider-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Integer), + extension = + listOf( + Extension( + url = EXTENSION_ITEM_CONTROL_URL, + value = + Extension.Value.CodeableConcept( + value = + CodeableConcept( + coding = + listOf( + Coding( + system = Uri(value = EXTENSION_ITEM_CONTROL_SYSTEM), + code = Code(value = "slider"), + ), + ), + ), + ), + ), + ), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "slider-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + + setContent { QuestionnaireSliderView(questionnaireViewItem) } + + onNodeWithTag(SLIDER_TAG) + .assertRangeInfoEquals(ProgressBarRangeInfo(current = 0f, range = 0f..100f, steps = 99)) + } + + @Test + fun sliderValueFromShouldComeFromTheMinValueExtension() = runComposeUiTest { + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "slider-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Integer), + extension = + listOf( + Extension( + url = EXTENSION_ITEM_CONTROL_URL, + value = + Extension.Value.CodeableConcept( + value = + CodeableConcept( + coding = + listOf( + Coding( + system = Uri(value = EXTENSION_ITEM_CONTROL_SYSTEM), + code = Code(value = "slider"), + ), + ), + ), + ), + ), + Extension( + url = "http://hl7.org/fhir/StructureDefinition/minValue", + value = Extension.Value.Integer(value = FhirR4Integer(value = 50)), + ), + ), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "slider-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + + setContent { QuestionnaireSliderView(questionnaireViewItem) } + + onNodeWithTag(SLIDER_TAG) + .assertRangeInfoEquals(ProgressBarRangeInfo(current = 50f, range = 50f..100f, steps = 49)) + } + + @Test + fun sliderValueFromShouldBeSetToDefaultValueIfMinValueExtensionIsNotPresent() = runComposeUiTest { + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "slider-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Integer), + extension = + listOf( + Extension( + url = EXTENSION_ITEM_CONTROL_URL, + value = + Extension.Value.CodeableConcept( + value = + CodeableConcept( + coding = + listOf( + Coding( + system = Uri(value = EXTENSION_ITEM_CONTROL_SYSTEM), + code = Code(value = "slider"), + ), + ), + ), + ), + ), + ), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "slider-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + + setContent { QuestionnaireSliderView(questionnaireViewItem) } + + onNodeWithTag(SLIDER_TAG) + .assertRangeInfoEquals(ProgressBarRangeInfo(current = 0f, range = 0f..100f, steps = 99)) + } + + @Test + fun throwsExceptionIfMinValueIsGreaterThanMaxvalue() = runComposeUiTest { + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "slider-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Integer), + extension = + listOf( + Extension( + url = EXTENSION_ITEM_CONTROL_URL, + value = + Extension.Value.CodeableConcept( + value = + CodeableConcept( + coding = + listOf( + Coding( + system = Uri(value = EXTENSION_ITEM_CONTROL_SYSTEM), + code = Code(value = "slider"), + ), + ), + ), + ), + ), + Extension( + url = "http://hl7.org/fhir/StructureDefinition/minValue", + value = Extension.Value.Integer(value = FhirR4Integer(value = 100)), + ), + Extension( + url = "http://hl7.org/fhir/StructureDefinition/maxValue", + value = Extension.Value.Integer(value = FhirR4Integer(value = 50)), + ), + ), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "slider-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + assertFailsWith { + setContent { QuestionnaireSliderView(questionnaireViewItem) } + } + } + + @Test + fun shouldSetQuestionnaireResponseSliderAnswer() = runComposeUiTest { + var answerHolder: List? = null + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "slider-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Integer), + extension = + listOf( + Extension( + url = EXTENSION_ITEM_CONTROL_URL, + value = + Extension.Value.CodeableConcept( + value = + CodeableConcept( + coding = + listOf( + Coding( + system = Uri(value = EXTENSION_ITEM_CONTROL_SYSTEM), + code = Code(value = "slider"), + ), + ), + ), + ), + ), + ), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "slider-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, answers, _ -> answerHolder = answers }, + ) + + setContent { QuestionnaireSliderView(questionnaireViewItem) } + + onNodeWithTag(SLIDER_TAG).performSemanticsAction(SemanticsActions.SetProgress) { + it.invoke(20f) + } + + waitUntil { answerHolder != null } + answerHolder!!.single().value?.asInteger()?.value?.value.shouldBe(20) + } + + @Test + fun shouldSetSliderValueToDefaultWhenQuestionnaireResponseHasMultipleAnswers() = + runComposeUiTest { + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "slider-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Integer), + extension = + listOf( + Extension( + url = EXTENSION_ITEM_CONTROL_URL, + value = + Extension.Value.CodeableConcept( + value = + CodeableConcept( + coding = + listOf( + Coding( + system = Uri(value = EXTENSION_ITEM_CONTROL_SYSTEM), + code = Code(value = "slider"), + ), + ), + ), + ), + ), + ), + ), + QuestionnaireResponse.Item( + linkId = FhirR4String(value = "slider-item"), + answer = + listOf( + QuestionnaireResponse.Item.Answer( + value = + QuestionnaireResponse.Item.Answer.Value.Integer( + value = FhirR4Integer(value = 10), + ), + ), + QuestionnaireResponse.Item.Answer( + value = + QuestionnaireResponse.Item.Answer.Value.Integer( + value = FhirR4Integer(value = 10), + ), + ), + ), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + + setContent { QuestionnaireSliderView(questionnaireViewItem) } + + onNodeWithTag(SLIDER_TAG) + .assertRangeInfoEquals(ProgressBarRangeInfo(0.0f, 0.0f..100f, steps = 99)) + } + + @Test + fun displayValidationResultNoErrorShouldShowNoErrorMessage() = runComposeUiTest { + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "slider-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Integer), + extension = + listOf( + Extension( + url = EXTENSION_ITEM_CONTROL_URL, + value = + Extension.Value.CodeableConcept( + value = + CodeableConcept( + coding = + listOf( + Coding( + system = Uri(value = EXTENSION_ITEM_CONTROL_SYSTEM), + code = Code(value = "slider"), + ), + ), + ), + ), + ), + Extension( + url = "http://hl7.org/fhir/StructureDefinition/minValue", + value = Extension.Value.Integer(value = FhirR4Integer(value = 50)), + ), + Extension( + url = "http://hl7.org/fhir/StructureDefinition/maxValue", + value = Extension.Value.Integer(value = FhirR4Integer(value = 100)), + ), + ), + ), + QuestionnaireResponse.Item( + linkId = FhirR4String(value = "slider-item"), + answer = + listOf( + QuestionnaireResponse.Item.Answer( + value = + QuestionnaireResponse.Item.Answer.Value.Integer( + value = FhirR4Integer(value = 75), + ), + ), + ), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + + setContent { QuestionnaireSliderView(questionnaireViewItem) } + + onNodeWithTag(ERROR_TEXT_TAG).assertDoesNotExist() + } + + @Test + fun displayValidationResultErrorShouldShowErrorMessage() = runComposeUiTest { + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "slider-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Integer), + extension = + listOf( + Extension( + url = EXTENSION_ITEM_CONTROL_URL, + value = + Extension.Value.CodeableConcept( + value = + CodeableConcept( + coding = + listOf( + Coding( + system = Uri(value = EXTENSION_ITEM_CONTROL_SYSTEM), + code = Code(value = "slider"), + ), + ), + ), + ), + ), + Extension( + url = "http://hl7.org/fhir/StructureDefinition/minValue", + value = Extension.Value.Integer(value = FhirR4Integer(value = 50)), + ), + Extension( + url = "http://hl7.org/fhir/StructureDefinition/maxValue", + value = Extension.Value.Integer(value = FhirR4Integer(value = 100)), + ), + ), + ), + QuestionnaireResponse.Item( + linkId = FhirR4String(value = "slider-item"), + answer = + listOf( + QuestionnaireResponse.Item.Answer( + value = + QuestionnaireResponse.Item.Answer.Value.Integer( + value = FhirR4Integer(value = 25), + ), + ), + ), + ), + validationResult = Invalid(listOf("Minimum value allowed is:50")), + answersChangedCallback = { _, _, _, _ -> }, + ) + + setContent { QuestionnaireSliderView(questionnaireViewItem) } + + onNodeWithTag(ERROR_TEXT_TAG).assertTextEquals("Minimum value allowed is:50") + } + + @Test + fun hidesErrorTextviewInTheHeader() = runComposeUiTest { + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "slider-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Integer), + extension = + listOf( + Extension( + url = EXTENSION_ITEM_CONTROL_URL, + value = + Extension.Value.CodeableConcept( + value = + CodeableConcept( + coding = + listOf( + Coding( + system = Uri(value = EXTENSION_ITEM_CONTROL_SYSTEM), + code = Code(value = "slider"), + ), + ), + ), + ), + ), + ), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "slider-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + + setContent { QuestionnaireSliderView(questionnaireViewItem) } + + onNodeWithTag(ERROR_TEXT_AT_HEADER_TEST_TAG).assertDoesNotExist() + } + + @Test + fun bindReadOnlyShouldDisableView() = runComposeUiTest { + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "slider-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Integer), + extension = + listOf( + Extension( + url = EXTENSION_ITEM_CONTROL_URL, + value = + Extension.Value.CodeableConcept( + value = + CodeableConcept( + coding = + listOf( + Coding( + system = Uri(value = EXTENSION_ITEM_CONTROL_SYSTEM), + code = Code(value = "slider"), + ), + ), + ), + ), + ), + ), + readOnly = FhirR4Boolean(value = true), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "slider-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + + setContent { QuestionnaireSliderView(questionnaireViewItem) } + + onNodeWithTag(SLIDER_TAG).assertIsNotEnabled() + } + + @Test + fun bindMultipleTimesWithDifferentQuestionnaireItemViewItemShouldShowProperSliderValue() = + runComposeUiTest { + var questionnaireViewItem by + mutableStateOf( + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "slider-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Integer), + extension = + listOf( + Extension( + url = EXTENSION_ITEM_CONTROL_URL, + value = + Extension.Value.CodeableConcept( + value = + CodeableConcept( + coding = + listOf( + Coding( + system = Uri(value = EXTENSION_ITEM_CONTROL_SYSTEM), + code = Code(value = "slider"), + ), + ), + ), + ), + ), + ), + ), + QuestionnaireResponse.Item( + linkId = FhirR4String(value = "slider-item"), + answer = + listOf( + QuestionnaireResponse.Item.Answer( + value = + QuestionnaireResponse.Item.Answer.Value.Integer( + value = FhirR4Integer(value = 10), + ), + ), + ), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ), + ) + + setContent { QuestionnaireSliderView(questionnaireViewItem) } + + onNodeWithTag(SLIDER_TAG) + .assertRangeInfoEquals(ProgressBarRangeInfo(10f, 0f..100f, steps = 99)) + + questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "slider-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Integer), + extension = + listOf( + Extension( + url = EXTENSION_ITEM_CONTROL_URL, + value = + Extension.Value.CodeableConcept( + value = + CodeableConcept( + coding = + listOf( + Coding( + system = Uri(value = EXTENSION_ITEM_CONTROL_SYSTEM), + code = Code(value = "slider"), + ), + ), + ), + ), + ), + ), + ), + QuestionnaireResponse.Item( + linkId = FhirR4String(value = "slider-item"), + answer = + listOf( + QuestionnaireResponse.Item.Answer( + value = + QuestionnaireResponse.Item.Answer.Value.Integer( + value = FhirR4Integer(value = 12), + ), + ), + ), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + + onNodeWithTag(SLIDER_TAG) + .assertRangeInfoEquals(ProgressBarRangeInfo(12f, 0f..100f, steps = 99)) + + questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "slider-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Integer), + extension = + listOf( + Extension( + url = EXTENSION_ITEM_CONTROL_URL, + value = + Extension.Value.CodeableConcept( + value = + CodeableConcept( + coding = + listOf( + Coding( + system = Uri(value = EXTENSION_ITEM_CONTROL_SYSTEM), + code = Code(value = "slider"), + ), + ), + ), + ), + ), + Extension( + url = "http://hl7.org/fhir/StructureDefinition/minValue", + value = Extension.Value.Integer(value = FhirR4Integer(value = 50)), + ), + ), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "slider-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + + onNodeWithTag(SLIDER_TAG) + .assertRangeInfoEquals(ProgressBarRangeInfo(50f, 50f..100f, steps = 49)) + } + + @Test + fun hidesAsterisk() = runComposeUiTest { + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "slider-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Integer), + extension = + listOf( + Extension( + url = EXTENSION_ITEM_CONTROL_URL, + value = + Extension.Value.CodeableConcept( + value = + CodeableConcept( + coding = + listOf( + Coding( + system = Uri(value = EXTENSION_ITEM_CONTROL_SYSTEM), + code = Code(value = "slider"), + ), + ), + ), + ), + ), + ), + text = FhirR4String(value = "Question"), + required = FhirR4Boolean(value = true), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "slider-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + questionViewTextConfiguration = QuestionTextConfiguration(showAsterisk = false), + ) + + setContent { QuestionnaireSliderView(questionnaireViewItem) } + + onNodeWithTag(QUESTION_HEADER_TAG).assertTextEquals("Question") + } + + @Test + fun showsRequiredText() = runComposeUiTest { + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "slider-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Integer), + extension = + listOf( + Extension( + url = EXTENSION_ITEM_CONTROL_URL, + value = + Extension.Value.CodeableConcept( + value = + CodeableConcept( + coding = + listOf( + Coding( + system = Uri(value = EXTENSION_ITEM_CONTROL_SYSTEM), + code = Code(value = "slider"), + ), + ), + ), + ), + ), + ), + required = FhirR4Boolean(value = true), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "slider-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + questionViewTextConfiguration = QuestionTextConfiguration(showRequiredText = true), + ) + setContent { QuestionnaireSliderView(questionnaireViewItem) } + + onNodeWithText("Required").assertIsDisplayed() + } + + @Test + fun hidesRequiredText() = runComposeUiTest { + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "slider-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Integer), + extension = + listOf( + Extension( + url = EXTENSION_ITEM_CONTROL_URL, + value = + Extension.Value.CodeableConcept( + value = + CodeableConcept( + coding = + listOf( + Coding( + system = Uri(value = EXTENSION_ITEM_CONTROL_SYSTEM), + code = Code(value = "slider"), + ), + ), + ), + ), + ), + ), + required = FhirR4Boolean(value = true), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "slider-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + questionViewTextConfiguration = QuestionTextConfiguration(showRequiredText = false), + ) + + setContent { QuestionnaireSliderView(questionnaireViewItem) } + + onNodeWithText("Required").assertDoesNotExist() + } + + @Test + fun showsOptionalText() = runComposeUiTest { + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "slider-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Integer), + extension = + listOf( + Extension( + url = EXTENSION_ITEM_CONTROL_URL, + value = + Extension.Value.CodeableConcept( + value = + CodeableConcept( + coding = + listOf( + Coding( + system = Uri(value = EXTENSION_ITEM_CONTROL_SYSTEM), + code = Code(value = "slider"), + ), + ), + ), + ), + ), + ), + text = FhirR4String(value = "Question"), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "slider-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + questionViewTextConfiguration = QuestionTextConfiguration(showOptionalText = true), + ) + + setContent { QuestionnaireSliderView(questionnaireViewItem) } + + onNodeWithText("Optional").assertIsDisplayed() + } + + @Test + fun hidesOptionalText() = runComposeUiTest { + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "slider-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Integer), + extension = + listOf( + Extension( + url = EXTENSION_ITEM_CONTROL_URL, + value = + Extension.Value.CodeableConcept( + value = + CodeableConcept( + coding = + listOf( + Coding( + system = Uri(value = EXTENSION_ITEM_CONTROL_SYSTEM), + code = Code(value = "slider"), + ), + ), + ), + ), + ), + ), + text = FhirR4String(value = "Question"), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "slider-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + questionViewTextConfiguration = QuestionTextConfiguration(showOptionalText = false), + ) + + setContent { QuestionnaireSliderView(questionnaireViewItem) } + + onNodeWithText("Optional").assertDoesNotExist() + } +} diff --git a/datacapture/src/desktopMain/kotlin/dev/ohs/fhir/datacapture/AttachmentMediaHandler.desktop.kt b/datacapture/src/desktopMain/kotlin/dev/ohs/fhir/datacapture/AttachmentMediaHandler.desktop.kt new file mode 100644 index 000000000..16ba5105d --- /dev/null +++ b/datacapture/src/desktopMain/kotlin/dev/ohs/fhir/datacapture/AttachmentMediaHandler.desktop.kt @@ -0,0 +1,81 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.ohs.fhir.datacapture + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import com.ionspin.kotlin.bignum.decimal.BigDecimal +import io.github.vinceglb.filekit.FileKit +import io.github.vinceglb.filekit.dialogs.FileKitType +import io.github.vinceglb.filekit.dialogs.openFilePicker +import io.github.vinceglb.filekit.mimeType +import io.github.vinceglb.filekit.name +import kotlin.coroutines.cancellation.CancellationException + +internal class JvmMediaHandler( + override val maxSupportedFileSizeBytes: BigDecimal, + override val supportedMimeTypes: Array, +) : MediaHandler { + override fun isCameraSupported(): Boolean = false + + override suspend fun capturePhoto(): MediaCaptureResult { + // Camera capture is not available on desktop through standard APIs + return MediaCaptureResult.Error("Error: Camera not available") + } + + override suspend fun selectFile(inputMimeTypes: Array): MediaCaptureResult { + val pickedFile = + FileKit.openFilePicker( + type = + FileKitType.File( + inputMimeTypes + .flatMap { getExtensionsForMimeType(it) } + .toSet() + .takeIf { it.isNotEmpty() }, + ), + ) + return pickedFile?.let { + val fileName = it.name + captureResult( + it.file.readBytes(), + mimeType = it.mimeType()?.toString() ?: "application/octet-stream", + titleName = fileName, + ) + } + ?: throw CancellationException() + } + + private fun getExtensionsForMimeType(mimeType: String): List { + return when (mimeType.lowercase()) { + "image/*" -> listOf("jpg", "jpeg", "png", "gif", "bmp") + "text/*" -> listOf("txt", "md") + "video/*" -> listOf("mp4", "avi", "mov", "wmv") + "audio/*" -> listOf("mp3", "wav", "flac", "aac") + else -> listOf(mimeType.substringAfter("/")) + } + } +} + +@Composable +internal actual fun rememberMediaHandler( + maxSupportedFileSizeBytes: BigDecimal, + supportedMimeTypes: Array, +): MediaHandler { + return remember(maxSupportedFileSizeBytes, supportedMimeTypes) { + JvmMediaHandler(maxSupportedFileSizeBytes, supportedMimeTypes) + } +} diff --git a/datacapture/src/desktopMain/kotlin/dev/ohs/fhir/datacapture/DataCapture.desktop.kt b/datacapture/src/desktopMain/kotlin/dev/ohs/fhir/datacapture/DataCapture.desktop.kt new file mode 100644 index 000000000..2cdbaf80e --- /dev/null +++ b/datacapture/src/desktopMain/kotlin/dev/ohs/fhir/datacapture/DataCapture.desktop.kt @@ -0,0 +1,23 @@ +/* + * Copyright 2025-2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.ohs.fhir.datacapture + +actual object DataCapture { + actual fun getConfiguration(): DataCaptureConfig { + return DataCaptureConfig() + } +} diff --git a/datacapture/src/desktopMain/kotlin/dev/ohs/fhir/datacapture/LocalDateTimeFormatter.desktop.kt b/datacapture/src/desktopMain/kotlin/dev/ohs/fhir/datacapture/LocalDateTimeFormatter.desktop.kt new file mode 100644 index 000000000..97a199dea --- /dev/null +++ b/datacapture/src/desktopMain/kotlin/dev/ohs/fhir/datacapture/LocalDateTimeFormatter.desktop.kt @@ -0,0 +1,76 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.ohs.fhir.datacapture + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import dev.ohs.fhir.datacapture.extensions.length +import java.text.ParseException +import java.time.chrono.IsoChronology +import java.time.format.DateTimeFormatter +import java.time.format.DateTimeFormatterBuilder +import java.time.format.FormatStyle +import java.util.Locale +import kotlinx.datetime.LocalDate +import kotlinx.datetime.LocalTime +import kotlinx.datetime.toJavaLocalDate +import kotlinx.datetime.toJavaLocalTime +import kotlinx.datetime.toKotlinLocalDate + +object JVMLocalDateTimeFormatter : LocalDateTimeFormatter { + override fun parseStringToLocalDate( + str: String, + pattern: String, + ): LocalDate { + val localDate = java.time.LocalDate.parse(str, DateTimeFormatter.ofPattern(pattern)) + + // Throw ParseException if year is less than 4 digits. + if (localDate.year.length() < 4) { + throw ParseException("Year has less than 4 digits.", str.indexOf('y')) + } + // date/localDate with year more than 4 digits + if (localDate.year.length() > 4) { + throw ParseException("Year has more than 4 digits.", str.indexOf('y')) + } + return localDate.toKotlinLocalDate() + } + + override fun format(localDate: LocalDate, pattern: String?): String = + if (pattern.isNullOrEmpty()) { + DateTimeFormatter.ofLocalizedDate(FormatStyle.SHORT).format(localDate.toJavaLocalDate()) + } else { + DateTimeFormatter.ofPattern(pattern).format(localDate.toJavaLocalDate()) + } + + override val localDateShortFormatPattern: String + get() = + DateTimeFormatterBuilder.getLocalizedDateTimePattern( + FormatStyle.SHORT, + null, + IsoChronology.INSTANCE, + Locale.getDefault(), + ) + + override fun localizedTimeString(time: LocalTime): String { + return DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT).format(time.toJavaLocalTime()) + } +} + +@Composable +actual fun getLocalDateTimeFormatter(): LocalDateTimeFormatter { + return remember { JVMLocalDateTimeFormatter } +} diff --git a/datacapture/src/desktopMain/kotlin/dev/ohs/fhir/datacapture/NumberFormatter.desktop.kt b/datacapture/src/desktopMain/kotlin/dev/ohs/fhir/datacapture/NumberFormatter.desktop.kt new file mode 100644 index 000000000..1dcf321cd --- /dev/null +++ b/datacapture/src/desktopMain/kotlin/dev/ohs/fhir/datacapture/NumberFormatter.desktop.kt @@ -0,0 +1,26 @@ +/* + * Copyright 2025-2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.ohs.fhir.datacapture + +import java.text.DecimalFormat +import java.util.Locale + +actual object NumberFormatter { + actual fun formatInteger(value: Int): String { + return DecimalFormat.getInstance(Locale.getDefault()).format(value) + } +} diff --git a/datacapture/src/desktopTest/kotlin/dev/ohs/fhir/datacapture/views/factories/DateTimeViewFactoryTest.kt b/datacapture/src/desktopTest/kotlin/dev/ohs/fhir/datacapture/views/factories/DateTimeViewFactoryTest.kt new file mode 100644 index 000000000..8dc23bc11 --- /dev/null +++ b/datacapture/src/desktopTest/kotlin/dev/ohs/fhir/datacapture/views/factories/DateTimeViewFactoryTest.kt @@ -0,0 +1,1214 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.ohs.fhir.datacapture.views.factories + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.test.assert +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.assertTextContains +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.filterToOne +import androidx.compose.ui.test.hasAnyChild +import androidx.compose.ui.test.hasContentDescription +import androidx.compose.ui.test.hasTextExactly +import androidx.compose.ui.test.isEditable +import androidx.compose.ui.test.onChildren +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextReplacement +import androidx.compose.ui.test.runComposeUiTest +import androidx.compose.ui.text.AnnotatedString +import com.google.fhir.model.r4.DateTime +import com.google.fhir.model.r4.Enumeration +import com.google.fhir.model.r4.Extension +import com.google.fhir.model.r4.FhirDateTime +import com.google.fhir.model.r4.Questionnaire +import com.google.fhir.model.r4.QuestionnaireResponse +import dev.ohs.fhir.datacapture.extensions.FhirR4Boolean +import dev.ohs.fhir.datacapture.extensions.FhirR4String +import dev.ohs.fhir.datacapture.theme.QuestionnaireTheme +import dev.ohs.fhir.datacapture.validation.Invalid +import dev.ohs.fhir.datacapture.validation.NotValidated +import dev.ohs.fhir.datacapture.views.QuestionTextConfiguration +import dev.ohs.fhir.datacapture.views.QuestionnaireViewItem +import dev.ohs.fhir.datacapture.views.components.DATE_TEXT_INPUT_FIELD +import dev.ohs.fhir.datacapture.views.components.ERROR_TEXT_AT_HEADER_TEST_TAG +import dev.ohs.fhir.datacapture.views.components.QUESTION_HEADER_TAG +import dev.ohs.fhir.datacapture.views.components.TIME_PICKER_INPUT_FIELD +import io.kotest.matchers.collections.shouldBeEmpty +import io.kotest.matchers.nulls.shouldBeNull +import io.kotest.matchers.nulls.shouldNotBeNull +import io.kotest.matchers.shouldBe +import java.util.Locale +import kotlin.test.Test +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.UtcOffset +import kotlinx.datetime.number + +@OptIn(ExperimentalTestApi::class) +class DateTimeViewFactoryTest { + @Composable + fun QuestionnaireDateTimeView(questionnaireViewItem: QuestionnaireViewItem) { + QuestionnaireTheme { DateTimeViewFactory.Content(questionnaireViewItem) } + } + + @Test + fun shouldSetQuestionHeader() = runComposeUiTest { + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "datetime-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.DateTime), + text = FhirR4String(value = "Question?"), + ), + QuestionnaireResponse.Item( + linkId = + FhirR4String( + value = "datetime-item", + ), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + + setContent { QuestionnaireDateTimeView(questionnaireViewItem) } + + onNodeWithTag(QUESTION_HEADER_TAG).assertTextEquals("Question?") + } + + @Test + fun shouldSetEmptyDateTimeInput() = runComposeUiTest { + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "datetime-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.DateTime), + text = FhirR4String(value = "Question?"), + ), + QuestionnaireResponse.Item( + linkId = + FhirR4String( + value = "datetime-item", + ), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + + setContent { QuestionnaireDateTimeView(questionnaireViewItem) } + + onNodeWithTag(DATE_TEXT_INPUT_FIELD, useUnmergedTree = true).assertTextEquals("") + onNodeWithTag(TIME_PICKER_INPUT_FIELD, useUnmergedTree = true).assertTextEquals("") + } + + @Test + fun showDateFormatLabelInLowerCase() = runComposeUiTest { + Locale.setDefault(Locale.US) + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "datetime-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.DateTime), + ), + QuestionnaireResponse.Item( + linkId = + FhirR4String( + value = "datetime-item", + ), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + + setContent { QuestionnaireDateTimeView(questionnaireViewItem) } + onNodeWithTag(DATE_TEXT_INPUT_FIELD).assertTextEquals("mm/dd/yyyy", includeEditableText = false) + } + + @Test + fun shouldSetDateTimeInput() = runComposeUiTest { + Locale.setDefault(Locale.US) + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "datetime-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.DateTime), + text = FhirR4String(value = "Question?"), + ), + QuestionnaireResponse.Item( + linkId = FhirR4String(value = "datetime-item"), + answer = + listOf( + QuestionnaireResponse.Item.Answer( + value = + QuestionnaireResponse.Item.Answer.Value.DateTime( + value = + DateTime( + value = + FhirDateTime.DateTime( + dateTime = LocalDateTime(2020, 1, 5, 1, 30), + utcOffset = UtcOffset.ZERO, + ), + ), + ), + ), + ), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + + setContent { QuestionnaireDateTimeView(questionnaireViewItem) } + + onNodeWithTag(DATE_TEXT_INPUT_FIELD, useUnmergedTree = true).assertTextEquals("01/05/2020") + onNodeWithTag(TIME_PICKER_INPUT_FIELD, useUnmergedTree = true) + .assert( + hasTextExactly("1:30\u202FAM"), + ) // U+202F (Narrow No-Break Space - NNBSP) for time formatting in US Locale + } + + @Test + fun parseDateTextInputInUSLocale() = runComposeUiTest { + Locale.setDefault(Locale.US) + var answer: QuestionnaireResponse.Item.Answer? = null + val itemViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "datetime-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.DateTime), + text = FhirR4String(value = "Question?"), + ), + QuestionnaireResponse.Item( + linkId = FhirR4String(value = "datetime-item"), + answer = + listOf( + QuestionnaireResponse.Item.Answer( + value = + QuestionnaireResponse.Item.Answer.Value.DateTime( + value = + DateTime( + value = + FhirDateTime.DateTime( + dateTime = LocalDateTime(2020, 1, 5, 1, 30), + utcOffset = UtcOffset.ZERO, + ), + ), + ), + ), + ), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, result, _ -> answer = result.singleOrNull() }, + ) + setContent { QuestionnaireDateTimeView(itemViewItem) } + + onNodeWithTag(DATE_TEXT_INPUT_FIELD).performTextReplacement("11/19/2020") + waitUntil { answer != null } + + val dateTime = (answer!!.value?.asDateTime()?.value?.value as? FhirDateTime.DateTime)?.dateTime + dateTime.shouldNotBeNull() + dateTime.day.shouldBe(19) + dateTime.month.number.shouldBe(11) + dateTime.year.shouldBe(2020) + } + + @Test + fun parseDateTextInputInJapanLocale() = runComposeUiTest { + Locale.setDefault(Locale.JAPAN) + var answer: QuestionnaireResponse.Item.Answer? = null + val itemViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "datetime-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.DateTime), + text = FhirR4String(value = "Question?"), + ), + QuestionnaireResponse.Item( + linkId = FhirR4String(value = "datetime-item"), + answer = + listOf( + QuestionnaireResponse.Item.Answer( + value = + QuestionnaireResponse.Item.Answer.Value.DateTime( + value = + DateTime( + value = + FhirDateTime.DateTime( + dateTime = LocalDateTime(2020, 1, 5, 1, 30), + utcOffset = UtcOffset.ZERO, + ), + ), + ), + ), + ), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, result, _ -> answer = result.singleOrNull() }, + ) + setContent { QuestionnaireDateTimeView(itemViewItem) } + + onNodeWithTag(DATE_TEXT_INPUT_FIELD).performTextReplacement("2020/11/19") + waitUntil { answer != null } + + val dateTime = (answer!!.value?.asDateTime()?.value?.value as? FhirDateTime.DateTime)?.dateTime + dateTime.shouldNotBeNull() + dateTime.day.shouldBe(19) + dateTime.month.number.shouldBe(11) + dateTime.year.shouldBe(2020) + } + + @Test + fun ifDateInputIsInvalidThenClearTheAnswer() = runComposeUiTest { + Locale.setDefault(Locale.JAPAN) + var answers: List? = null + var draftAnswer: Any? = null + val itemViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "datetime-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.DateTime), + text = FhirR4String(value = "Question?"), + ), + QuestionnaireResponse.Item( + linkId = FhirR4String(value = "datetime-item"), + answer = + listOf( + QuestionnaireResponse.Item.Answer( + value = + QuestionnaireResponse.Item.Answer.Value.DateTime( + value = + DateTime( + value = + FhirDateTime.DateTime( + dateTime = LocalDateTime(2020, 1, 5, 1, 30), + utcOffset = UtcOffset.ZERO, + ), + ), + ), + ), + ), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, result, draft -> + answers = result + draftAnswer = draft + }, + ) + setContent { QuestionnaireDateTimeView(itemViewItem) } + + onNodeWithTag(DATE_TEXT_INPUT_FIELD).performTextReplacement("2020/11") + waitUntil { answers != null } + + answers!!.shouldBeEmpty() + (draftAnswer as String).shouldBe("2020/11") + } + + @Test + fun doNotClearTheTextFieldInputOnInvalidDate() = runComposeUiTest { + Locale.setDefault(Locale.JAPAN) + val itemViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "datetime-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.DateTime), + text = FhirR4String(value = "Question?"), + ), + QuestionnaireResponse.Item( + linkId = FhirR4String(value = "datetime-item"), + answer = + listOf( + QuestionnaireResponse.Item.Answer( + value = + QuestionnaireResponse.Item.Answer.Value.DateTime( + value = + DateTime( + value = + FhirDateTime.DateTime( + dateTime = LocalDateTime(2020, 1, 5, 1, 30), + utcOffset = UtcOffset.ZERO, + ), + ), + ), + ), + ), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + setContent { QuestionnaireDateTimeView(itemViewItem) } + onNodeWithTag(DATE_TEXT_INPUT_FIELD).performTextReplacement("2020/11") + + onNodeWithTag(DATE_TEXT_INPUT_FIELD, useUnmergedTree = true).assertTextEquals("2020/11") + } + + @Test + fun clearQuestionnaireResponseAnswerOnDraftAnswerUpdate() = runComposeUiTest { + var answers: List? = + listOf(QuestionnaireResponse.Item.Answer()) + val questionnaireItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "datetime-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.DateTime), + text = FhirR4String(value = "Question?"), + ), + QuestionnaireResponse.Item( + linkId = FhirR4String(value = "datetime-item"), + answer = + listOf( + QuestionnaireResponse.Item.Answer( + value = + QuestionnaireResponse.Item.Answer.Value.DateTime( + value = + DateTime( + value = + FhirDateTime.DateTime( + dateTime = LocalDateTime(2020, 1, 5, 1, 30), + utcOffset = UtcOffset.ZERO, + ), + ), + ), + ), + ), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, answersForCallback, _ -> answers = answersForCallback }, + ) + + setContent { QuestionnaireDateTimeView(questionnaireItem) } + questionnaireItem.setDraftAnswer("02/07") + + answers!!.shouldBeEmpty() + } + + @Test + fun clearDraftAnswerOnAnValidAnswerUpdate() = runComposeUiTest { + val answer = + QuestionnaireResponse.Item.Answer( + value = + QuestionnaireResponse.Item.Answer.Value.DateTime( + value = + DateTime( + value = + FhirDateTime.DateTime( + dateTime = LocalDateTime(2020, 2, 6, 2, 30), + utcOffset = UtcOffset.ZERO, + ), + ), + ), + ) + var draft: String? = "02/07" + val questionnaireItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "datetime-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.DateTime), + ), + QuestionnaireResponse.Item( + linkId = FhirR4String(value = "datetime-item"), + answer = + listOf( + QuestionnaireResponse.Item.Answer( + value = + QuestionnaireResponse.Item.Answer.Value.DateTime( + value = + DateTime( + value = + FhirDateTime.DateTime( + dateTime = LocalDateTime(2020, 1, 5, 1, 30), + utcOffset = UtcOffset.ZERO, + ), + ), + ), + ), + ), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, draftAnswer -> draft = draftAnswer as? String }, + ) + + setContent { QuestionnaireDateTimeView(questionnaireItem) } + questionnaireItem.setAnswer(answer) + draft.shouldBeNull() + } + + @Test + fun displayDraftAnswerInTheTextFieldOfRecycledItems() = runComposeUiTest { + Locale.setDefault(Locale.US) + var questionnaireItem by + mutableStateOf( + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "datetime-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.DateTime), + ), + QuestionnaireResponse.Item( + linkId = FhirR4String(value = "datetime-item"), + answer = + listOf( + QuestionnaireResponse.Item.Answer( + value = + QuestionnaireResponse.Item.Answer.Value.DateTime( + value = + DateTime( + value = + FhirDateTime.DateTime( + dateTime = LocalDateTime(2020, 1, 5, 1, 30), + utcOffset = UtcOffset.ZERO, + ), + ), + ), + ), + ), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ), + ) + + setContent { QuestionnaireDateTimeView(questionnaireItem) } + onNodeWithTag(DATE_TEXT_INPUT_FIELD, useUnmergedTree = true).assertTextEquals("01/05/2020") + + questionnaireItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "datetime-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.DateTime), + ), + QuestionnaireResponse.Item( + linkId = + FhirR4String( + value = "datetime-item", + ), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + draftAnswer = "02/07", + ) + + onNodeWithTag(DATE_TEXT_INPUT_FIELD, useUnmergedTree = true).assertTextEquals("02/07") + } + + @Test + fun displayAnAnswerInTheTextFieldOfPartiallyAnsweredRecycledItem() = runComposeUiTest { + Locale.setDefault(Locale.US) + var questionnaireItem by + mutableStateOf( + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "datetime-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.DateTime), + ), + QuestionnaireResponse.Item( + linkId = + FhirR4String( + value = "datetime-item", + ), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + draftAnswer = "02/07", + ), + ) + + setContent { QuestionnaireDateTimeView(questionnaireItem) } + onNodeWithTag(DATE_TEXT_INPUT_FIELD, useUnmergedTree = true).assertTextEquals("02/07") + + questionnaireItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "datetime-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.DateTime), + ), + QuestionnaireResponse.Item( + linkId = FhirR4String(value = "datetime-item"), + answer = + listOf( + QuestionnaireResponse.Item.Answer( + value = + QuestionnaireResponse.Item.Answer.Value.DateTime( + value = + DateTime( + value = + FhirDateTime.DateTime( + dateTime = LocalDateTime(2020, 1, 5, 1, 30), + utcOffset = UtcOffset.ZERO, + ), + ), + ), + ), + ), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + + onNodeWithTag(DATE_TEXT_INPUT_FIELD, useUnmergedTree = true).assertTextEquals("01/05/2020") + } + + @Test + fun ifDraftAnswerInputIsInvalidThenDoNotEnableTimeTextInputLayout() = runComposeUiTest { + val itemViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "datetime-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.DateTime), + text = FhirR4String(value = "Question?"), + ), + QuestionnaireResponse.Item( + linkId = + FhirR4String( + value = "datetime-item", + ), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + draftAnswer = "11/19", + ) + + setContent { QuestionnaireDateTimeView(itemViewItem) } + onNodeWithTag(TIME_PICKER_INPUT_FIELD).assertIsNotEnabled() + } + + @Test + fun ifTheDraftAnswerInputIsEmptyDoNotEnableTheTimeTextInputLayout() = runComposeUiTest { + val itemViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "datetime-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.DateTime), + text = FhirR4String(value = "Question?"), + ), + QuestionnaireResponse.Item( + linkId = + FhirR4String( + value = "datetime-item", + ), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + draftAnswer = "", + ) + + setContent { QuestionnaireDateTimeView(itemViewItem) } + onNodeWithTag(TIME_PICKER_INPUT_FIELD).assertIsNotEnabled() + } + + @Test + fun ifThereIsNoAnswerOrDraftAnswerDoNotEnableTheTimeTextInputLayout() = runComposeUiTest { + val itemViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "datetime-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.DateTime), + text = FhirR4String(value = "Question?"), + ), + QuestionnaireResponse.Item( + linkId = + FhirR4String( + value = "datetime-item", + ), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + draftAnswer = null, + ) + + setContent { QuestionnaireDateTimeView(itemViewItem) } + onNodeWithTag(TIME_PICKER_INPUT_FIELD).assertIsNotEnabled() + } + + @Test + fun ifDateDraftAnswerIsValidThenEnableTimeTextInputLayout() = runComposeUiTest { + val itemViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "datetime-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.DateTime), + text = FhirR4String(value = "Question?"), + ), + QuestionnaireResponse.Item( + linkId = + FhirR4String( + value = "datetime-item", + ), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + draftAnswer = "11/19/2020", + ) + + setContent { QuestionnaireDateTimeView(itemViewItem) } + onNodeWithTag(TIME_PICKER_INPUT_FIELD).assertIsNotEnabled() + } + + @Test + fun displayValidationResult_error_shouldShowErrorMessage() = runComposeUiTest { + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "datetime-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.DateTime), + required = FhirR4Boolean(value = true), + ), + QuestionnaireResponse.Item( + linkId = + FhirR4String( + value = "datetime-item", + ), + ), + validationResult = Invalid(listOf("Missing answer for required field.")), + answersChangedCallback = { _, _, _, _ -> }, + ) + + setContent { QuestionnaireDateTimeView(questionnaireViewItem) } + + onNodeWithTag(DATE_TEXT_INPUT_FIELD) + .assert( + SemanticsMatcher.expectValue( + SemanticsProperties.Error, + "Missing answer for required field.", + ), + ) + } + + @Test + fun displayValidationResult_noError_shouldShowNoErrorMessage() = runComposeUiTest { + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "datetime-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.DateTime), + extension = + listOf( + Extension( + url = "http://hl7.org/fhir/StructureDefinition/minValue", + value = + Extension.Value.DateTime( + value = + DateTime( + value = + FhirDateTime.DateTime( + dateTime = LocalDateTime(2020, 1, 5, 1, 30), + utcOffset = UtcOffset.ZERO, + ), + ), + ), + ), + Extension( + url = "http://hl7.org/fhir/StructureDefinition/maxValue", + value = + Extension.Value.DateTime( + value = + DateTime( + value = + FhirDateTime.DateTime( + dateTime = LocalDateTime(2025, 1, 5, 1, 30), + utcOffset = UtcOffset.ZERO, + ), + ), + ), + ), + ), + ), + QuestionnaireResponse.Item( + linkId = FhirR4String(value = "datetime-item"), + answer = + listOf( + QuestionnaireResponse.Item.Answer( + value = + QuestionnaireResponse.Item.Answer.Value.DateTime( + value = + DateTime( + value = + FhirDateTime.DateTime( + dateTime = LocalDateTime(2023, 1, 5, 1, 30), + utcOffset = UtcOffset.ZERO, + ), + ), + ), + ), + ), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + + setContent { QuestionnaireDateTimeView(questionnaireViewItem) } + + onNodeWithTag(DATE_TEXT_INPUT_FIELD) + .assert(SemanticsMatcher.keyNotDefined(SemanticsProperties.Error)) + onNodeWithTag(TIME_PICKER_INPUT_FIELD) + .assert(SemanticsMatcher.keyNotDefined(SemanticsProperties.Error)) + } + + @Test + fun ifTheDraftAnswerIsInvalidDisplayTheErrorMessage() = runComposeUiTest { + Locale.setDefault(Locale.US) + + val itemViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "datetime-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.DateTime), + text = FhirR4String(value = "Question?"), + ), + QuestionnaireResponse.Item( + linkId = + FhirR4String( + value = "datetime-item", + ), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + draftAnswer = "11/19/202", + ) + + setContent { QuestionnaireDateTimeView(itemViewItem) } + + onNodeWithTag(DATE_TEXT_INPUT_FIELD) + .assert( + SemanticsMatcher.expectValue( + SemanticsProperties.Error, + "Date format needs to be mm/dd/yyyy (e.g. 01/31/2023)", + ), + ) + } + + @Test + fun showDateFormatInLowerCaseInTheErrorMessage() = runComposeUiTest { + Locale.setDefault(Locale.US) + val itemViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "datetime-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.DateTime), + ), + QuestionnaireResponse.Item( + linkId = + FhirR4String( + value = "datetime-item", + ), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + draftAnswer = "11/19/202", + ) + + setContent { QuestionnaireDateTimeView(itemViewItem) } + onNodeWithTag(DATE_TEXT_INPUT_FIELD) + .assert( + SemanticsMatcher.expectValue( + SemanticsProperties.Error, + "Date format needs to be mm/dd/yyyy (e.g. 01/31/2023)", + ), + ) + } + + @Test + fun hidesErrorTextviewInTheHeader() = runComposeUiTest { + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "datetime-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.DateTime), + ), + QuestionnaireResponse.Item( + linkId = + FhirR4String( + value = "datetime-item", + ), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + + setContent { QuestionnaireDateTimeView(questionnaireViewItem) } + + onNodeWithTag(ERROR_TEXT_AT_HEADER_TEST_TAG).assertDoesNotExist() + } + + @Test + fun bind_readOnly_shouldDisableView() = runComposeUiTest { + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "datetime-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.DateTime), + readOnly = FhirR4Boolean(value = true), + ), + QuestionnaireResponse.Item( + linkId = + FhirR4String( + value = "datetime-item", + ), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + + setContent { QuestionnaireDateTimeView(questionnaireViewItem) } + + onNodeWithTag(DATE_TEXT_INPUT_FIELD).assertIsNotEnabled() + onNodeWithTag(TIME_PICKER_INPUT_FIELD).assertIsNotEnabled() + } + + @Test + fun bindMultipleTimesWithSeparateQuestionnaireItemViewItemShouldShowProperDateAndTime() = + runComposeUiTest { + Locale.setDefault(Locale.US) + var questionnaireViewItem by + mutableStateOf( + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "datetime-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.DateTime), + text = FhirR4String(value = "Question?"), + ), + QuestionnaireResponse.Item( + linkId = FhirR4String(value = "datetime-item"), + answer = + listOf( + QuestionnaireResponse.Item.Answer( + value = + QuestionnaireResponse.Item.Answer.Value.DateTime( + value = + DateTime( + value = + FhirDateTime.DateTime( + dateTime = LocalDateTime(2020, 1, 5, 1, 30), + utcOffset = UtcOffset.ZERO, + ), + ), + ), + ), + ), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ), + ) + + setContent { QuestionnaireDateTimeView(questionnaireViewItem) } + + onNodeWithTag(DATE_TEXT_INPUT_FIELD, useUnmergedTree = true).assertTextEquals("01/05/2020") + onNodeWithTag(TIME_PICKER_INPUT_FIELD, useUnmergedTree = true) + .assert( + SemanticsMatcher.expectValue( + SemanticsProperties.EditableText, + AnnotatedString("1:30\u202FAM"), + ), // U+202F (Narrow No-Break Space - NNBSP) for time formatting in US Locale + ) + + questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "datetime-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.DateTime), + text = FhirR4String(value = "Question?"), + ), + QuestionnaireResponse.Item( + linkId = FhirR4String(value = "datetime-item"), + answer = + listOf( + QuestionnaireResponse.Item.Answer( + value = + QuestionnaireResponse.Item.Answer.Value.DateTime( + value = + DateTime( + value = + FhirDateTime.DateTime( + dateTime = LocalDateTime(2021, 1, 5, 2, 30), + utcOffset = UtcOffset.ZERO, + ), + ), + ), + ), + ), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + + onNodeWithTag(DATE_TEXT_INPUT_FIELD, useUnmergedTree = true).assertTextEquals("01/05/2021") + onNodeWithTag(TIME_PICKER_INPUT_FIELD, useUnmergedTree = true) + .assert( + SemanticsMatcher.expectValue( + SemanticsProperties.EditableText, + AnnotatedString("2:30\u202FAM"), + ), // U+202F (Narrow No-Break Space - NNBSP) for time formatting in US Locale + ) + + questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "datetime-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.DateTime), + text = FhirR4String(value = "Question?"), + ), + QuestionnaireResponse.Item( + linkId = + FhirR4String( + value = "datetime-item", + ), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + + onNodeWithTag(DATE_TEXT_INPUT_FIELD, useUnmergedTree = true).assertTextEquals("") + onNodeWithTag(TIME_PICKER_INPUT_FIELD, useUnmergedTree = true).assertTextEquals("") + } + + @Test + fun showsTimePickerInInputMode() = runComposeUiTest { + val questionnaireItemView = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "datetime-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.DateTime), + ), + QuestionnaireResponse.Item( + linkId = + FhirR4String( + value = "datetime-item", + ), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + + setContent { QuestionnaireDateTimeView(questionnaireItemView) } + onNodeWithTag(DATE_TEXT_INPUT_FIELD) + .onChildren() + .filterToOne( + SemanticsMatcher.expectValue(SemanticsProperties.Role, Role.Button), + ) + .performClick() + onNodeWithText("OK").performClick() + onNodeWithTag(TIME_PICKER_INPUT_FIELD).performClick() + + onNode( + hasContentDescription("Switch to clock input", substring = true) and + SemanticsMatcher.expectValue(SemanticsProperties.Role, Role.Button), + ) + .assertIsDisplayed() + onNode(hasContentDescription("for hour", substring = true) and isEditable()).assertIsDisplayed() + onNode(hasContentDescription("for minutes", substring = true) and isEditable()).assertExists() + } + + @Test + fun showsTimePickerInClockMode() = runComposeUiTest { + val questionnaireItemView = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "datetime-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.DateTime), + ), + QuestionnaireResponse.Item( + linkId = + FhirR4String( + value = "datetime-item", + ), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + + setContent { QuestionnaireDateTimeView(questionnaireItemView) } + onNodeWithTag(DATE_TEXT_INPUT_FIELD) + .onChildren() + .filterToOne( + SemanticsMatcher.expectValue(SemanticsProperties.Role, Role.Button), + ) + .performClick() + onNodeWithText("OK").performClick() + onNodeWithTag(TIME_PICKER_INPUT_FIELD) + .onChildren() + .filterToOne( + SemanticsMatcher.expectValue(SemanticsProperties.Role, Role.Button), + ) + .performClick() + + onNode( + hasContentDescription("Switch to text input", substring = true) and + SemanticsMatcher.expectValue(SemanticsProperties.Role, Role.Button), + ) + .assertIsDisplayed() + onNode( + hasAnyChild(SemanticsMatcher.keyIsDefined(SemanticsProperties.TraversalIndex)) and + SemanticsMatcher.keyIsDefined(SemanticsProperties.SelectableGroup), + ) + .assertIsDisplayed() + } + + @Test + fun showsAsterisk() = runComposeUiTest { + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "datetime-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.DateTime), + text = FhirR4String(value = "Question?"), + required = FhirR4Boolean(value = true), + ), + QuestionnaireResponse.Item( + linkId = + FhirR4String( + value = "datetime-item", + ), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + questionViewTextConfiguration = QuestionTextConfiguration(showAsterisk = true), + ) + + setContent { QuestionnaireDateTimeView(questionnaireViewItem) } + + onNodeWithTag(QUESTION_HEADER_TAG).assertTextEquals("Question? *") + } + + @Test + fun hideAsterisk() = runComposeUiTest { + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "datetime-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.DateTime), + text = FhirR4String(value = "Question?"), + required = FhirR4Boolean(value = true), + ), + QuestionnaireResponse.Item( + linkId = + FhirR4String( + value = "datetime-item", + ), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + questionViewTextConfiguration = QuestionTextConfiguration(showAsterisk = false), + ) + + setContent { QuestionnaireDateTimeView(questionnaireViewItem) } + + onNodeWithTag(QUESTION_HEADER_TAG).assertTextEquals("Question?") + } + + @Test + fun showsRequiredText() = runComposeUiTest { + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "datetime-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.DateTime), + required = FhirR4Boolean(value = true), + ), + QuestionnaireResponse.Item( + linkId = + FhirR4String( + value = "datetime-item", + ), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + questionViewTextConfiguration = QuestionTextConfiguration(showRequiredText = true), + ) + + setContent { QuestionnaireDateTimeView(questionnaireViewItem) } + onNodeWithTag(DATE_TEXT_INPUT_FIELD).assertTextContains("Required", substring = true) + } + + @Test + fun hideRequiredText() = runComposeUiTest { + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "datetime-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.DateTime), + required = FhirR4Boolean(value = true), + ), + QuestionnaireResponse.Item( + linkId = + FhirR4String( + value = "datetime-item", + ), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + questionViewTextConfiguration = QuestionTextConfiguration(showRequiredText = false), + ) + + setContent { QuestionnaireDateTimeView(questionnaireViewItem) } + + onNodeWithText("Required").assertDoesNotExist() + } + + @Test + fun showsOptionalText() = runComposeUiTest { + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "datetime-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.DateTime), + ), + QuestionnaireResponse.Item( + linkId = + FhirR4String( + value = "datetime-item", + ), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + questionViewTextConfiguration = QuestionTextConfiguration(showOptionalText = true), + ) + + setContent { QuestionnaireDateTimeView(questionnaireViewItem) } + + onNodeWithTag(DATE_TEXT_INPUT_FIELD).assertTextContains("Optional") + } + + @Test + fun hideOptionalText() = runComposeUiTest { + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "datetime-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.DateTime), + ), + QuestionnaireResponse.Item( + linkId = + FhirR4String( + value = "datetime-item", + ), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + questionViewTextConfiguration = QuestionTextConfiguration(showOptionalText = false), + ) + + setContent { QuestionnaireDateTimeView(questionnaireViewItem) } + onNodeWithText("Optional").assertDoesNotExist() + } +} diff --git a/datacapture/src/desktopTest/kotlin/dev/ohs/fhir/datacapture/views/factories/TimeViewFactoryTest.kt b/datacapture/src/desktopTest/kotlin/dev/ohs/fhir/datacapture/views/factories/TimeViewFactoryTest.kt new file mode 100644 index 000000000..58d2aacf3 --- /dev/null +++ b/datacapture/src/desktopTest/kotlin/dev/ohs/fhir/datacapture/views/factories/TimeViewFactoryTest.kt @@ -0,0 +1,215 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.ohs.fhir.datacapture.views.factories + +import androidx.compose.runtime.Composable +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assert +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.hasTextExactly +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.runComposeUiTest +import com.google.fhir.model.r4.Enumeration +import com.google.fhir.model.r4.Questionnaire +import com.google.fhir.model.r4.QuestionnaireResponse +import com.google.fhir.model.r4.Time +import dev.ohs.fhir.datacapture.extensions.FhirR4String +import dev.ohs.fhir.datacapture.theme.QuestionnaireTheme +import dev.ohs.fhir.datacapture.validation.NotValidated +import dev.ohs.fhir.datacapture.views.QuestionnaireViewItem +import dev.ohs.fhir.datacapture.views.components.QUESTION_HEADER_TAG +import dev.ohs.fhir.datacapture.views.components.TIME_PICKER_INPUT_FIELD +import java.util.Locale +import kotlin.test.Test +import kotlinx.datetime.LocalTime + +@OptIn(ExperimentalTestApi::class) +class TimeViewFactoryTest { + + @Composable + fun QuestionnaireTimeView(questionnaireViewItem: QuestionnaireViewItem) { + QuestionnaireTheme { TimeViewFactory.Content(questionnaireViewItem) } + } + + @Test + fun shouldSetQuestionHeader() = runComposeUiTest { + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "time-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Time), + text = FhirR4String(value = "Question?"), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "time-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + + setContent { QuestionnaireTimeView(questionnaireViewItem) } + + onNodeWithTag(QUESTION_HEADER_TAG).assertTextEquals("Question?") + } + + @Test + fun shouldSetEmptyTimeInput() = runComposeUiTest { + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "time-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Time), + text = FhirR4String(value = "Question?"), + ), + QuestionnaireResponse.Item(linkId = FhirR4String(value = "time-item")), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + + setContent { QuestionnaireTimeView(questionnaireViewItem) } + + onNodeWithTag(TIME_PICKER_INPUT_FIELD, useUnmergedTree = true).assertTextEquals("") + } + + @Test + fun shouldDisplayAMTimeInCorrectFormat_Locale_Japan() = runComposeUiTest { + Locale.setDefault(Locale.JAPAN) + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "time-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Time), + text = FhirR4String(value = "Question?"), + ), + QuestionnaireResponse.Item( + linkId = FhirR4String(value = "time-item"), + answer = + listOf( + QuestionnaireResponse.Item.Answer( + value = + QuestionnaireResponse.Item.Answer.Value.Time( + value = Time(value = LocalTime.parse("10:10:00")), + ), + ), + ), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + + setContent { QuestionnaireTimeView(questionnaireViewItem) } + + onNodeWithTag(TIME_PICKER_INPUT_FIELD, useUnmergedTree = true).assert(hasTextExactly("10:10")) + } + + @Test + fun shouldDisplayAMTimeInCorrectFormat_Locale_US() = runComposeUiTest { + Locale.setDefault(Locale.US) + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "time-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Time), + text = FhirR4String(value = "Question?"), + ), + QuestionnaireResponse.Item( + linkId = FhirR4String(value = "time-item"), + answer = + listOf( + QuestionnaireResponse.Item.Answer( + value = + QuestionnaireResponse.Item.Answer.Value.Time( + value = Time(value = LocalTime.parse("10:10:00")), + ), + ), + ), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + + setContent { QuestionnaireTimeView(questionnaireViewItem) } + + onNodeWithTag(TIME_PICKER_INPUT_FIELD, useUnmergedTree = true) + .assert( + hasTextExactly("10:10\u202FAM"), + ) // U+202F (Narrow No-Break Space - NNBSP) for time formatting in US Locale + } + + @Test + fun shouldDisplayPMTimeInCorrectFormat_Locale_US() = runComposeUiTest { + Locale.setDefault(Locale.US) + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "time-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Time), + text = FhirR4String(value = "Question?"), + ), + QuestionnaireResponse.Item( + linkId = FhirR4String(value = "time-item"), + answer = + listOf( + QuestionnaireResponse.Item.Answer( + value = + QuestionnaireResponse.Item.Answer.Value.Time( + value = Time(value = LocalTime.parse("22:10:00")), + ), + ), + ), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + + setContent { QuestionnaireTimeView(questionnaireViewItem) } + + onNodeWithTag(TIME_PICKER_INPUT_FIELD, useUnmergedTree = true) + .assert( + hasTextExactly("10:10\u202FPM"), + ) // U+202F (Narrow No-Break Space - NNBSP) for time formatting in US Locale + } + + @Test + fun shouldDisplayPMTimeInCorrectFormat_Locale_Japan() = runComposeUiTest { + Locale.setDefault(Locale.JAPAN) + val questionnaireViewItem = + QuestionnaireViewItem( + Questionnaire.Item( + linkId = FhirR4String(value = "time-item"), + type = Enumeration(value = Questionnaire.QuestionnaireItemType.Time), + text = FhirR4String(value = "Question?"), + ), + QuestionnaireResponse.Item( + linkId = FhirR4String(value = "time-item"), + answer = + listOf( + QuestionnaireResponse.Item.Answer( + value = + QuestionnaireResponse.Item.Answer.Value.Time( + value = Time(value = LocalTime.parse("22:10:00")), + ), + ), + ), + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + + setContent { QuestionnaireTimeView(questionnaireViewItem) } + + onNodeWithTag(TIME_PICKER_INPUT_FIELD, useUnmergedTree = true).assert(hasTextExactly("22:10")) + } +} diff --git a/datacapture/src/iosMain/kotlin/dev/ohs/fhir/datacapture/AttachmentMediaHandler.ios.kt b/datacapture/src/iosMain/kotlin/dev/ohs/fhir/datacapture/AttachmentMediaHandler.ios.kt new file mode 100644 index 000000000..cf189309e --- /dev/null +++ b/datacapture/src/iosMain/kotlin/dev/ohs/fhir/datacapture/AttachmentMediaHandler.ios.kt @@ -0,0 +1,135 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.ohs.fhir.datacapture + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import co.touchlab.kermit.Logger +import com.ionspin.kotlin.bignum.decimal.BigDecimal +import io.github.vinceglb.filekit.FileKit +import io.github.vinceglb.filekit.compressImage +import io.github.vinceglb.filekit.dialogs.FileKitType +import io.github.vinceglb.filekit.dialogs.openCameraPicker +import io.github.vinceglb.filekit.dialogs.openFilePicker +import io.github.vinceglb.filekit.mimeType +import io.github.vinceglb.filekit.name +import io.github.vinceglb.filekit.readBytes +import kotlin.coroutines.cancellation.CancellationException +import kotlinx.coroutines.suspendCancellableCoroutine +import platform.AVFoundation.AVAuthorizationStatusAuthorized +import platform.AVFoundation.AVAuthorizationStatusDenied +import platform.AVFoundation.AVAuthorizationStatusNotDetermined +import platform.AVFoundation.AVCaptureDevice +import platform.AVFoundation.AVMediaTypeVideo +import platform.AVFoundation.authorizationStatusForMediaType +import platform.AVFoundation.requestAccessForMediaType +import platform.UIKit.UIImagePickerController +import platform.UIKit.UIImagePickerControllerSourceType +import platform.UniformTypeIdentifiers.UTType + +internal class IosMediaHandler( + override val maxSupportedFileSizeBytes: BigDecimal, + override val supportedMimeTypes: Array, +) : MediaHandler { + + override suspend fun capturePhoto(): MediaCaptureResult { + val currentAuthorizedStatus = AVCaptureDevice.authorizationStatusForMediaType(AVMediaTypeVideo) + return when (currentAuthorizedStatus) { + AVAuthorizationStatusAuthorized -> { + val pickedFile = FileKit.openCameraPicker() + pickedFile?.let { + captureResult( + FileKit.compressImage(it), + titleName = it.name, + mimeType = it.mimeType()?.toString() ?: "application/octet-stream", + ) + } + ?: throw CancellationException() + } + AVAuthorizationStatusNotDetermined -> { + suspendCancellableCoroutine { continuation -> + AVCaptureDevice.requestAccessForMediaType(AVMediaTypeVideo) { isGranted -> + continuation.resumeWith(Result.success(Unit)) + } + } + throw CancellationException() // cancel camera picker request to reset and allow user to + // pick again + } + AVAuthorizationStatusDenied -> + MediaCaptureResult.Error("Error: Camera permission not granted") + else -> { + Logger.e("unknown camera permission status $currentAuthorizedStatus") + MediaCaptureResult.Error("Error: Camera permission not granted") + } + } + } + + override suspend fun selectFile(inputMimeTypes: Array): MediaCaptureResult { + val imageOnly = inputMimeTypes.all { it.startsWith("image/") } + val fileKitType = + if (imageOnly) { + FileKitType.Image + } else { + val extensions = + inputMimeTypes.mapNotNull { getExtensionFromMimeType(it) } + + inputMimeTypes.flatMap { getExtensionsForWildcardMimeType(it) } + FileKitType.File( + extensions.toSet().takeIf { it.isNotEmpty() }, + ) + } + val pickedFile = FileKit.openFilePicker(type = fileKitType) + + return pickedFile?.let { + captureResult( + if (fileKitType == FileKitType.Image) FileKit.compressImage(it) else it.readBytes(), + mimeType = it.mimeType()?.toString() ?: "application/octet-stream", + titleName = it.name, + ) + } + ?: throw CancellationException() + } + + override fun isCameraSupported(): Boolean = + UIImagePickerController.isSourceTypeAvailable( + UIImagePickerControllerSourceType.UIImagePickerControllerSourceTypeCamera, + ) + + private fun getExtensionsForWildcardMimeType(mimeType: String): List { + return when (mimeType.lowercase()) { + "image/*" -> listOf("jpg", "jpeg", "png", "gif", "bmp") + "text/*" -> listOf("txt", "md") + "video/*" -> listOf("mp4", "avi", "mov", "wmv") + "audio/*" -> listOf("mp3", "wav", "flac", "aac", "m4a") + else -> emptyList() + } + } + + private fun getExtensionFromMimeType(mimeType: String): String? { + val type = UTType.typeWithMIMEType(mimeType) + return type?.preferredFilenameExtension + } +} + +@Composable +internal actual fun rememberMediaHandler( + maxSupportedFileSizeBytes: BigDecimal, + supportedMimeTypes: Array, +): MediaHandler { + return remember(maxSupportedFileSizeBytes, supportedMimeTypes) { + IosMediaHandler(maxSupportedFileSizeBytes, supportedMimeTypes) + } +} diff --git a/datacapture/src/iosMain/kotlin/dev/ohs/fhir/datacapture/DataCapture.ios.kt b/datacapture/src/iosMain/kotlin/dev/ohs/fhir/datacapture/DataCapture.ios.kt new file mode 100644 index 000000000..cbc7d4bf5 --- /dev/null +++ b/datacapture/src/iosMain/kotlin/dev/ohs/fhir/datacapture/DataCapture.ios.kt @@ -0,0 +1,24 @@ +/* + * Copyright 2025-2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.ohs.fhir.datacapture + +actual object DataCapture { + actual fun getConfiguration(): DataCaptureConfig { + // TODO Replace default configuration with actual data capture configuration + return DataCaptureConfig() + } +} diff --git a/datacapture/src/iosMain/kotlin/dev/ohs/fhir/datacapture/LocalDateTimeFormatter.ios.kt b/datacapture/src/iosMain/kotlin/dev/ohs/fhir/datacapture/LocalDateTimeFormatter.ios.kt new file mode 100644 index 000000000..1cf345077 --- /dev/null +++ b/datacapture/src/iosMain/kotlin/dev/ohs/fhir/datacapture/LocalDateTimeFormatter.ios.kt @@ -0,0 +1,94 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.ohs.fhir.datacapture + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import kotlin.time.ExperimentalTime +import kotlinx.datetime.LocalDate +import kotlinx.datetime.LocalTime +import kotlinx.datetime.TimeZone +import kotlinx.datetime.atStartOfDayIn +import kotlinx.datetime.toKotlinInstant +import kotlinx.datetime.toLocalDateTime +import kotlinx.datetime.toNSDate +import platform.Foundation.NSCalendar +import platform.Foundation.NSDate +import platform.Foundation.NSDateComponents +import platform.Foundation.NSDateFormatter +import platform.Foundation.NSDateFormatterNoStyle +import platform.Foundation.NSDateFormatterShortStyle + +@OptIn(ExperimentalTime::class) +object IosLocalDateTimeFormatter : LocalDateTimeFormatter { + + override fun parseStringToLocalDate( + str: String, + pattern: String, + ): LocalDate { + val nsDateFormatter = NSDateFormatter() + nsDateFormatter.dateFormat = pattern + val localDate = + nsDateFormatter.dateFromString(str) ?: throw IllegalArgumentException("Invalid date format") + return localDate.toKotlinInstant().toLocalDateTime(TimeZone.currentSystemDefault()).date + } + + override fun format(localDate: LocalDate, pattern: String?): String { + val nsDateFormatter = NSDateFormatter() + if (!pattern.isNullOrBlank()) { + nsDateFormatter.dateFormat = pattern + } else { + nsDateFormatter.dateStyle = NSDateFormatterShortStyle + } + return nsDateFormatter.stringFromDate( + localDate.atStartOfDayIn(TimeZone.currentSystemDefault()).toNSDate(), + ) + } + + override val localDateShortFormatPattern: String + get() { + val nsDateFormatter = NSDateFormatter() + nsDateFormatter.dateStyle = NSDateFormatterShortStyle + nsDateFormatter.timeStyle = NSDateFormatterNoStyle + return nsDateFormatter.dateFormat + } + + override fun localizedTimeString(time: LocalTime): String { + val nsTime = time.toNSDate() ?: return "" + val nsDateFormatter = NSDateFormatter() + nsDateFormatter.dateStyle = NSDateFormatterNoStyle + nsDateFormatter.timeStyle = NSDateFormatterShortStyle + return nsDateFormatter.stringFromDate(nsTime) + } + + private fun LocalTime.toNSDate(): NSDate? { + val calendar = NSCalendar.currentCalendar + val components = + NSDateComponents().apply { + hour = this@toNSDate.hour.toLong() + minute = this@toNSDate.minute.toLong() + second = this@toNSDate.second.toLong() + } + + return calendar.dateFromComponents(components) + } +} + +@Composable +actual fun getLocalDateTimeFormatter(): LocalDateTimeFormatter { + return remember { IosLocalDateTimeFormatter } +} diff --git a/datacapture/src/iosMain/kotlin/dev/ohs/fhir/datacapture/NumberFormatter.ios.kt b/datacapture/src/iosMain/kotlin/dev/ohs/fhir/datacapture/NumberFormatter.ios.kt new file mode 100644 index 000000000..a910748fe --- /dev/null +++ b/datacapture/src/iosMain/kotlin/dev/ohs/fhir/datacapture/NumberFormatter.ios.kt @@ -0,0 +1,32 @@ +/* + * Copyright 2025-2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.ohs.fhir.datacapture + +import platform.Foundation.NSLocale +import platform.Foundation.NSNumber +import platform.Foundation.NSNumberFormatter +import platform.Foundation.NSNumberFormatterDecimalStyle +import platform.Foundation.currentLocale + +actual object NumberFormatter { + actual fun formatInteger(value: Int): String { + val formatter = NSNumberFormatter() + formatter.numberStyle = NSNumberFormatterDecimalStyle + formatter.locale = NSLocale.currentLocale + return formatter.stringFromNumber(NSNumber(value)) ?: value.toString() + } +} diff --git a/datacapture/src/jsMain/kotlin/dev/ohs/fhir/datacapture/AttachmentMediaHandler.js.kt b/datacapture/src/jsMain/kotlin/dev/ohs/fhir/datacapture/AttachmentMediaHandler.js.kt new file mode 100644 index 000000000..ba37a3b4f --- /dev/null +++ b/datacapture/src/jsMain/kotlin/dev/ohs/fhir/datacapture/AttachmentMediaHandler.js.kt @@ -0,0 +1,68 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.ohs.fhir.datacapture + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import com.ionspin.kotlin.bignum.decimal.BigDecimal +import io.github.vinceglb.filekit.FileKit +import io.github.vinceglb.filekit.dialogs.FileKitType +import io.github.vinceglb.filekit.dialogs.openFilePicker +import io.github.vinceglb.filekit.mimeType +import io.github.vinceglb.filekit.name +import io.github.vinceglb.filekit.readBytes +import kotlin.coroutines.cancellation.CancellationException + +internal class JsMediaHandler( + override val maxSupportedFileSizeBytes: BigDecimal, + override val supportedMimeTypes: Array, +) : MediaHandler { + override suspend fun capturePhoto(): MediaCaptureResult { + return MediaCaptureResult.Error("Error: Camera not supported") + } + + override suspend fun selectFile(inputMimeTypes: Array): MediaCaptureResult { + val pickedFile = + FileKit.openFilePicker( + type = + FileKitType.File( + inputMimeTypes.toSet().takeIf { it.isNotEmpty() }, + ), + ) + + return pickedFile?.let { + captureResult( + it.readBytes(), + mimeType = it.mimeType()?.toString() ?: "application/octet-stream", + titleName = it.name, + ) + } + ?: throw CancellationException() + } + + override fun isCameraSupported(): Boolean = false +} + +@Composable +internal actual fun rememberMediaHandler( + maxSupportedFileSizeBytes: BigDecimal, + supportedMimeTypes: Array, +): MediaHandler { + return remember(maxSupportedFileSizeBytes, supportedMimeTypes) { + JsMediaHandler(maxSupportedFileSizeBytes, supportedMimeTypes) + } +} diff --git a/datacapture/src/jsMain/kotlin/dev/ohs/fhir/datacapture/DataCapture.js.kt b/datacapture/src/jsMain/kotlin/dev/ohs/fhir/datacapture/DataCapture.js.kt new file mode 100644 index 000000000..2cdbaf80e --- /dev/null +++ b/datacapture/src/jsMain/kotlin/dev/ohs/fhir/datacapture/DataCapture.js.kt @@ -0,0 +1,23 @@ +/* + * Copyright 2025-2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.ohs.fhir.datacapture + +actual object DataCapture { + actual fun getConfiguration(): DataCaptureConfig { + return DataCaptureConfig() + } +} diff --git a/datacapture/src/jsMain/kotlin/dev/ohs/fhir/datacapture/LocalDateTimeFormatter.js.kt b/datacapture/src/jsMain/kotlin/dev/ohs/fhir/datacapture/LocalDateTimeFormatter.js.kt new file mode 100644 index 000000000..b61dd288c --- /dev/null +++ b/datacapture/src/jsMain/kotlin/dev/ohs/fhir/datacapture/LocalDateTimeFormatter.js.kt @@ -0,0 +1,82 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.ohs.fhir.datacapture + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import dev.ohs.fhir.datacapture.extensions.length +import kotlin.time.Clock +import kotlin.time.ExperimentalTime +import kotlin.time.toJSDate +import kotlinx.datetime.LocalDate +import kotlinx.datetime.LocalTime +import kotlinx.datetime.TimeZone +import kotlinx.datetime.atDate +import kotlinx.datetime.format.FormatStringsInDatetimeFormats +import kotlinx.datetime.format.byUnicodePattern +import kotlinx.datetime.toInstant +import kotlinx.datetime.todayIn + +@OptIn(FormatStringsInDatetimeFormats::class) +object JsLocalDateTimeFormatter : LocalDateTimeFormatter { + override fun parseStringToLocalDate( + str: String, + pattern: String, + ): LocalDate { + val dateFormat = LocalDate.Format { byUnicodePattern(pattern) } + val localDate = LocalDate.parse(str, dateFormat) + + // Validate year has exactly 4 digits + if (localDate.year.length() < 4) { + throw IllegalArgumentException("Year has less than 4 digits.") + } + // date/localDate with year more than 4 digits + if (localDate.year.length() > 4) { + throw IllegalArgumentException("Year has more than 4 digits.") + } + return localDate + } + + override fun format(localDate: LocalDate, pattern: String?): String { + val format = + if (!pattern.isNullOrEmpty()) { + LocalDate.Format { byUnicodePattern(pattern) } + } else { + LocalDate.Formats.ISO // Default to ISO format: yyyy-MM-dd + } + + return format.format(localDate) + } + + override val localDateShortFormatPattern: String + get() = "dd/MM/yyyy" + + @OptIn(ExperimentalTime::class) + override fun localizedTimeString(time: LocalTime): String { + val jsDate = + time + .atDate(Clock.System.todayIn(TimeZone.currentSystemDefault())) + .toInstant(TimeZone.currentSystemDefault()) + .toJSDate() + return jsDate.toLocaleTimeString() + } +} + +@Composable +actual fun getLocalDateTimeFormatter(): LocalDateTimeFormatter { + return remember { JsLocalDateTimeFormatter } +} diff --git a/datacapture/src/jsMain/kotlin/dev/ohs/fhir/datacapture/NumberFormatter.js.kt b/datacapture/src/jsMain/kotlin/dev/ohs/fhir/datacapture/NumberFormatter.js.kt new file mode 100644 index 000000000..8b95130c5 --- /dev/null +++ b/datacapture/src/jsMain/kotlin/dev/ohs/fhir/datacapture/NumberFormatter.js.kt @@ -0,0 +1,23 @@ +/* + * Copyright 2025-2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.ohs.fhir.datacapture + +actual object NumberFormatter { + actual fun formatInteger(value: Int): String { + return value.toString() + } +} diff --git a/datacapture/src/wasmJsMain/kotlin/dev/ohs/fhir/datacapture/AttachmentMediaHandler.wasmJs.kt b/datacapture/src/wasmJsMain/kotlin/dev/ohs/fhir/datacapture/AttachmentMediaHandler.wasmJs.kt new file mode 100644 index 000000000..e6a609070 --- /dev/null +++ b/datacapture/src/wasmJsMain/kotlin/dev/ohs/fhir/datacapture/AttachmentMediaHandler.wasmJs.kt @@ -0,0 +1,68 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.ohs.fhir.datacapture + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import com.ionspin.kotlin.bignum.decimal.BigDecimal +import io.github.vinceglb.filekit.FileKit +import io.github.vinceglb.filekit.dialogs.FileKitType +import io.github.vinceglb.filekit.dialogs.openFilePicker +import io.github.vinceglb.filekit.mimeType +import io.github.vinceglb.filekit.name +import io.github.vinceglb.filekit.readBytes +import kotlin.coroutines.cancellation.CancellationException + +internal class WasmMediaHandler( + override val maxSupportedFileSizeBytes: BigDecimal, + override val supportedMimeTypes: Array, +) : MediaHandler { + override suspend fun capturePhoto(): MediaCaptureResult { + return MediaCaptureResult.Error("Error: Camera not supported") + } + + override suspend fun selectFile(inputMimeTypes: Array): MediaCaptureResult { + val pickedFile = + FileKit.openFilePicker( + type = + FileKitType.File( + inputMimeTypes.toSet().takeIf { it.isNotEmpty() }, + ), + ) + + return pickedFile?.let { + captureResult( + it.readBytes(), + mimeType = it.mimeType()?.toString() ?: "application/octet-stream", + titleName = it.name, + ) + } + ?: throw CancellationException() + } + + override fun isCameraSupported(): Boolean = false +} + +@Composable +internal actual fun rememberMediaHandler( + maxSupportedFileSizeBytes: BigDecimal, + supportedMimeTypes: Array, +): MediaHandler { + return remember(maxSupportedFileSizeBytes, supportedMimeTypes) { + WasmMediaHandler(maxSupportedFileSizeBytes, supportedMimeTypes) + } +} diff --git a/datacapture/src/wasmJsMain/kotlin/dev/ohs/fhir/datacapture/DataCapture.wasmJs.kt b/datacapture/src/wasmJsMain/kotlin/dev/ohs/fhir/datacapture/DataCapture.wasmJs.kt new file mode 100644 index 000000000..2cdbaf80e --- /dev/null +++ b/datacapture/src/wasmJsMain/kotlin/dev/ohs/fhir/datacapture/DataCapture.wasmJs.kt @@ -0,0 +1,23 @@ +/* + * Copyright 2025-2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.ohs.fhir.datacapture + +actual object DataCapture { + actual fun getConfiguration(): DataCaptureConfig { + return DataCaptureConfig() + } +} diff --git a/datacapture/src/wasmJsMain/kotlin/dev/ohs/fhir/datacapture/LocalDateTimeFormatter.wasmJs.kt b/datacapture/src/wasmJsMain/kotlin/dev/ohs/fhir/datacapture/LocalDateTimeFormatter.wasmJs.kt new file mode 100644 index 000000000..cbdd6fcbe --- /dev/null +++ b/datacapture/src/wasmJsMain/kotlin/dev/ohs/fhir/datacapture/LocalDateTimeFormatter.wasmJs.kt @@ -0,0 +1,85 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.ohs.fhir.datacapture + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import dev.ohs.fhir.datacapture.extensions.length +import kotlin.time.Clock +import kotlin.time.ExperimentalTime +import kotlinx.datetime.LocalDate +import kotlinx.datetime.LocalTime +import kotlinx.datetime.TimeZone +import kotlinx.datetime.atDate +import kotlinx.datetime.format +import kotlinx.datetime.format.FormatStringsInDatetimeFormats +import kotlinx.datetime.format.byUnicodePattern +import kotlinx.datetime.toInstant +import kotlinx.datetime.toLocalDateTime +import kotlinx.datetime.todayIn + +@OptIn(FormatStringsInDatetimeFormats::class) +object WasmJsLocalDateTimeFormatter : LocalDateTimeFormatter { + override fun parseStringToLocalDate( + str: String, + pattern: String, + ): LocalDate { + val dateFormat = LocalDate.Format { byUnicodePattern(pattern) } + val localDate = LocalDate.parse(str, dateFormat) + + // Validate year has exactly 4 digits + if (localDate.year.length() < 4) { + throw IllegalArgumentException("Year has less than 4 digits.") + } + // date/localDate with year more than 4 digits + if (localDate.year.length() > 4) { + throw IllegalArgumentException("Year has more than 4 digits.") + } + return localDate + } + + override fun format(localDate: LocalDate, pattern: String?): String { + val format = + if (!pattern.isNullOrEmpty()) { + LocalDate.Format { byUnicodePattern(pattern) } + } else { + LocalDate.Formats.ISO + } + + return format.format(localDate) + } + + override val localDateShortFormatPattern: String + get() = "dd/MM/yyyy" + + @OptIn(ExperimentalTime::class) + override fun localizedTimeString(time: LocalTime): String { + val dateTime = + time + .atDate(Clock.System.todayIn(TimeZone.currentSystemDefault())) + .toInstant(TimeZone.currentSystemDefault()) + .toLocalDateTime( + TimeZone.currentSystemDefault(), + ) + return dateTime.time.format(LocalTime.Formats.ISO) + } +} + +@Composable +actual fun getLocalDateTimeFormatter(): LocalDateTimeFormatter { + return remember { WasmJsLocalDateTimeFormatter } +} diff --git a/datacapture/src/wasmJsMain/kotlin/dev/ohs/fhir/datacapture/NumberFormatter.wasmJs.kt b/datacapture/src/wasmJsMain/kotlin/dev/ohs/fhir/datacapture/NumberFormatter.wasmJs.kt new file mode 100644 index 000000000..8b95130c5 --- /dev/null +++ b/datacapture/src/wasmJsMain/kotlin/dev/ohs/fhir/datacapture/NumberFormatter.wasmJs.kt @@ -0,0 +1,23 @@ +/* + * Copyright 2025-2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.ohs.fhir.datacapture + +actual object NumberFormatter { + actual fun formatInteger(value: Int): String { + return value.toString() + } +} diff --git a/docs/community.md b/docs/community.md new file mode 100644 index 000000000..daf554d5f --- /dev/null +++ b/docs/community.md @@ -0,0 +1,44 @@ +# Developer Community + +The development of the Android FHIR SDK began as a collaborative effort between +Google, [The World Health Organization](https://www.who.int/), and [Ona](https://ona.io/). Since +then, a consortium of app developers have been contributing to the project. + +## Developers Call + +We host a fortnightly Android FHIR SDK Developers Call every other Thursday. + +> OHS Developers Call: Android FHIR SDK +> Every other Thursday · 10:00 – 11:00 +> Time zone: Europe/London +> Google Meet joining info +> Video call link: https://meet.google.com/woo-hecp-ufr +> Or dial: (GB) +44 20 3956 2102 PIN: 937 593 189# +> More phone numbers: https://tel.meet/woo-hecp-ufr?pin=8022014824840 + +Subscribe to +the [OHS Developer Community Calendar](https://calendar.google.com/calendar/u/0?cid=Y19lMjAzMzkyMjY5MDFjYzM1MGE1YTVkNmEyODhkOTgxNDc0MTZlYzk0MzViNGU2OTAyMTgwNTMwM2QxNDgzNmEzQGdyb3VwLmNhbGVuZGFyLmdvb2dsZS5jb20) +to see event details and other OHS events. + +Ask to join the [OHS Developers Google Group](mailto:ohs-developers-external@google.com) to receive +the Google Calendar invitation and the latest updates. + +## Raise an issue + +Create a [GitHub issue](https://github.com/google/android-fhir/issues) for bugs and feature +requests. + +In case you find any security bug, do NOT create a GitHub issue. Instead, email us +at . + +## Start a discussion + +Start a [GitHub discussion](https://github.com/google/android-fhir/discussions) for discussions +directly related to the Android FHIR SDK. +Use [android](https://chat.fhir.org/#narrow/stream/276344-android), [questionnaire](https://chat.fhir.org/#narrow/stream/179255-questionnaire), [implementers](https://chat.fhir.org/#narrow/stream/179166-implementers), +or [WHO SMART guidelines](https://chat.fhir.org/#narrow/stream/310477-who-smart-guidelines) stream +in the [FHIR Zulip chat](https://chat.fhir.org/) for more general FHIR-related discussions. + +## Contact Us + +Email to contact the team directly. diff --git a/docs/contrib/codespaces.md b/docs/contrib/codespaces.md new file mode 100644 index 000000000..f4c6f4276 --- /dev/null +++ b/docs/contrib/codespaces.md @@ -0,0 +1,20 @@ +# Dev Containers for GitHub Codespaces + +This project includes (initial) support for +[GitHub Codespaces](https://docs.github.com/en/codespaces/overview), based on +[Development Containers](https://containers.dev) technology. + +**Status:** This is currently primarily used as a ready-made environment +for documentation writing for this project. The Gradle build also works. +However support for Kotlin development for Android is TBD. +Your contributions to make this better are very welcome. + +## Usage + +[![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/google/android-fhir?quickstart=1) + +If you are missing your fancy custom Shell configuration that you have built over the last 100 years, +you should [set up your dotfiles for Codespaces](https://docs.github.com/en/codespaces/setting-your-user-preferences/personalizing-github-codespaces-for-your-account#dotfiles) +such as [e.g. this dude did in his dotfiles](https://github.com/vorburger/vorburger-dotfiles-bin-etc#github-codespaces). + +Gradle may fail with an _out-of-memory_ related error on its very first run; just re-run it once, and it should work in the Codespace. diff --git a/docs/contrib/db.md b/docs/contrib/db.md new file mode 100644 index 000000000..32a71f1b7 --- /dev/null +++ b/docs/contrib/db.md @@ -0,0 +1,17 @@ +# Database + +## Migrations + +If you are making changes to the database schema (in the `engine` or the `knowledge` module), you +need to consider how applications with Android FHIR SDK dependencies can upgrade to the new schema +without losing or corrupting existing data already on device. This can be done with [Room database +migration](https://developer.android.com/training/data-storage/room/migrating-db-versions). + +!!! tip + A new JSON schema file will be generated under the `schemas` folder in the module when you + update the database version. If you are having trouble with this, make sure you run the gradle + command with `--rerun-tasks`: + + ```sh + ./gradlew ::build --rerun-tasks + ``` diff --git a/docs/contrib/docs.md b/docs/contrib/docs.md new file mode 100644 index 000000000..778f3d027 --- /dev/null +++ b/docs/contrib/docs.md @@ -0,0 +1,37 @@ +# Docs + +Developer Documentation for both users of and contributors to this project is maintained +alongside its code in [the `docs/` folder its git repo](https://github.com/google/android-fhir/tree/master/docs). + +The website is automatically generated from this with Continous Integration. + +## Contribute + +Contributions to the documentation are very welcome, and should typically be made together with code contributions. + +You can edit the `docs/**.md` files with any editor you like and include such changes with your regular Pull Requests. + +New MD files needs to be added to `nav:` in [`mkdocs.yaml`](https://github.com/google/android-fhir/blob/master/mkdocs.yaml). + +To generate the website, for simplicity we recommend using [GitHub Codespaces](codespaces.md); in a Terminal of such a Codespace: + +1. `pipenv shell`: This enters a [Python "virtual environment" (`venv`)](https://docs.python.org/3/library/venv.html), using [`pipenv`](https://pipenv.pypa.io/) + +1. `pipenv install`: This install the required tools listed in the [`Pipfile`](https://github.com/google/android-fhir/blob/master/Pipfile) + +1. `mkdocs serve` + +1. Confirm Codespace's _"Port Forward"_ prompt to open `https://...app.github.dev`, et voilà! + +## Implementation Details + +The website is built using [Material](https://squidfunk.github.io/mkdocs-material/) for [MkDocs](https://www.mkdocs.org/). + +The GitHub Action [`Build`](https://github.com/google/android-fhir/actions/workflows/build.yml) (defined in [`build.yml`](https://github.com/google/android-fhir/blob/master/.github/workflows/build.yml)) runs the [`build-docs.bash`](https://github.com/google/android-fhir/blob/master/build-docs.bash) script to generate the HTML in the `site/` directory. +That `site/` directory is created dynamically during the build process but never commited into the Git repository since it is included in the `.gitignore` file. + +The docs related build steps run for every PR, to help detect broken doc before merge. However, these runs do not update +the live website, which only happens for builds of the `master` branch (notice `on: push: branches: [master]` and `if: ${{ github.event_name == 'push' }}` in [`build.yml`](https://github.com/google/android-fhir/blob/master/.github/workflows/build.yml).) + +The publication works by using the [`upload-pages-artifact`](https://github.com/actions/upload-pages-artifact) action to package the generated HTML from the `site/` directory, +and uploading this artifact to a staging area on GitHub, and the [`deploy-pages`](https://github.com/actions/deploy-pages) action to actually publish that artifact to the live website. diff --git a/docs/contrib/git.md b/docs/contrib/git.md new file mode 100644 index 000000000..cabd02a50 --- /dev/null +++ b/docs/contrib/git.md @@ -0,0 +1,94 @@ +# Git Tips + +This page documents [Git](https://git-scm.com) tips for contributors to this project. + +This project generally follows [these guidelines](https://github.com/google/fhir-data-pipes/blob/master/doc/review_process.md) from our `fhir-data-pipes` sister project. + +## Overview + +We use the following workflow: + +1. Contributors can open "draft" PRs for any Work In Progress (WIP) which does not require review yet. + It's OK if the checks do not pass yet. + +1. When a PR is ready for code review, the contributor marks the PR as "Ready for review". For a smooth review, the PR must pass all the checks. + At this point, the PR should only have 1 single commit; please "squash" (or use `--amend`) your "local history", + before pushing to a branch to open a PR (or when your WIP Draft PR is ready for review). The commit message + of this initial commit should explain what this PR is all about. + +1. Maintainers, or other Contributors, will now review the PR. They may add comments requesting changes. + +1. When contributors update PRs to make changes requested by reviewers, those should be added as + additional new single commits per round of review, + typically with a generic message such as _"Incorporated review feedback."_ + Do NOT squash (or amend) these review updates into the original commit. + +1. Maintainers (with write acces) _squash_ all commits of PRs into a single commit when merging. + +## Usage + +### Edit on GitHub Web UI + +To make simple single file changes, notably e.g. to `docs/*.md` Markdown files, +it can be convenient to simply click the _"Pencil" button_ on GitHub's Web UI +to _Edit in place._ For a more fully fledged IDE for docs, see [Docs](docs.md). + +### Locally with Git CLI + +Using the standard `git` CLI client, you would typically do the following: + +1. Initially, one time only, click Fork on https://github.com/google/android-fhir/tree/master and: + + git clone git@github.com:google/android-fhir.git + cd android-fhir + git remote add YOUR-UID git@github.com:YOUR-UID/android-fhir.git + +2. Now every time you want to propose a change, you do: + + git checkout -b NEW-BRANCH-NAME + ... do some work ... + git commit -a -m "MESSAGE ABOUT WHAT YOU CHANGED" + git push YOUR-UID + + This will print something with a URL you can click on to create a Pull Request. + +3. To _"rebase"_ your local branch so that it has the latest upstream work: + + git checkout master + git pull + git checkout THAT-BRANCH-NAME + git rebase master + +4. When you get code review feedback and would like to make some changes: + + git checkout THAT-BRANCH-NAME + ... do some work ... + git commit -a -m "Incorporated review feedback." + git push --force-with-lease YOUR-UID + +Note that, as per [these guidelines](https://github.com/google/fhir-data-pipes/blob/master/doc/review_process.md) we do not `commit --amend`. (Maintainers will _"squash"_ when merging PRs.) + +### Locally with GitHub's CLI + +https://cli.github.com is a GitHub specific tool which allows you to do certain +operations from the CLI instead of having to click on the GitHub Web UI, e.g. : + + gh repo fork google/android-fhir --clone --remote + + gh pr create + + gh pr checkout 2306 + +https://github.com/topics/gh-extension has a lot of fancy extensions for `gh`. + +`gh` is also useful to manage GitHub Secrets. + +## Processes + +## Further Resources + +* https://git-scm.com/doc +* https://en.wikipedia.org/wiki/Git +* https://developer.mozilla.org/en-US/docs/Learn/Tools_and_testing/GitHub +* https://docs.github.com/en/get-started/quickstart/hello-world +* https://www.w3schools.com/git/git_intro.asp diff --git a/docs/contrib/libs.md b/docs/contrib/libs.md new file mode 100644 index 000000000..a9beb0cef --- /dev/null +++ b/docs/contrib/libs.md @@ -0,0 +1,36 @@ +# Libraries + +## External Dependencies + +This Android FHIR SDK uses a number of external libraries. + +Their exact version numbers are shown on [the Dependency Graph Insights](https://github.com/google/android-fhir/network/dependencies), which is automatically updated. + +## Version Upgrades + +This section defines the process one goes through when making changes to any of the dependent libraries on the FHIR SDK. An example of a library is the [Workflow Library](../use/WFL/index.md). + +**Step 1:** + +* Make changes, updates, edits on the library's codebase. +* Make sure to update the [Releases.kt](https://github.com/google/android-fhir/blob/master/buildSrc/src/main/kotlin/Releases.kt) configuration file with a new _Version/Artifact ID_ + +**Step 2:** + +* Make a PR (pull request) with the above changes, request a code review + +**Step 3:** + +* When PR is reviewed and merged - request for the artifact to be published on maven + +**Step 4:** + +* Update your/any dependent PR (PR using the library) with the new _Artifact ID_ and make/trigger the CI + +## Common Library + +The _common_ library module contains code that is shared across other modules e.g. _engine, datacapture_ . During development you might want to make updates to the _common_ module and test them on the dependant library. + +To make developing/testing these type of updates more efficient, it is recommended to replace the `implementation(Dependencies.androidFhirCommon)` dependency configuration with `implementation(project(":common"))` in the `build.gradle.kts` file. Then once the PR with the changes to _common_ is merged in and the artifact published, you can revert. + +Remember to update the `Versions.androidFhirCommon` variable with the correct/new version of the published artifact in the `Dependencies.kt` file. diff --git a/docs/contrib/prereqs.md b/docs/contrib/prereqs.md new file mode 100644 index 000000000..885d3a802 --- /dev/null +++ b/docs/contrib/prereqs.md @@ -0,0 +1,9 @@ +# Prerequisites + +The following software is recommended for contributing to this project: + +* Java 17 +* Android Studio Koala | 2024.1.1+ +* Node.js + * Install e.g. [via package manager](https://nodejs.org/en/download/package-manager/) + * Needed for the `prettier` plugin we use to format `XML` files diff --git a/docs/contrib/roadmap.md b/docs/contrib/roadmap.md new file mode 100644 index 000000000..627c4c90c --- /dev/null +++ b/docs/contrib/roadmap.md @@ -0,0 +1,36 @@ +# Roadmap + +The [product roadmap board](https://github.com/google/android-fhir/projects/7) is where you can learn about what features are being working on, what stage they're in, and release timelines. Have any questions or comments about items on the roadmap or want to know how to get involved? Please use the [discussions section](https://github.com/google/android-fhir/discussions), or email us at . + +## Guide to the roadmap + +Every item pre-fixed with [Roadmap] relates to one or more issues on individual feature area project boards, with a label that indicates each of the following: + +* A **release phase** that describes the next expected phase of the roadmap item. See below for a guide to release phases. +* A **feature area** that indicates the specific library to which the item belongs. For a list of feature areas, see below. + +## Release phases + +Release phases indicate the stages that the product or feature goes through, from early testing to general availability. + +* **alpha**: Primarily for testing and feedback: Features still under heavy development, and subject to change. Not for production use, and no documentation provided. +* **beta**: Available in full or limited capacity: Features mostly complete and documented. +* **ga**: Generally available: Confidence within that community that this is ready for production use. Implementer able to provide support or leverage open source community + +Some features will be in exploratory stages, and have no timeframe available. These are included in the roadmap for early feedback. These are marked as follows: + +* **in design**: Feature in discovery phase. We have decided to build this feature, but are still figuring out how. +* **exploring**: Feature under consideration. We are considering building this feature, and gathering feedback on it or doing R&D. + +## Roadmap stages + +The roadmap is arranged on a [project board](https://github.com/google/android-fhir/projects/7) to give a sense for how far out each item is on the horizon (broken down by quarters). **This is just indicative at this stage and is subject to change**, especially further out on the timeline. There is also a specific column for exploratory R&D items where no current timeline has been decided. + +## Feature areas + +The SDK is designed in a modular fashion to enable developers to layer in a rich set of features for building FHIR capable mobile solutions. The feature areas, which are packaged as separate libraries are defined as follows: + +* **FHIR Engine:** includes APIs for managing FHIR resources (using FHIR protos), interacting with the storage layer (sqllite db), searching and syncing +* **Data Capture Library:** Implementation of the FHIR SDC specification for interacting with Questionnaires includes population and extraction +* **Workflow Library:** Provides specific operations for generating CarePlans and indicators relevant to implementers working with [WHO SMART Guidelines content](https://www.who.int/news/item/18-02-2021-from-paper-to-digital-pathway-who-launches-first-smart-guidelines) + diff --git a/docs/contrib/style.md b/docs/contrib/style.md new file mode 100644 index 000000000..998122e37 --- /dev/null +++ b/docs/contrib/style.md @@ -0,0 +1,43 @@ +# Code Style + +## Spotless + +We use Spotless to maintain the Java/Kotlin coding style in the codebase. Run the following command to check the codebase: + +```sh +./gradlew spotlessCheck +``` + +and run the following command to apply fixes to the violations: + +```sh +./gradlew spotlessApply +``` + +## License Headers + +Spotless maintains the license headers for Kotlin files. Use addlicense to maintain license headers in other files: + +```sh +addlicense -c "Google LLC" -l apache . +``` + +## Kotlin style + +The codebase follows [google-java-format](https://github.com/google/google-java-format) instead of the [Kotlin coding conventions](https://kotlinlang.org/docs/coding-conventions.html) because google-java-format is strict and deterministic, and therefore removes formatting as a concern for developers altogether. + +If you would like Android Studio to help format your code, follow these steps to set up your Android Studio: + +1. Install and configure the [ktfmt plugin](https://github.com/facebookincubator/ktfmt) in Android Studio by following these steps: + 1. Go to Android Studio's `Settings` (or `Preferences`), select the `Plugins` category, click the `Marketplace` tab, search for the `ktfmt` plugin, and click the `Install` button + 1. In Android Studio's `Settings` (or `Preferences`), go to `Editor` → `ktfmt Settings`, tick `Enable ktfmt`, change the `Code style` to `Google (Internal)`, and click `OK` +1. Indent 2 spaces. In Android Studio's `Settings` (or `Preferences`), go to `Editor` → `Code Style` → `Kotlin` → `Tabs and Indents`, set `Tab size`, `Indent` and `Continuation indent` to `2`, and click `OK`. +1. Use single name import sorted lexigraphically. In Android Studio's `Settings` (or `Preferences`), go to `Editor` → `Code Style` → `Kotlin` → `Imports`, in `Top-level Symbols` and `Java statics and Enum Members` sections select `Use single name import` option, remove all the rules in `Packages to Use Imports with '*'` and `Import Layout` sections and click `OK`. + +Now you can go to `Code` → `Reformat code`, or press `Ctrl+Alt+L` (`⌘+⌥+L` for Mac) to automatically format code in Android Studio. + +Note that you don't have to do any of these. You could rely on spotless to format any code you want to push. For details see below. + +## XML style + +We use [prettier](https://prettier.io/)'s [XML plugin](https://github.com/prettier/plugin-xml) to format the XML code. At the moment we have not discovered an Android Studio style configuration that would produce the same result. As a result, please run `./gradlew spotlessApply` to format the XML files. diff --git a/docs/contrib/test.md b/docs/contrib/test.md new file mode 100644 index 000000000..b7d0e4d3d --- /dev/null +++ b/docs/contrib/test.md @@ -0,0 +1,200 @@ +# Testing + +## Unit Tests + +### Naming Conventions + +We follow the following naming convention for our test cases: + +``` +methodName_conditionUnderTest_expectedBehavior +``` + +For example: + +``` +isNumberEven_one_shouldReturnFalse +isNumberEven_two_shouldReturnTrue +``` + +## Instrumentation Tests + +### UI Automation Testing + +**For UI automation, “Page object framework” is used, Below is the structure of framework:** + +**Structure:** + +**Pages:** +Here all screens objects ,identifiers and functions are defined + +**Tests:** +All tests for all screens are written here + +**Testdata:** +Data which is used for testing mentioned here + +**Setup QA tests:** + +Precondition: + +1. To launch App and its activity create file under tests folder(Right click pages->New -> Kotlin class/File(BaseTest): + + ```kotlin + open class BaseTest { + @get:Rule + val activityRule: ActivityScenarioRule = + ActivityScenarioRule(MainActivity::class.java) + } + ``` + +Write testcase: + +1. Right click pages->New -> Kotlin class/File(AddPatientPage.kt) +2. On newly created file, objects ,identifiers and functions can be defined +3. Define object and identifiers as below: + + ```kotlin + private val pageName = "Add Patient" + private val addPatientButton = R.id.add_patient + ``` + +4. To write functions: + + ```kotlin + fun validate_page() { + + } + ``` + +5. To write verification of text + + ```kotlin + onView(allOf(withText(R.string.add_patient), withText(pageName))).check(matches(isDisplayed())) + ``` + +6. To create Tests page for writing tests same as mentioned in step 1 (AddPatientTest.kt) +7. To access function from (AddPatientPage.kt), create its object: + + ```kotlin + class AddPatientTest() : BaseTest() { + private val addPatientPage: AddPatientPage = AddPatientPage() + } + ``` + +8. Access function through object: + + ```kotlin + fun shouldBeAbleToValidatePage() { + addPatientPage.validate_page() + } + ``` + +9. To run tests: + +precondition: +device or emulator should be connected + +Right click on test file created in step 6 -> Run 'AddPatientTest' + +### Screenshot Testing + +**To write Screenshot Tests**: + +1. To Launch Activity: + + ```kotlin + @get:Rule var activityTestRule = ActivityTestRule(MainActivity::class.java, false, false) + + val grantPermissionRule: GrantPermissionRule = + GrantPermissionRule.grant( + android.Manifest.permission.READ_EXTERNAL_STORAGE, + android.Manifest.permission.WRITE_EXTERNAL_STORAGE + ) + ``` + +2. To write functions: + + ```kotlin + fun testScreenshotEntireActivity() { + + } + ``` + +3. Launch activity through "activityrule": + + ```kotlin + val activity = activityTestRule.launchActivity(null)) + ``` + +4. get view of element: + + ```kotlin + val view = activityTestRule.activity.findViewById(R.id.search_src_text) + ``` + +5. Record view through snapactivity: + + ```kotlin + snapActivity(activity).setName("Registered Patient List").record() + ``` + +6. You can set name to the view and record that view: + + ```kotlin + snap(view).setName("sample_view_test").record() + ``` + +**To Run Screenshot Tasks below:** + +`cleanScreenshots` - Clean last generated screenshot report + +`pullScreenshots` - Pull screenshots from your device + +`recordScreenshotTest` - Installs and runs screenshot tests, then records their output for later verification + +`runScreenshotTest` - Installs and runs screenshot tests, then generates a report + +`verifyScreenshotTest` - Installs and runs screenshot tests, then verifies their output against previously recorded screenshots + +**To run through ./gradlew below is the command:** + +`$ ./gradlew runDebugAndroidTestScreenshotTest` + +To run screenshot test for specific application: + +`$ ./gradlew Demo:runDebugAndroidTestScreenshotTest` + +**To directory run screenshot test using android studio:** + + Right click either the test folder or the test file in Android Studio and click 'Run Tests in ...': + +## Jacoco Test Report + +### Run locally + +To run the task locally: + +```sh +$./gradlew jacocoTestReport +``` + +To run the task locally for a specific module: + +```sh +$./gradlew ::jacocoTestReport +``` + +The Jacoco test coverage report will be in the folder `/build/reports/jacoco/jacocoTestReport`. + +## Ruler + +We use [Ruler](https://github.com/spotify/ruler) to generate reports on the APK size of our demo/catalog app. This allows us to track increases in our SDK's library size. + +To generate these reports, run the `analyzeReleaseBundle` task on the project you are interested in. For example: + +```sh +./gradlew :demo:analyzeReleaseBundle +``` + +The task will print a path to an HTML report, which is human-readable, and a JSON report, which can be used for automation. diff --git a/docs/contrib/troubleshoot.md b/docs/contrib/troubleshoot.md new file mode 100644 index 000000000..90f1f921c --- /dev/null +++ b/docs/contrib/troubleshoot.md @@ -0,0 +1,17 @@ +# Troubleshooting + +## Common Build Problems + +1. _Build failed due to Java heap space memory:_ + + Set `org.gradle.jvmargs=-Xmx2048m` in a `gradle.properties`. + + If it still fails, you can further increase the memory. + +2. _More than one file was found with OS independent path `mozilla/public-suffix-list.txt`:_ + + Add this line to the packagingOptions in the build.gradle of you app: + + `packagingOptions { + exclude 'mozilla/public-suffix-list.txt' + }` diff --git a/docs/faq.md b/docs/faq.md new file mode 100644 index 000000000..cfc1bb3fa --- /dev/null +++ b/docs/faq.md @@ -0,0 +1,93 @@ +# FAQs + +1. [What is the Android FHIR SDK?](#what-is-the-android-fhir-sdk) +1. [What is FHIR?](#what-is-fhir) +1. [Why is a FHIR SDK needed? What problem does it solve?](#why-is-a-fhir-sdk-needed-what-problem-does-it-solve) +1. [What are the components of the FHIR SDK?](#what-are-the-components-of-the-fhir-sdk) +1. [What is Google’s role in this project?](#what-is-googles-role-in-this-project) +1. [Who else is involved?](#who-else-is-involved) +1. [What is in/out of scope of the FHIR SDK?](#what-is-inout-of-scope-of-the-fhir-sdk) +1. [Do I have control of the data collected using the SDK?](#do-i-have-control-of-the-data-collected-using-the-sdk) +1. [How is data encrypted?](#how-is-data-encrypted) +1. [How can I build cross-platform applications?](#how-can-i-build-cross-platform-applications) +1. [What applications currently use the SDK?](#what-applications-currently-use-the-sdk) +1. [How to do database inspect in the FHIR demo app?](#how-to-do-database-inspect-in-the-fhir-demo-app) + +### What is the Android FHIR SDK? + +It is an open source library for Android developers who want to build [FHIR](http://hl7.org/fhir/)-enabled offline-capable, mobile-first healthcare applications. + +### What is FHIR? + +FHIR (Fast Healthcare Interoperability Resources) is the latest version of the HL7 standard for building modern patient centered healthcare applications - see [HL7 FHIR Specification](http://hl7.org/fhir/) + +### Why is a FHIR SDK needed? What problem does it solve? + +Today there is no simple way to build a FHIR compliant app on Android that is suited to the needs of LMICs - particularly offline capabilities due to poor connectivity. The FHIR SDK makes it easy for developers to build custom applications without needing to also build and maintain all the underlying components. + +### What are the components of the FHIR SDK? + +The SDK enables the common components that developers need to build rich, offline-capable, standard-compliant applications. This includes APIs for standard data capture, data access, search and sync as well as encrypted local storage (using SQLite DB). + +### What is Google’s role in this project? + +We are contributing to the design and development of the open source FHIR SDK + +### Who else is involved? + +The Android FHIR SDK project was initiated by the WHO together with teams from Android and [ONA](https://ona.io/home/). There is an open consortium of groups convened by the WHO to progress the design and development of the FHIR SDK as part of supporting their [SMART Guidelines work](https://www.who.int/teams/digital-health-and-innovation/smart-guidelineshttp:// "SMART Guidelines work"). To-date developers from ONA, IPRD and Argusoft as well as a number of independent software engineers have contributed to the code-base. It is an open source initiative and we welcome contributions from any developers who want to get involved. + +### What is in/out of scope of the FHIR SDK? + +In the SDK: + +* Implementation of the FHIR specification relevant for mobile health use cases +* Helper methods for common implementation scenarios that are implementer agnostic +* Encryption of data within the SQLite DB at rest (using standard Android encryption) + +Not in the SDK: + +* Custom implementation requirements: these should be in implementer specific libraries +* Creation of an Authenticated client: assume server side implementation with client side tokens. +* Authorization: rules around what resources a specific user should have access to are applied server side. + +### Do I have control of the data collected using the SDK? + +The Android FHIR SDK stores data on device in a SQLite database and can be configured to sync the data with a FHIR server specified by the application. The choice of storage (on-prem or cloud based) is up to the individual implementation. + +### How is data encrypted? + +Data in the SQLite database is **encrypted at rest** using standard Android device encryption (minimum support for Android 5.0). For devices using Android 6.0 or above, additional level of application based encryption is provided by SQLCipher integration. [Read more](use/FEL/Privacy-Security.md#database-encryption) + +There is no limitation on supporting **encryption in transit** and this is recommended best practice for all implementers to put in place when syncing data between the Android client and a FHIR server. + +### How can I build cross-platform applications? + +The Android FHIR SDK is designed for the Android OS. There are currently no plans to support iOS. + +### What applications currently use the SDK? + +We have an active community of developers that are in the process of building applications using the FHIR SDK. + +### How to do database inspect in the FHIR demo app? + +The data inside FHIR database are encrypted for safety reason. This is controlled by the flag `enableEncryptionIfSupported` flag in the `FhirEngineConfiguration`. To debug/inspect the database content(for example, in the demo app), developer can temporarily disable the encryption as following: +In [FhirApplication](https://github.com/google/android-fhir/blob/master/demo/src/main/java/com/google/android/fhir/demo/FhirApplication.kt), when initiate the `FhirEngineProvider`, set `enableEncryptionIfSupported` to false. Code example: + +```kotlin +class FhirApplication : Application() { + // Only initiate the FhirEngine when used for the first time, not when the app is created. + private val fhirEngine: FhirEngine by lazy { constructFhirEngine() } + + override fun onCreate() { + super.onCreate() + FhirEngineProvider.init( + FhirEngineConfiguration(enableEncryptionIfSupported = false, RECREATE_AT_OPEN) + ) + Sync.oneTimeSync(this) + } +``` + +In AndroidStudio, run the `demo` app on a connected android device. Then go to View -> Tool Windows -> App Inspection, click on the Database Inspector tab to inspect the database `resources.db`. + +One thing to note: If there is any database exception after disabling encryption, developers can wipe the demo app data either in Settings or via `adb shell pm clear com.google.android.fhir.demo`, and rerun the demo app. diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 000000000..0f8d67740 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,39 @@ +# Welcome to Android FHIR SDK Technical Documentation + +The Android FHIR SDK is a set of Kotlin libraries for building offline-capable, mobile-first +healthcare applications using the [HL7® FHIR® standard](https://www.hl7.org/fhir/) on Android. It +aims to accelerate the adoption of FHIR by making it easy to incorporate FHIR into new and existing +mobile applications. + +Use the following table for documentation and resources to get started with each library in the SDK. + +| Library | Latest release | Code | Docs | Codelab | Summary | +| -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------- | ----------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- | - | +| Data Capture Library | [![Google Maven](https://badgen.net/maven/v/metadata-url/dl.google.com/dl/android/maven2/com/google/android/fhir/data-capture/maven-metadata.xml)](https://maven.google.com/web/index.html?#com.google.android.fhir:data-capture) | [code](https://github.com/google/android-fhir/tree/master/datacapture)| [docs](use/SDCL/index.md) | [codelab](https://github.com/google/android-fhir/tree/master/codelabs/datacapture) | Collects, validates, and processes healthcare data on Android | +| FHIR Engine Library | [![Google Maven](https://badgen.net/maven/v/metadata-url/dl.google.com/dl/android/maven2/com/google/android/fhir/engine/maven-metadata.xml)](https://maven.google.com/web/index.html?#com.google.android.fhir:engine) | [code](https://github.com/google/android-fhir/tree/master/engine) | [docs](use/FEL/index.md) | [codelab](https://github.com/google/android-fhir/tree/master/codelabs/engine) | Stores and manages FHIR resources locally on Android and synchronizes with FHIR server | +| Workflow Library | [![Google Maven](https://badgen.net/maven/v/metadata-url/dl.google.com/dl/android/maven2/com/google/android/fhir/workflow/maven-metadata.xml)](https://maven.google.com/web/index.html?#com.google.android.fhir:workflow) | [code](https://github.com/google/android-fhir/tree/master/workflow) | [docs](use/WFL/index.md) | | Provides decision support and analytics in clinical workflow on Android including implementation of specific FHIR operations ($measure_evaluate and $apply) | +| Knowledge Manager Library | [![Google Maven](https://badgen.net/maven/v/metadata-url/dl.google.com/dl/android/maven2/com/google/android/fhir/knowledge/maven-metadata.xml)](https://maven.google.com/web/index.html?#com.google.android.fhir:knowledge) | [code](https://github.com/google/android-fhir/tree/master/knowledge) | | | Manages knowledge resources locally on Android and supports other libraries with knowledge resources | + +This repository also contains the following demo apps: + +| Demo app | Code | Docs | +| ----------------------------------- | ------------------------------------------------------------------ | ----------------------------------------------------------------- | +| FHIR Engine Demo App | [code](https://github.com/google/android-fhir/tree/master/demo) | [docs](https://google.github.io/android-fhir/use/FEL/Demo-app/) | +| Structured Data Capture Catalog App | [code](https://github.com/google/android-fhir/tree/master/catalog) | [docs](https://google.github.io/android-fhir/use/SDCL/Demo-app/) | + +**These applications are for demo purposes only. Do NOT use in production.** + +Use the sidebar to navigate to other sections of the site. + +## Resources + +FHIR and implementation guides: + +* [HL7 FHIR](https://www.hl7.org/fhir/) +* [Structured Data Capture](http://hl7.org/fhir/us/sdc/) +* [Clinical Quality Lanugage (CQL)](https://cql.hl7.org/) + +More on mobile health landscape: + +* [Digital Health Atlas](https://digitalhealthatlas.org/) +* [Global Goods Guidebook](https://digitalsquare.org/global-goods-guidebook) diff --git a/docs/use/FEL/Benchmarking.md b/docs/use/FEL/Benchmarking.md new file mode 100644 index 000000000..d36799eea --- /dev/null +++ b/docs/use/FEL/Benchmarking.md @@ -0,0 +1,260 @@ +# _FHIR Engine Library_ Benchmarks + +Benchmarks have been added in the _FHIR Engine Library_ to help track performance regressions +and areas for performance improvement. + +The _FHIR Engine Library_ has the following benchmark modules + +1. **app** - configurable android application for testing and running benchmarks +2. **microbenchmark** - [Jetpack Microbenchmark](https://developer.android.com/topic/performance/benchmarking/microbenchmark-overview) module +3. **macrobenchmark** - [Jetpack Macrobenchmark](https://developer.android.com/topic/performance/benchmarking/macrobenchmark-overview) module +to automate testing large-scale user-facing apis with the benchmark app + +## App module + +Located in the module `:engine:benchmarks:app` +The _FHIR Engine Library_ Benchmark app runs benchmarks for the _FHIR Engine Library_ APIs including Data Access API, Search API and Sync API. + +It can be configured to run the benchmarks for different population sizes whereby population refers to the number of Patients and their associated data + +### Configuration + +The benchmark app requires that the dataset that is to be benchmarked on be added in the _assets/bulk_data_ folder as `.ndjson` formatted files; whereby each line refers to a single FHIR resource + +![Path to bulk data](path-bulk-data.png) + +The dataset can be from an external source. Within the repository, there is a script to generate [synthetic data](https://github.com/synthetichealth/synthea/wiki/Getting-Started) that could then be used for benchmarking. + +```shell +./gradlew :engine:benchmarks:app:generateSynthea -Ppopulation=1000 +``` + +It generates [synthetic data](https://github.com/synthetichealth/synthea/wiki/Getting-Started) with a population size of 1000. +The `population` parameter determines the population size that would be used to generate the data + +### Running + +To run this app in Android Studio, [create a run/debug configuration](https://developer.android.com/studio/run/rundebugconfig) for the `:engine:benchmarks:app` module using the [Android App](https://developer.android.com/studio/run/rundebugconfig#android-application) template and run the app using the configuration. + +[Change the build variant](https://developer.android.com/studio/run#changing-variant) to `benchmark` for an optimised version of the app, for the best results + +Alternatively, run the following command to build and install the benchmark APK on your device/emulator: + +```shell +./gradlew :engine:benchmarks:app:installBenchmark +``` + +## Microbenchmark module + +Contains test cases that evaluate the performance of individual tasks executed for the first time directly on hardware, located in the module `:engine:benchmarks:microbenchmark`. + +The test cases are designed to run in sequence of their alphabetic order to make sure larger tasks do not build cache for smaller ones. Their class names are prefixed by an extra letter to inform their position relative to others in the list. + +### Running + +In Android Studio, set your build variants to `release` and run your benchmark as you would any `@Test` using the gutter action next to your test class or method. + +![gutter test action](https://developer.android.com/static/topic/performance/images/benchmark_images/microbenchmark_run.png) + +The results will be similar to this: + +``` +1,297,374 ns 5345 allocs trace EngineDatabaseBenchmark.createAndGet +1,114,474,793 ns 4922289 allocs trace FhirSyncWorkerBenchmark.oneTimeSync_50patients +15,251,125 ns 100542 allocs trace FhirSyncWorkerBenchmark.oneTimeSync_1patient +179,806,709 ns 986017 allocs trace FhirSyncWorkerBenchmark.oneTimeSync_10patients +1,451,758 ns 11883 allocs trace GzipUploadInterceptorBenchmark.upload_10patientsWithGzip +1,537,559 ns 11829 allocs trace GzipUploadInterceptorBenchmark.upload_10patientsWithoutGzip +73,640,833 ns 1074360 allocs trace GzipUploadInterceptorBenchmark.upload_1000patientsWithGzip +7,493,642 ns 108428 allocs trace GzipUploadInterceptorBenchmark.upload_100patientsWithoutGzip +7,799,264 ns 108465 allocs trace GzipUploadInterceptorBenchmark.upload_100patientsWithGzip +71,189,333 ns 1074466 allocs trace GzipUploadInterceptorBenchmark.upload_1000patientsWithoutGzip + +``` + +Alternatively, from the command line, run the connectedCheck to run all of the tests from specified Gradle module: + +```bash +./gradlew :engine:benchmarks:microbenchmark:connectedReleaseAndroidTest +``` + +In this case, results will be saved to the `outputs/androidTest-results/connected//test-result.pb`. To visualize on Android Studio, click Run / Import Tests From File and find the `.pb` file + +### Continuous Integration (CI) + +#### Configuration + +Microbenchmark tests are configured to run in Kokoro and use [Fladle](https://runningcode.github.io/fladle/) plugin, configured through `Project.configureFirebaseTestLabForMicroBenchmark` in file `buildSrc/src/main/kotlin/FirebaseTestLabConfig.kt` + +#### Accessing the benchmark results + +The Microbenchmark results can be accessed through the following steps + +1. Click to `View details` of the `Kokoro: Build and Device Tests` + ![PR Kokoro view details](pr-kokoro-view-details.png) + + The details page would look similar to + ![Kokoro details](kokoro-details-page.png) +2. Within the `Target Log` tab, locate for the section + ![Microbenchmark section](microbenchmark-section.png) + with the `TEST FILE NAME` `microbenchmark-release-androidTest.apk` +3. Select and visit the Google Bucket url that looks as similar to + [https://console.developers.google.com/storage/browser/android-fhir-build-artifacts/prod/openhealthstack/android-fhir/gcp_ubuntu/presubmit/5404/20250618-172425/firebase/microbenchmark](ttps://console.developers.google.com/storage/browser/android-fhir-build-artifacts/prod/openhealthstack/android-fhir/gcp_ubuntu/presubmit/5404/20250618-172425/firebase/microbenchmark) + that navigates to the `android-fhir-build-artifacts` ![bucket](microbenchmark-bucket.png) +4. Navigate to `matrix_0/panther-33-en_US-portrait-test_results_merged.xml` to download the benchmark .xml results file. The `panther-33-en_US-portrait` in the path refers to the Firebase Test Lab device/shard used in running the benchmark tests. + +## Macrobenchmark module + +The _FHIR Engine Library_ macrobenchmark tests are located in the module `:engine:benchmarks:macrobenchmark`. + +### Set Up + +1. Set up the _FHIR Engine Library_ Benchmark App with [the relevant data](#configuration) + +2. Start the local Hapi server + + Execute + + ```shell + sh benchmark-start-server.sh + ``` + + The script uses [Docker](https://docs.docker.com/engine/install/) + to start up a container with the [image](https://hub.docker.com/r/hapiproject/hapi) from Hapi. + + It runs the docker image with the default configuration, mapping port 8080 from the container to port 8080 in the host. + + Once running, you can access in the browser to access the HAPI FHIR server's UI + or use as the base URL for your REST requests +3. Populate the Hapi server with data generated from Synthea + + Execute + + ```shell + sh benchmark-populate-server.sh 100 0.0.0.0:8080 + ``` + + This generates Synthea data for a population of 100 patients and uploads the data to the local Hapi + server started at port 8080 +4. Check that the Android device and the host computer are connected to the same network +5. Get the host machine's domain or LAN IP address + + For mac + + ```shell + hostname + ``` + + For linux + + ```shell + hostname -I | awk '{print $1}' + ``` + +6. Update `local.properties` file, located in the root folder, with the server url + + ```properties + FHIR_SERVER_BASE_URL=http://192.168.0.24:8080/fhir/ + ``` + + replacing `192.168.0.24` with your domain/address +7. Run Gradle sync +8. [Run the Macrobenchmark](https://developer.android.com/topic/performance/benchmarking/macrobenchmark-overview#run-benchmark) tests in the `engine:benchmarks:macrobenchmark` module + + Execute + + ```shell + ./gradlew :engine:benchmarks:macrobenchmark:connectedCheck + ``` + + The JSON results are automatically copied from the device to the host. These are written on the host machine in the location + `engine/benchmarks/macrobenchmark/build/outputs/connected_android_test_additional_output/benchmark/connected/device_id/` + +9. After all the tests are complete, you can stop the benchmark server + + ```shell + sh benchmark-populate-server.sh + ``` + +For reliable benchmarks: + +* Use physical devices: Emulators won’t give you consistent, real-world data. +* Kill all background services: Anything running outside your app adds noise. +* Lock the device state: Keep the brightness, network and battery levels consistent. + +### Continuous Integration (CI) + +#### Configuration + +The `FHIR Engine` Macrobenchmarks have been configured to run in Kokoro and use FirebaseTestLab physical devices + +Configuration for the Kokoro script are currently located in `kokoro/gcp_ubuntu/kokoro_build.sh` while the FirebaseTestLab testing is configured through the [Fladle](https://runningcode.github.io/fladle/) plugin in `buildSrc/src/main/kotlin/FirebaseTestLabConfig.kt` + +#### Accessing the benchmark results + +From a GitHub PR , the following steps could be used to download the benchmark results from a Kokoro run + +1. Click to `View details` of the `Kokoro: Build and Device Tests` + ![PR Kokoro view details](pr-kokoro-view-details.png) + + The details page would look similar to + ![Kokoro details](kokoro-details-page.png) + +2. Within the `Target Log` tab, locate for the section + ![Macrobenchmark section](macrobenchmark-section.png) + with the `TEST FILE NAME` `macrobenchmark-benchmark.apk` + +3. Select and visit the url as referenced in image + ![Reference url](select-bucket-url.png) + representative of the Google Cloud Bucket containing the artifacts from the Kokoro run. From the image example, the url is [https://console.developers.google.com/storage/browser/android-fhir-build-artifacts/prod/openhealthstack/android-fhir/gcp_ubuntu/presubmit/5403/20250616-053647/firebase/macrobenchmark](https://console.developers.google.com/storage/browser/android-fhir-build-artifacts/prod/openhealthstack/android-fhir/gcp_ubuntu/presubmit/5403/20250616-053647/firebase/macrobenchmark) + + The bucket page would look similar to + ![Artifacts bucket page](google-bucket-page.png) + +4. Navigate to `matrix_0/panther-33-en_US-portrait/artifacts/sdcard/Download/com.google.android.fhir.engine.macrobenchmark-benchmarkData.json` to download the benchmark results file. The `panther-33-en_US-portrait` in the path represents the Firebase Test Lab device/shard that was used to run the benchmark tests. + +#### Sample Benchmark Results + +The results shared below are generated from running the _FHIR Engine Library_ Macrobenchmark tests in Kokoro + +##### [**_Panther - Google Pixel 7_**](https://wiki.lineageos.org/devices/panther/) + +**CPU** - Octa-core (2x2.85 GHz Cortex-X1 & 2x2.35 GHz Cortex-A78 & 4x1.80 GHz Cortex-A55) + +**RAM** - 8GB + +API 33 + +###### Data Access API results + +Generated from execution of `FhirEngineCrudBenchmark` test in the `engine:benchmarks:macrobenchmark` module located at `engine/benchmarks/macrobenchmark/src/main/java/com/google/android/fhir/engine/macrobenchmark/FhirEngineCrudBenchmark.kt` + +| API | Average duration (ms) | Notes | +|:-------|----------------------:|---------------------------------------| +| create | ~4.7 | Takes ~47s for population size of 10k | +| update | ~12.29 | | +| get | ~3.83 | | +| delete | ~8.08 | | + +###### Search DSL API + +Generated from the execution of the `FhirEngineSearchApiBenchmark` test in the file `engine/benchmarks/macrobenchmark/src/main/java/com/google/android/fhir/engine/macrobenchmark/FhirEngineSearchApiBenchmark.kt`. + +*_Data preloaded contains Patients with associated resources; Encounters/Practitioners/Organization/Location_ + +| | Population size | Average duration (ms)
_∣ϵ∣≤20 ms_ | Notes | +|-----------------------------------------------|----------------:|--------------------------------------:|-------| +| searchPatientGivenWithDisjunctValues | 10k | ~34.47 | | +| searchEncounterLocalLastUpdated | 10k | ~428.07 | | +| searchPatientHasEncounter | 10k | ~104.66 | | +| searchPatientSortedByBirthDate | 10k | ~612.62 | | +| searchPatientSortedByName | 10k | ~497.06 | | +| searchPatientWithIncludeGeneralPractitioner | 10k | ~9.53 | | +| searchPatientWithRevIncludeConditions | 10k | ~8.32 | | +| searchPatientIdWithTokenIdentifier | 10k | ~11.11 | | +| searchPatientWithEitherGivenNameOrBirthDate | 10k | ~16.17 | | +| searchPatientWithEitherGivenNameOrIndexNumber | 10k | ~15.61 | | +| searchWithTypeDateSearchParameter | 10k | ~5.65 | | +| searchWithTypeNumberSearchParameter | 10k | ~1.68 | | +| searchWithTypeQuantitySearchParameter | 10k | ~3.12 | | +| searchWithTypeStringSearchParameter | 10k | ~24.49 | | diff --git a/docs/use/FEL/Demo-app.md b/docs/use/FEL/Demo-app.md new file mode 100644 index 000000000..492379196 --- /dev/null +++ b/docs/use/FEL/Demo-app.md @@ -0,0 +1,15 @@ +# Demo App + +To demonstrate the capabilities of the FHIR Engine Library, we have created the *FHIR Engine Demo* app. + +To run this app in Android Studio, [create a run/debug configuration](https://developer.android.com/studio/run/rundebugconfig) for the `demo` module using the [Android App](https://developer.android.com/studio/run/rundebugconfig#android-application) template and run the app using the configuration. + +Alternatively, run the following command to build and install the debug APK on your device/emulator: + +```shell +./gradlew :demo:installDebug +``` + +The Demo app uses the [HAPI FHIR demo server](https://hapi.fhir.org/baseR4/) as the server to sync to and all data is visible to anyone on the internet. Do not use real data, or any data you would not want publicly visible. + +The app filters for patients with `Patient.address.city = NAIROBI`. If creating a new patient in the app, set the **City** field to `NAIROBI` for it to show up in the **Registered Patients** list. diff --git a/docs/use/FEL/FHIR-search-specification-support.md b/docs/use/FEL/FHIR-search-specification-support.md new file mode 100644 index 000000000..101ab21bd --- /dev/null +++ b/docs/use/FEL/FHIR-search-specification-support.md @@ -0,0 +1,49 @@ +# FHIR search specification support + +This document tracks the support for the FHIR search specification supported by the SDK (fhirEngine library) and common example use cases. It will be updated over time. + +## Search parameter types + +The following table summarizes the support (released and planned) for [search parameter types](https://www.hl7.org/fhir/search.html) within the SDK: + +| | Support | Notes | +|-----------|-------------|--------------------------| +| String | Yes | | +| Number | Yes | | +| Date | Yes | | +| DateTime | Yes | | +| Token | Partial | URI, code, boolean | +| Reference | Yes | See examples | +| Quantity | No | Planned | +| Special | In-progress | TBC | +| Composite | Yes | AND and OR are supported | + +## Search prefixes + +https://www.hl7.org/fhir/search.html#prefix + +| | Support | Notes | +|----|---------|---------------------------| +| eq | Partial | Numerical and date values | +| ne | Partial | Numerical and date values | +| gt | Partial | Numerical and date values | +| lt | Partial | Numerical and date values | +| ge | Partial | Numerical and date values | +| le | Partial | Numerical and date values | +| sa | No | | +| eb | No | | +| ap | No | | + +## Modifiers + +https://www.hl7.org/fhir/search.html#modifiers + +| | Support | Notes | +|-----------------------------------------------|---------|--------| +| :contains | Partial | String | +| :exact | Partial | String | +| :missing, :text, :in, :below, :above, :not-in | No | | + +Other: + +* Search by meta-data - in-progress: https://github.com/google/android-fhir/issues/481 diff --git a/docs/use/FEL/Getting-started.md b/docs/use/FEL/Getting-started.md new file mode 100644 index 000000000..2066f5525 --- /dev/null +++ b/docs/use/FEL/Getting-started.md @@ -0,0 +1,64 @@ +# Getting Started + +This page describes how to configure an Android Studio project to use the FHIR Engine Library, and simple examples of how to use the library. + +This guide is intended for developers who are familiar with basic Android development with Kotlin, and proficient in working with [FHIR](http://hl7.org/fhir/) concepts and resources. + +## Dependencies + +The FHIR Engine Library is available through [Google's Maven repository](https://maven.google.com/web/index.html), which should be already configured for Android projects created in Android Studio 3.0 and higher. + +Add the following dependency to your module's app-level `build.gradle` file, typically `app/build.gradle`: + +```kotlin +dependencies { + implementation("com.google.android.fhir:engine:0.1.0-beta02") +} +``` + +In the same file, add the following APK packaging options: + +```kotlin +android { + // ... + packagingOptions { + resources.excludes.addAll(listOf("META-INF/ASL-2.0.txt", "META-INF/LGPL-3.0.txt")) + } +} +``` + +The minimum API level supported is 24. The library also requires [Android Gradle Plugin](https://developer.android.com/studio/releases/gradle-plugin) +version 4.0.0 or later for [Java 8+ API desugaring support](https://developer.android.com/studio/write/java8-support#library-desugaring). + +### Initialize and get a FHIR Engine + +To use a FHIR Engine instance, first call `FhirEngineProvider.init()` with a `FhirEngineConfiguration`. This must be done only once; we recommend doing this in the `onCreate()` function of your `Application` class. + +```kotlin +class MyApplication : Application() { + override fun onCreate() { + super.onCreate() + + FhirEngineProvider.init( + FhirEngineConfiguration( + enableEncryptionIfSupported = true, + RECREATE_AT_OPEN + ) + ) + } +} +``` + +When you need to use a `FhirEngine`, make sure to call the method `FhirEngineProvider.getInstance(Context)`: + +```kotlin +val fhirEngine = FhirEngineProvider.getInstance(this) +``` + +See [FhirApplication](https://github.com/google/android-fhir/blob/master/demo/src/main/java/com/google/android/fhir/demo/FhirApplication.kt) in the demo app for a sample implementation. + +## Next steps + +* [Manage FHIR resources Locally](Manage-FHIR-resources-locally.md) +* [Search FHIR resources](Search-FHIR-resources.md) +* [Sync data with a FHIR server](Sync-data-with-FHIR-server.md) diff --git a/docs/use/FEL/Manage-FHIR-resources-locally.md b/docs/use/FEL/Manage-FHIR-resources-locally.md new file mode 100644 index 000000000..4299d2977 --- /dev/null +++ b/docs/use/FEL/Manage-FHIR-resources-locally.md @@ -0,0 +1,74 @@ +# Manage FHIR Resources Locally + +The FHIR Engine Library has a CRUD-like interface for managing local FHIR resources. The FHIR resources are represented using [HAPI FHIR Structures](https://hapifhir.io/hapi-fhir/apidocs/hapi-fhir-structures-r4/index.html) objects and types. + +**Note**: All FHIR Engine functions are `suspend` functions and must be called from a [coroutine](https://developer.android.com/kotlin/coroutines), which is not included in the examples below. + +## Create a FHIR resource + +A `FhirEngine` instance stores FHIR resources represented as [HAPI FHIR Structures](https://hapifhir.io/hapi-fhir/apidocs/hapi-fhir-structures-r4/index.html) objects. To create a FHIR resource in a `FhirEngine` instance, first make the resource using the HAPI FHIR Structures API, then use `FhirEngine.create()`. + +**NOTE:** The FHIR Engine requires all stored resources to have a logical [`Resource.id`](https://www.hl7.org/fhir/resource-definitions.html#Resource.id). If the `id` is specified in the resource passed to `create()`, the resource created in `FhirEngine` will have the same `id`. If no `id` is specified, `FhirEngine` will generate a UUID as that resource's `id` and include it in the returned list of IDs. + +Example: Create a Patient resource + +```kotlin +// Create a new Patient resource +val patient = Patient().apply{ + id = "19682646" + gender = Enumerations.AdministrativeGender.MALE + addName( + HumanName().apply { + addGiven("Lance") + addGiven("Thomas") + family = "East" + } + ) +} + +// Create the resource in an existing FhirEngine instance +fhirEngine.create(patient) +``` + +## Read a FHIR Resource + +Read resources from FHIR Engine by providing the type and logical Resource ID. + +Example: Read a Patient resource + +```kotlin +// Read a Patient resource by ID +try { + val patient = fhirEngine.get("19682646") +} catch (e : ResourceNotFoundException) { + e.printStackTrace() +} +``` + +You can also [search for FHIR resources](Search-FHIR-resources.md). + +## Update a FHIR resource + +Example: Update a Patient resource + +```kotlin +val updatedPatient = patient.copy().setActive(false) + +fhirEngine.update(updatedPatient) + +``` + +## Delete a FHIR resource + +Example: Delete a Patient resource by ID. + +```kotlin +fhirEngine.delete("19682646") +``` + +If you already have a copy of the resource you want to delete, you can get the logical ID from it instead. + +```kotlin +fhirEngine.delete(patient.logicalId) + +``` diff --git a/docs/use/FEL/Privacy-Security.md b/docs/use/FEL/Privacy-Security.md new file mode 100644 index 000000000..a7500928f --- /dev/null +++ b/docs/use/FEL/Privacy-Security.md @@ -0,0 +1,27 @@ +# Privacy & Security + +## Database encryption + +In FHIREngine, by default, we utilize Android [encryption](https://source.android.com/security/encryption) and support Android 5.0 and above. This ensures data is always encrypted at rest. + +On Android 6.0 or above, FHIREngine offers **additional** application level database encryption via SQLChiper. + +To enable database encryption, you should invoke the `FhirEngineProvider#init(FhirEngineConfiguration)` in your `Application#onCreate` before creating any instance of `FhirEngine`. You can select one of the two database error handlings: + +* `UNSPECIFIED`: All database errors will be propagated to the call site. The caller shall handle the database error on a case-by-case basis. +* `RECREATE_AT_OPEN`: If a database error occurs at open, automatically recreate the database. This strategy is **NOT** respected when opening a previously unencrypted database with an encrypted configuration or vice versa. An `IllegalStateException` is thrown instead. + +Here is a code snippet of enabling database encryption with `RECREATE_AT_OPEN` error handling strategy: + +``` +FhirEngineProvider.init( + FhirEngineConfiguration( + enableEncryptionIfSupported = true, + databaseErrorStrategy = RECREATE_AT_OPEN + ) +) +``` + +## Data safety + +This library does not collect or share any personal or sensitive [user data](https://developer.android.com/guide/topics/data/collect-share) with any third party libraries or SDKs. diff --git a/docs/use/FEL/Search-FHIR-resources.md b/docs/use/FEL/Search-FHIR-resources.md new file mode 100644 index 000000000..37e467d12 --- /dev/null +++ b/docs/use/FEL/Search-FHIR-resources.md @@ -0,0 +1,456 @@ +# Search FHIR Resources + +You can search for resources in a FHIR Engine instance using the Android FHIR SDK's Search DSL or [FHIR search strings](https://www.hl7.org/fhir/search.html). The SDK supports [all defined search parameters](https://www.hl7.org/fhir/searchparameter-registry.html). + +See the full list of [FHIR search specification support](FHIR-search-specification-support.md). + +## Search using Search DSL + +The Search DSL is based on [FHIR search](https://www.hl7.org/fhir/search.html), and most of the capabilities of FHIR search have an equivalent in Search DSL. + +### General search syntax + +This is the simplest search syntax: + +```kotlin +val patients = fhirEngine.search {} +``` + +This search returns a `List` containing all `Patient` resources in the `FhirEngine` instance and is equivalent to the FHIR search: + +``` +GET [base]/Patient +``` + +If you're having trouble getting search to work, make sure Android Studio is using the correct version of `search`. + +![Search code completion](https://user-images.githubusercontent.com/7772901/197892131-38247b9a-9f3c-4dff-a2aa-f1019d8476d2.png) + +In most cases, use a `filter` to search for particular Patient resources. + +```kotlin +fhirEngine.search { + filter(Patient.GIVEN, { + value = "Kiran" + modifier = StringFilterModifier.MATCHES_EXACTLY + }) +} +``` + +To do so, first specify a search parameter from the HAPI FHIR resource being searched. For the example above, we recommend you use Android Studio's code completion by first entering the resource followed by a dot, such as `Patient.`, then pressing **ctrl+space** to see valid options. They will be of the type `XClientParam!`. + +Search parameter code completion + +Alternatively, look at the Field Summary for the [HAPI FHIR Patient resource](https://hapifhir.io/hapi-fhir/apidocs/hapi-fhir-structures-r4/org/hl7/fhir/r4/model/Patient.html) and find fields with the description "**Fluent Client** search parameter constant for **field**". These are the Patient resource fields we can search by. + +![HAPI FHIR Patient documentation](https://user-images.githubusercontent.com/7772901/197891914-4a5a0e23-ae28-408c-9602-8dcef5f28277.png) + +Not every field in a resource has search implemented, for example `Patient.maritalStatus` does not have a search parameter implemented in HAPI FHIR. On the other hand, HAPI FHIR provides some convenience search parameters like [`Patient.NAME`](https://hapifhir.io/hapi-fhir/apidocs/hapi-fhir-structures-r4/org/hl7/fhir/r4/model/Patient.html#NAME) which searches across all name-related fields but is not part of the base FHIR specification. + +Next, provide a `value` to search for. The expected type for the value for `value` depends on the type of the search parameter from the HAPI FHIR resource field, or seen in the code completion pop-up. In the example above, `Patient.GIVEN` is a `StringClientParam` so `value` should be a `String`. + +In addition, most parameter types have ways to control how the search is performed. In the case of `String` there is a `modifier` field. The `Number`, `Date`, and `Quantity` parameters have a `prefix` field instead, corresponding to the [FHIR Search prefix](https://www.hl7.org/fhir/search.html#prefix). + +For more details, see the examples below or [look at the implementation of the specific filter type](https://github.com/google/android-fhir/tree/master/engine/src/main/java/com/google/android/fhir/search/filter). + +### Using the OR operator + +By default, multiple filters are considered using logical `AND`. You can treat them as an `OR` by setting the `operation` field. The following example returns patients with a given name or family name of exactly "Jay". + +```kotlin +fhirEngine.search { + filter(Patient.GIVEN, { + value = "Jay" + modifier = StringFilterModifier.MATCHES_EXACTLY + }) + + filter(Patient.FAMILY, { + value = "Jay" + modifier = StringFilterModifier.MATCHES_EXACTLY + }) + + operation = Operation.OR +} +``` + +You can also pass multiple search criteria to the same filter. In this case, they are considered using `OR` by default. (Using `AND` on multiple search criteria is usually not useful.) The following example returns patients with a given name of exactly "Jay" or "Kat". + +```kotlin +fhirEngine.search { + filter(Patient.GIVEN, { + value = "Jay" + modifier = StringFilterModifier.MATCHES_EXACTLY + } , { + value = "Kat" + modifier = StringFilterModifier.MATCHES_EXACTLY + }) +} +``` + +### Reverse chaining + +The `has` function allows you to search for a resource based on other resources that reference it and functions similarly to [FHIR Search reverse chaining](https://build.fhir.org/search.html#has). In the following example, the search will return a list of Patients that: + +1. have a RelatedPerson that references that Patient in the `RelatedPerson.PATIENT` field +2. the RelatedPerson has a name matching "Ariel" + +In this case, the results will include a patient resource matching `patient1`, plus any others that were already created in the FhirEngine. + +```kotlin +val patient1 = Patient().apply{ + id = "1234567" + addName( + HumanName().apply { + addGiven("Alex") + family = "Lee" + } + ) +} + +val related1 = RelatedPerson().apply { + patient.reference = "${patient1.fhirType()}/${patient1.logicalId}" + addName( + HumanName().apply { + addGiven("Ariel") + family = "Lee" + } + ) +} + +fhirEngine.create(patient1) +fhirEngine.create(related1) + +val revinclude = fhirEngine.search { + has(RelatedPerson.PATIENT) { + filter (RelatedPerson.NAME, { + value = "Ariel" + }) + } +} +``` + +You can use `has` in combination with the other Search DSL features to create [complex searches](#complex-search-queries). + +### Search result modification + +You can specify several fields to control search results, similar to [FHIR Search results](https://build.fhir.org/search.html#modifyingresults). + +* `sort` - specify a Date, Number, or String field to sort the results by +* `count` - limit the results to a certain size +* `from` - start the results from a specified index + +```kotlin +val patients = fhirEngine.search { + filter (Patient.NAME, { + modifier = StringFilterModifier.CONTAINS + value = "Quinn" + }) + sort(Patient.FAMILY, Order.ASCENDING) + count = 100 + from = 0 +} +``` + +### Search filter parameters by type + +#### Number + +https://www.hl7.org/fhir/search.html#number + +Fields + +* `prefix` - A [FHIR search prefix](https://build.fhir.org/search.html#prefix), represented by [`ParamPrefixEnum`](https://hapifhir.io/hapi-fhir/apidocs/hapi-fhir-base/ca/uhn/fhir/rest/param/ParamPrefixEnum.html). +* `value` - A `BigDecimal` of the value to search for. + +Search for all the Risk Assessments with probability greater than 0.8. + +```kotlin +fhirEngine.search { + filter(RiskAssessment.PROBABILITY, { + value = BigDecimal(0.8) + prefix = ParamPrefixEnum.GREATERTHAN + }) +} +``` + +#### Date + +https://www.hl7.org/fhir/search.html#date + +* `prefix` - A [FHIR search prefix](https://build.fhir.org/search.html#prefix), represented by [`ParamPrefixEnum`](https://hapifhir.io/hapi-fhir/apidocs/hapi-fhir-base/ca/uhn/fhir/rest/param/ParamPrefixEnum.html). +* `value` - A `DateFilterValues` of the value to search for. Pass a [`DateType`](https://hapifhir.io/hapi-fhir/apidocs/hapi-fhir-structures-r5/org/hl7/fhir/r5/model/DateType.html) or [`DateTimeType`](https://hapifhir.io/hapi-fhir/apidocs/hapi-fhir-structures-r5/org/hl7/fhir/r5/model/DateTimeType.html) to the `of` function to create. + +Search for any patients born after 2013-03-14. + +```kotlin +fhirEngine.search { + filter( + Patient.BIRTHDATE, + { + prefix = ParamPrefixEnum.STARTS_AFTER + value = of(DateType("2013-03-14")) + } + ) +} +``` + +#### String + +https://www.hl7.org/fhir/search.html#string + +* `value` - The `String` to search for. +* `modifier` - An option from [`StringFilterModifier`](https://google.github.io/android-fhir/engine/engine/com.google.android.fhir.search/-string-filter-modifier/index.html), corresponding to a [FHIR string search](https://www.hl7.org/fhir/search.html#string) modifier. Default: `StringFilterModifier.STARTS_WITH` + +Search for any patients with a name with a given part containing "eve" at any position. This would include patients with the given name "Eve", "Evelyn", and also "Severine". + +```kotlin +val patients = fhirEngine.search { + filter (Patient.NAME, { + value = "eve" + modifier = StringFilterModifier.CONTAINS + }) +} +``` + +#### Token + +https://www.hl7.org/fhir/search.html#token + +* `value` - A `TokenFilterValue` of the value to search for. Pass the appropriate object type to the `of` function to create: `Boolean`, `String`, `UriType`, `CodeType`, `Coding`, `CodeableConcept`, `Identifier` + +Search for any patient with a gender that has the code "male". + +```kotlin +fhirEngine.search { + filter(Patient.GENDER, { + value = of(CodeType("male")) + }) +} +``` + +Search for any patients that are active. + +```kotlin +fhirEngine.search { + filter(Patient.ACTIVE, { + value = of(true) + }) +} +``` + +Search for any condition with a code "123" in the code system "http://example.org/codes". + +```kotlin +fhirEngine.search { + filter(Condition.CODE, { + value = of(CodeableConcept(Coding("http://example.org/codes", "123", ""))) + }) +} +``` + +#### Reference + +https://www.hl7.org/fhir/search.html#reference + +* `value` - a `String` of a resource reference. + +Search for all the observations where the subject references "Patient/123". + +```kotlin +fhirEngine.search { + filter(Observation.SUBJECT, { + value = "Patient/123" + }) +} +``` + +#### Quantity + +https://www.hl7.org/fhir/search.html#quantity + +* `prefix` - A [FHIR search prefix](https://build.fhir.org/search.html#prefix), represented by [`ParamPrefixEnum`](https://hapifhir.io/hapi-fhir/apidocs/hapi-fhir-base/ca/uhn/fhir/rest/param/ParamPrefixEnum.html). +* `value` - A `BigDecimal` of the value to search for. +* `system` - A `String` of the URI identifying the coding system. +* `unit` - A `String` of the coded form of the unit. + +Search for all the observations where the value of is about 5.4 mg, where mg is understood as a UCUM unit. + +```kotlin +fhirEngine.search { + filter(Observation.VALUE_QUANTITY, { + prefix = ParamPrefixEnum.APPROXIMATE + value = BigDecimal(5.4) + system = "http://unitsofmeasure.org" + unit = "mg" + }) +} +``` + +#### URI + +https://www.hl7.org/fhir/search.html#uri + +* `value` - a `String` of the URI to search for. + +Search for all the value sets with the exact URL "http://example.org/fhir/ValueSet/123". + +```kotlin +fhirEngine.search { + filter(ValueSet.URL, { + value = "http://example.org/fhir/ValueSet/123" + }) +} +``` + +### Search query examples + +#### Complex search queries + +Search for Patients in India who have completed their Influenza vaccination. + +```kotlin +fhirEngine.search { + has(Immunization.PATIENT) { + filter( + Immunization.VACCINE_CODE, { + value = of(Coding("http://hl7.org/fhir/sid/cvx", "140", "Influenza, seasonal, injectable, preservative free")) + } + ) + + // Follow Immunization.ImmunizationStatus + filter(Immunization.STATUS, { + value = of(Coding("http://hl7.org/fhir/event-status", "completed", "")) + } + ) + } + + filter( + Patient.ADDRESS_COUNTRY, { + modifier = StringFilterModifier.MATCHES_EXACTLY + value = "IN" + } + ) + } +``` + +#### Search Patient compartment for Encounters and Observations + +Search for all Encounters and Observations where the subject is patient ID "123". + +```kotlin +val observations = + fhirEngine.search { + filter(Observation.SUBJECT, { value = "Patient/123" }) + } + +val conditions = + fhirEngine.search { + filter(Condition.SUBJECT, { value = "Patient/123" }) +} +``` + +#### Search Patients with Observation by Code + +Search for all Patients where they are the subject of an Observation with the code "http://snomed.info/sct|386661006" or "http://loinc.org|45701-0" (different codes for fever). + +```kotlin +val patients = fhirEngine.search { + has(Observation.SUBJECT) { + filter( + Observation.CODE, + { + of(Coding("http://snomed.info/sct", "386661006", "Fever" )) + }, { + of(Coding("http://loinc.org", "45701-0", "Fever")) + } + ) + } +} +``` + +## Search using FHIR search queries + +You can also search resources in a FHIR Engine instance using [FHIR search queries](https://www.hl7.org/fhir/search.html). + +Complex queries including FHIRPath expressions, global common search parameters, modifiers, prefixes, chained parameters are not supported. + +```kotlin +val result = fhirEngine.search("Patient?active=true&gender=male&_sort=-name,gender&_count=11") +``` + +## Adding custom search parameters + +You can define custom search parameters, in addition to the ones defined in the [FHIR spec](https://www.hl7.org/fhir/searchparameter-registry.html), add them to FHIR Engine and use them to search for resources. Adding custom search parameters is particularly useful when you want to search for extensions defined in custom profiles. + +### Setting up custom search parameters + +```kotlin +class FhirApplicationTest : Application(){ + + override fun onCreate() { + super.onCreate() + + FhirEngineProvider.init( + FhirEngineConfiguration( + enableEncryptionIfSupported = true, + RECREATE_AT_OPEN, + ServerConfiguration("https://hapi.fhir.org/baseR4/"), + customSearchParameters = listOf( + SearchParameter().apply { + url = "http://example.com/SearchParameter/patient-mothersMaidenName" + addBase("Patient") + name = "mothers-maiden-name" + code = "mothers-maiden-name" + type = Enumerations.SearchParamType.STRING + expression = + "Patient.extension('http://hl7.org/fhir/StructureDefinition/patient-mothersMaidenName').value.as(String)" + description = "search on mother's maiden name" + }, +// Additional String Type Search param for Patient.identifier for value matching along with the standard Token Type provided by FHIR. + SearchParameter().apply { + url = "http://example.com/SearchParameter/patient-identifierPartial" + addBase("Patient") + name = "identifierPartial" + code = "identifierPartial" + type = Enumerations.SearchParamType.STRING + expression = "Patient.identifier.value" + description = "Search the identifier" + } + ) + ) + ) + } +} +``` + +### Searching with the added custom search parameters + +```kotlin +// Identifier partial search returns all Patients whose Patient.identifier.value starts with 5005- . +fhirEngine.search { + filter( + StringClientParam("identifierPartial"), + { + value = "5005-" + modifier = StringFilterModifier.STARTS_WITH + } + ) +} +``` + +```kotlin +// searching with the extension returns all Patients who have Marca as their Maiden name. +// (custom extension http://example.com/SearchParameter/patient-mothersMaidenName). +fhirEngine.search { + filter( + StringClientParam("mothers-maiden-name"), + { + value = "Marca" + modifier = StringFilterModifier.MATCHES_EXACTLY + } + ) +} +``` + +**NOTE:** +The engine doesn't automatically reindex existing resources after a new SearchParameter is added. Resources created after the new SearchParameter is created will be indexed accordingly. Updates to the existing resources will result in the reindexing of the resource. diff --git a/docs/use/FEL/Sync-data-with-FHIR-server.md b/docs/use/FEL/Sync-data-with-FHIR-server.md new file mode 100644 index 000000000..0eaa80ce5 --- /dev/null +++ b/docs/use/FEL/Sync-data-with-FHIR-server.md @@ -0,0 +1,172 @@ +# Sync Data with FHIR Server + +This guide explains how to synchronize data between your Android app and a FHIR server using the Android FHIR Engine. The FHIR Engine provides two ways to achieve this: **one-time sync** and **periodic sync**. + +**One-time sync** allows you to trigger a synchronization job manually, on demand. This is useful for scenarios where you want to control when data is synchronized, such as when a user explicitly requests it. + +**Periodic sync** allows you to schedule a job that automatically synchronizes data with the FHIR server at regular intervals. This is useful for keeping the local data in your app up-to-date with the server. + +## Initial setup + +Before you can start syncing data, you need to perform some initial setup. + +### Server requirements + +The FHIR server you are syncing with must support the [optional capability to allow clients to `PUT` resources to locations that don't yet exist (upsert)](https://www.hl7.org/fhir/http.html#upsert). + +### Add dependencies and permissions + +* Add the `work-runtime-ktx` dependency to your app-level `build.gradle` file, typically `app/build.gradle`: + +```kotlin +dependencies { + // ... + implementation("androidx.work:work-runtime-ktx:2.8.1") +} +``` + +* Include the Internet permission in your `AndroidManifest.xml`: + +```xml + + + + + ... + + + +``` + +### Configure FhirEngine + +* During `FhirEngineProvider.init()`, provide a `ServerConfiguration` object with at least the `baseUrl` set to the URL of your FHIR server as part of the [`FhirEngineConfiguration`](https://github.com/google/android-fhir/blob/master/engine/src/main/java/com/google/android/fhir/FhirEngineProvider.kt#L106): + +```kotlin +FhirEngineProvider.init( + FhirEngineConfiguration( + enableEncryptionIfSupported = true, + databaseErrorStrategy = DatabaseErrorStrategy.RECREATE_AT_OPEN, + serverConfiguration = ServerConfiguration("https://your-fhir-server-url"), + ) +) +``` + +#### Advanced configuration + +The [`ServerConfiguration`](https://github.com/google/android-fhir/blob/master/engine/src/main/java/com/google/android/fhir/FhirEngineProvider.kt#L143) takes in other parameters as well. + +Optionally, you can customize network connection parameters like timeouts and gzip compression using [`NetworkConfiguration`](https://github.com/google/android-fhir/blob/master/engine/src/main/java/com/google/android/fhir/FhirEngineProvider.kt#L164). By default, `uploadWithGzip` is set to `false`. If you are uploading to Google Cloud FHIR Store or a HAPI FHIR Server, we recommend setting it to `true` as those two servers support gzip compression. + +If your server requires authentication, implement the [`HttpAuthenticator`](https://github.com/google/android-fhir/blob/master/engine/src/main/java/com/google/android/fhir/sync/HttpAuthenticator.kt#L33) interface and provide it to `ServerConfiguration`. This allows you to specify how the FHIR Engine should authenticate with the server. For example, let's say your server requires OAuth 2.0 Bearer Token authentication. To use it, implement the `HttpAuthenticator` interface using the [HttpAuthenticationMethod.Bearer](https://github.com/google/android-fhir/blob/master/engine/src/main/java/com/google/android/fhir/sync/HttpAuthenticator.kt#L52) function, which should return the bearer token string required by the server. It will be added to the header of all requests. + +For debugging purposes, you can configure the `httpLogger` parameter in `ServerConfiguration` to log the communication between the FHIR Engine and the server. + +Putting all this together, we can have a ServerConfiguration that looks like this: + +```kotlin + ServerConfiguration( + "https://hapi.fhir.org/baseR4/", + httpLogger = + HttpLogger( + HttpLogger.Configuration( + if (BuildConfig.DEBUG) HttpLogger.Level.BODY else HttpLogger.Level.BASIC + ) + ) { Timber.tag("App-HttpLog").d(it) }, + networkConfiguration = NetworkConfiguration(uploadWithGzip = true), + authenticator = { HttpAuthenticationMethod.Bearer("mySecureToken") } + ) +``` + +### Implement DownloadWorkManager + +Create a subclass of [`DownloadWorkManager`](https://github.com/google/android-fhir/blob/master/engine/src/main/java/com/google/android/fhir/sync/DownloadWorkManager.kt) to define how the engine should generate download requests and process responses from the server. You can refer to the [`TimestampBasedDownloadWorkManagerImpl`](https://github.com/google/android-fhir/blob/master/demo/src/main/java/com/google/android/fhir/demo/data/TimestampBasedDownloadWorkManagerImpl.kt) in the demo app for an example implementation. + +### Implement FhirSyncWorker + +Finally, provide an implementation of `FhirSyncWorker`. This class is responsible for defining how your app interacts with the FHIR Engine during synchronization. It has four functions to implement: + +* **`getDownloadWorkManager()`**: This should return the implementation of `DownloadWorkManager` you created earlier. +* **`getConflictResolver()`**: This controls how conflicts between the local and remote versions of resources are resolved. You can set it to `AcceptLocalConflictResolver` if the local version should take precedence, or `AcceptRemoteConflictResolver` if the remote version should. +* **`getFhirEngine()`**: This should return your application's `FhirEngine` instance. +* **`getUploadStrategy()`**: This defines how local changes are uploaded to the FHIR server. Currently, the only supported strategy is `UploadStrategy.AllChangesSquashedBundlePut`, which squashes all local changes into a single bundle and uses the `PUT` method to upload it. + +Here's an example implementation: + +```kotlin +class FhirPeriodicSyncWorker(appContext: Context, workerParams: WorkerParameters) : + FhirSyncWorker(appContext, workerParams) { + + override fun getDownloadWorkManager(): DownloadWorkManager { + return DownloadWorkManagerImpl() // Replace with your implementation + } + + override fun getConflictResolver() = AcceptLocalConflictResolver + + override fun getFhirEngine() = FhirApplication.fhirEngine(applicationContext) + + override fun getUploadStrategy(): UploadStrategy { + return UploadStrategy.AllChangesSquashedBundlePut + } +} +``` + +Remember to replace `DownloadWorkManagerImpl` with your actual implementation. + +See the [`DemoFhirSyncWorker`](https://github.com/google/android-fhir/blob/master/demo/src/main/java/com/google/android/fhir/demo/data/DemoFhirSyncWorker.kt) in the demo app for a more complete example. + +## Perform synchronization + +Once you have completed the initial setup, you can start syncing data with the FHIR server. + +### One-Time Sync + +Use the [Sync.oneTimeSync](https://github.com/google/android-fhir/blob/master/engine/src/main/java/com/google/android/fhir/sync/Sync.kt#L54) API: + +```kotlin +Sync.oneTimeSync(applicationContext) +``` + +This triggers a single sync job. You can collect the `SyncJobStatus` from the returned `Flow` to monitor the progress and result of the job. + +```kotlin +CoroutineScope.launch { + Sync.oneTimeSync(applicationContext) + .shareIn(this, SharingStarted.Eagerly, 0) + .collect { /* Handle SyncJobStatus here */ } +} +``` + +### Periodic Sync + +Use the [Sync.periodicSync](https://github.com/google/android-fhir/blob/master/engine/src/main/java/com/google/android/fhir/sync/Sync.kt#L80) API. + +```kotlin +Sync.periodicSync( + applicationContext, + PeriodicSyncConfiguration( + syncConstraints = Constraints.Builder().build(), + repeat = RepeatInterval(interval = 15, timeUnit = TimeUnit.MINUTES) + ) +) +``` + +This schedules a periodic sync job that runs according to the specified `PeriodicSyncConfiguration`. You can customize the sync constraints and repeat interval as needed. Similar to one-time sync, you can collect the `PeriodicSyncJobStatus` from the returned `Flow` to monitor the job. + +```kotlin +CoroutineScope.launch { + Sync.periodicSync( + application.applicationContext, + periodicSyncConfiguration = + PeriodicSyncConfiguration( + syncConstraints = Constraints.Builder().build(), + repeat = RepeatInterval(interval = 15, timeUnit = TimeUnit.MINUTES) + ) + ) + .shareIn(this, SharingStarted.Eagerly, 10) + .collect { /**Handle SyncJobStatus Here*/ } +} +``` + +Note: Both sync methods utilize the WorkManager library under the hood. For more information on WorkManager, refer to the [official documentation](https://developer.android.com/reference/androidx/work/WorkManager) diff --git a/docs/use/FEL/fhir-engine-components.png b/docs/use/FEL/fhir-engine-components.png new file mode 100644 index 000000000..493c6cc9d Binary files /dev/null and b/docs/use/FEL/fhir-engine-components.png differ diff --git a/docs/use/FEL/google-bucket-page.png b/docs/use/FEL/google-bucket-page.png new file mode 100644 index 000000000..639741876 Binary files /dev/null and b/docs/use/FEL/google-bucket-page.png differ diff --git a/docs/use/FEL/index.md b/docs/use/FEL/index.md new file mode 100644 index 000000000..ac4fada1e --- /dev/null +++ b/docs/use/FEL/index.md @@ -0,0 +1,23 @@ +# FHIR Engine Library + +The _FHIR Engine Library_ is a Kotlin library that makes it easy to manage FHIR resources on Android and synchronize them with FHIR servers. + +Today, there are a growing number of tools and software libraries supporting the use of [FHIR](https://www.hl7.org/fhir/) on various platforms. However, developer support for FHIR on Android is still lacking. Typically, Android healthcare applications still use proprietary data models, and as a result, suffer from a lack of interoperability. The FHIR Engine Library addresses this problem by provding a set of APIs to help manage FHIR resources on Android, allowing easier integration with other software systems using FHIR. + +![FHIR Engine Library components](fhir-engine-components.png) + +## FHIR Engine Library components + +The FHIR Engine library comprises an SQLLite database for offline storage of FHIR Resources and a suite of APIs (sync, search, data-access) for managing FHIR resources on the device. + +### Local db for offline storage of FHIR resources + +* The backbone of the FHIR Engine Library is the **SQLite database** storing FHIR resources in serialized JSON format +* Data stored in the on device storage is **[stored securely](Privacy-Security.md)** +* The database has **indexes for enabling fast retrieval** of resources based on [FHIR Search](https://www.hl7.org/fhir/search.html) + +### FHIR Engine APIs + +* **Data Access API**: basic access to local FHIR resources, namely, the CRUD operations: create, read, update, and delete. This relies on the HAPI FHIR Structures libraries ([api docs](https://hapifhir.io/hapi-fhir/apidocs/hapi-fhir-structures-r4/) | [documentation](https://hapifhir.io/hapi-fhir/docs/model/working_with_resources.html)) +* **Search API**: a Kotlin DSL (domain-specific language) for searching local FHIR resources +* **Sync API**: synchronizes local FHIR resources with a remote FHIR server/store (such as [HAPI FHIR](https://hapifhir.io/hapi-fhir//) or [Google Cloud Healthcare API](https://cloud.google.com/healthcare-api/docs/how-tos/fhir)) diff --git a/docs/use/FEL/kokoro-details-page.png b/docs/use/FEL/kokoro-details-page.png new file mode 100644 index 000000000..e93ffb896 Binary files /dev/null and b/docs/use/FEL/kokoro-details-page.png differ diff --git a/docs/use/FEL/macrobenchmark-section.png b/docs/use/FEL/macrobenchmark-section.png new file mode 100644 index 000000000..9ce49c1bc Binary files /dev/null and b/docs/use/FEL/macrobenchmark-section.png differ diff --git a/docs/use/FEL/microbenchmark-bucket.png b/docs/use/FEL/microbenchmark-bucket.png new file mode 100644 index 000000000..1b11b2004 Binary files /dev/null and b/docs/use/FEL/microbenchmark-bucket.png differ diff --git a/docs/use/FEL/microbenchmark-section.png b/docs/use/FEL/microbenchmark-section.png new file mode 100644 index 000000000..575e34aa8 Binary files /dev/null and b/docs/use/FEL/microbenchmark-section.png differ diff --git a/docs/use/FEL/path-bulk-data.png b/docs/use/FEL/path-bulk-data.png new file mode 100644 index 000000000..e4f8e0cd0 Binary files /dev/null and b/docs/use/FEL/path-bulk-data.png differ diff --git a/docs/use/FEL/pr-kokoro-view-details.png b/docs/use/FEL/pr-kokoro-view-details.png new file mode 100644 index 000000000..cfb125e9e Binary files /dev/null and b/docs/use/FEL/pr-kokoro-view-details.png differ diff --git a/docs/use/FEL/select-bucket-url.png b/docs/use/FEL/select-bucket-url.png new file mode 100644 index 000000000..43a21d15a Binary files /dev/null and b/docs/use/FEL/select-bucket-url.png differ diff --git a/docs/use/SDCL/Author-questionnaires.md b/docs/use/SDCL/Author-questionnaires.md new file mode 100644 index 000000000..1dccece76 --- /dev/null +++ b/docs/use/SDCL/Author-questionnaires.md @@ -0,0 +1,625 @@ +# Author Questionnaires + +An Android app developer can use the Structured Data Capture Library's capabilities without a deep understanding of FHIR or FHIR questionnaires; similarly, healthcare data experts can build questionnaires independently from the app development workflow. However, understanding FHIR Questionnaires can be helpful for developers to debug issues, implement more advanced features, or authoring questionnaires themselves. This section is meant to provide Android developers a brief overview of the Questionnaire with a focus on the parts relevant for the Structured Data Capture Library. + +You should be familiar with: + +* Reading and writing [JSON](https://www.json.org/) +* Working with data structures besides FHIR +* (Recommended) [FHIR overview for developers](https://www.hl7.org/fhir/overview-dev.html) + +The Structured Data Capture Library uses the [FHIR Questionnaire](https://www.hl7.org/fhir/questionnaire.html) as its fundamental data structure. A FHIR Questionnaire is a structured set of questions for collecting data from the end users. In addition, the [Structured Data Capture](http://hl7.org/fhir/uv/sdc/) (SDC) implementation guide supplements questionnaires by defining capabilities for more advanced control of rendering and question flow. + +* [Example questionnaires from HL7](https://www.hl7.org/fhir/questionnaire-examples.html) +* [Example questionnaires from the SDC IG](http://hl7.org/fhir/uv/sdc/artifacts.html#example-example-instances) + +## Questionnaire builders + +There are a number of web-based tools for building FHIR Questionnaires like the [NLM Form Builder](https://lhcformbuilder.nlm.nih.gov/), which are a great starting point for both developers and non-developers to create and edit Questionnaires. They allow you to add and edit questions using a simple drag-and-drop interface, and include some advanced features like conditional display. Many features which require extensions like [data extraction and population](#data-extraction-and-population) or [item control](FHIR-specification-support.md#item-control) are not included in the UI but can be manually added by saving as a JSON file and editing the file. Typically, if you load a file with manually added extensions back into a Questionnaire builder and edit an item with an extension, the extension is maintained. However, if you add new items any desired extensions would need to be manually added. + +One possible workflow is to have a non-developer create an initial version of a questionnaire using a Questionnaire builder tool, then saving it as a JSON file and it handing off to someone familiar with the FHIR Questionnaire syntax to add any desired extensions or other advanced functionality manually. + +Questionnaire builders may support different parts of the SDC implementation guide than the Structured Data Capture Library does, so double-check the SDC Library's [FHIR specification support](FHIR-specification-support.md) if something doesn't seem to be working. + +## Questionnaire basics + +Following is an example of a very simple Questionnaire: + +```json +{ + "resourceType": "Questionnaire", + "status": "draft", + "item": [ + { + "linkId": "1", + "text": "Name", + "type": "string" + } + ] +} + +``` + +The `item` array contains a single question object which collects a string. The optional `text` field means this question will have a text label containing "Name" when rendered. `linkId` is used to identify the specific component of the Questionnaire. Two common conventions for `linkId` are using the numeric representation of the nested structure (`1`, `1.1`, `1.2`, etc.), or descriptive names (`patient-info`, `patient-info-name`, `patient-info-dob`, for example). + +Let's look at a more complex Questionnaire, focused on the top-level `item` element: + +```json +"item": [ + { + "linkId": "1", + "text": "Patient Information", + "type": "group", + "item": [ + { + "linkId": "1.1", + "text": "Name", + "type": "string", + "required": true + }, + { + "linkId": "1.2", + "text": "Date of birth", + "type": "date" + } + ] + }, + { + "linkId": "2", + "text": "Demographic Information", + "type": "group", + "item": [ + { + "linkId": "2.1", + "text": "Cats are better than dogs", + "type": "boolean" + }, + { + "linkId": "2.2", + "text": "Correct!", + "type": "display", + "enableWhen": [ + { + "question": "2.1", + "operator": "=", + "answerBoolean": true + } + ] + } + ] + } +] +``` + +There are [several options](https://www.hl7.org/fhir/valueset-item-type.html) for the `type` member of `item` objects. The Structured Data Capture Library selects the UI component to use when rendering based on the type. This example also uses the `group` type where `text` acts as section headers and child item objects are logically grouped. + +Some Questionnaire elements control validation or rendering logic. For example, item `1.1` is required, and item `2.2` is only shown if item `2.1` is `true`. + +The next example of an item object uses extensions from the SDC implementation guide and also demonstrates the `choice` type: + +```json +... +{ + "linkId": "1.3", + "text": "Gender", + "type": "choice", + "extension": [ + { + "url": "http://hl7.org/fhir/StructureDefinition/questionnaire-itemControl", + "valueCodeableConcept": { + "coding": [ + { + "system": "http://hl7.org/fhir/questionnaire-item-control", + "code": "radio-button", + "display": "Radio Button" + } + ] + } + }, + { + "url" : "http://hl7.org/fhir/StructureDefinition/questionnaire-choiceOrientation", + "valueCode" : "horizontal" + } + ], + "answerOption": [ + { + "valueCoding": { + "code": "female", + "display": "Female", + "system": "http://hl7.org/fhir/gender-identity" + } + }, + { + "valueCoding": { + "code": "male", + "display": "Male", + "system": "http://hl7.org/fhir/gender-identity" + } + }, + { + "valueCoding": { + "code": "other", + "display": "Other", + "system": "http://hl7.org/fhir/gender-identity" + } + } + ] +}, +... +``` + +There are two extensions: the `questionnaire-itemControl` specifies that the `choice` type question should use radio buttons (and not a dropdown menu or checkboxes, for example), and the `questionnaire-choiceOrientation` says question options should be rendered horizontally. + +### Popular features and extensions + +#### Pagination + +[Page](https://build.fhir.org/codesystem-questionnaire-item-control.html#questionnaire-item-control-page) item control on group items for paginated questionnaires. Note: the `page` control type is not yet part of a stable FHIR release, but has been added to the SDC Library due to developer demand. + +Example: + +```json +"item": [ + { + "type": "group", + "linkId": "pagedemo", + "text": "Page title", + "extension": [ + { + "url": "http://hl7.org/fhir/StructureDefinition/questionnaire-itemControl", + "valueCodeableConcept": { + "coding": [ + { + "system": "http://hl7.org/fhir/questionnaire-item-control", + "code": "page", + "display": "Page" + } + ], + "text": "Page" + } + } + ], + "item": [ ... ] + } +] +``` + +#### Value constraints + +[Value constraints](http://hl7.org/fhir/uv/sdc/behavior.html#value-constraints) to add validation to responses. Currently supported: `maxLength`, `minLength`, `regex`, `minValue`, `maxValue`. + +Example: + +```json +"item": [ + { + "type": "decimal", + "linkId": "weightdemo", + "text": "Weight, Measured", + "extension": [ + { + "url": "http://hl7.org/fhir/StructureDefinition/minValue", + "valueDecimal": 1 + }, + { + "url": "http://hl7.org/fhir/StructureDefinition/maxValue", + "valueDecimal": 100 + }, + { + "url": "http://hl7.org/fhir/StructureDefinition/maxDecimalPlaces", + "valueInteger": 2 + } + ] + } +] +``` + +#### Conditional display + +Conditional display using [enableWhen](http://hl7.org/fhir/uv/sdc/behavior.html#enableWhen) and [enableWhenExpression](http://hl7.org/fhir/uv/sdc/expressions.html#enableWhenExpression). + +Example: + +```json +"item": [ + { + "linkId": "sex", + "text": "Sex", + "type": "choice", + "answerOption": [ + { + "valueCoding": { + "code": "male", + "display": "Male" + } + }, + { + "valueCoding": { + "code": "female", + "display": "Female" + } + } + ] + }, + { + "linkId": "birthdate", + "text": "Birth date" + "type": "date", + }, + { + "linkId" : "example1", + "text": "Shown only if female", + "type": "text", + "enableWhen": [ + { + "question" : "sex", + "operator": "=", + "answerCoding": { + "code": "female" + } + } + ] + }, + { + "linkId" : "example2", + "text": "Shown only if female over 40 years old", + "type": "text", + "extension": [ + { + "url": "http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-enableWhenExpression", + "valueExpression": { + "description": "female over 40", + "language": "text/fhirpath", + "expression": "%resource.repeat(item).where(linkId='sex').answer.value.code ='female' and today().toString().substring(0, 4).toInteger() - %resource.repeat(item).where(linkId='birthdate').answer.value.toString().substring(0, 4).toInteger() >= 40" + } + } + ], + } +] +``` + +#### Autocomplete + +[Autocomplete](http://hl7.org/fhir/R4/codesystem-questionnaire-item-control.html#questionnaire-item-control-autocomplete) for choice-type questionnaire item. + +Example: + +```json +"item": [ + { + "linkId": "state" + "text": "US State", + "type": "choice", + "extension": [ + { + "url": "http://hl7.org/fhir/StructureDefinition/questionnaire-itemControl", + "valueCodeableConcept": { + "coding": [ + { + "system": "http://hl7.org/fhir/questionnaire-item-control", + "code": "autocomplete", + "display": "autocomplete" + } + ] + } + } + ], + "answerOption": [ + { + "valueCoding": { + "code": "AL", + "display": "Alabama" + } + }, + { + "valueCoding": { + "code": "AK", + "display": "Alaska" + } + }, + { + "valueCoding": { + "code": "AS", + "display": "American Samoa" + } + }, + { + "valueCoding": { + "code": "AZ", + "display": "Arizona" + } + }, + { + "valueCoding": { + "code": "AR", + "display": "Arkansas" + } + }, + ... + ] + } +] +``` + +## Data extraction and population + +Mapping FHIR QuestionnaireResponses to other FHIR resources (and back) allows the structured data capture process to be more tightly integrated with clinical workflows. + +For example, if your application has a questionnaire for new patient registration, your ultimate goal may be to create a [FHIR Patient resource](https://www.hl7.org/fhir/patient.html) based on the answers provided to use in your application. Or, if your application has a questionnaire for entering test results, you could create a FHIR Observation resource. The process of mapping a FHIR QuestionnaireResponse to one or more other FHIR resources is called [extraction](http://hl7.org/fhir/uv/sdc/extraction.html). + +On the other hand, you may want to reduce data entry by loading values from existing FHIR resources into your questionnaire. For example, if a questionnaire asks for a patient's name and age, you can pre-populate that information from an existing FHIR Patient resource. The process of mapping one or more FHIR resources to a FHIR QuestionnaireResponse is called [population](http://hl7.org/fhir/uv/sdc/populate.html). + +### Definition-based extraction + +A questionnaire using [definition-based extraction](http://hl7.org/fhir/uv/sdc/extraction.html#definition-based-extraction) includes the [questionnaire-itemExtractionContext](http://hl7.org/fhir/uv/sdc/StructureDefinition-sdc-questionnaire-itemExtractionContext.html) extension to identify the FHIR resource to extract, and fill in the `Questionnaire.item.definition` to specify the resource or profile element that Questionnaire item corresponds to: + +```json +{ + "resourceType": "Questionnaire", + "status": "draft", + "extension": [ + { + "url": "http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-itemExtractionContext", + "valueExpression": { + "language": "application/x-fhir-query", + "expression": "Patient", + "name": "patient" + } + } + ], + "item": [ + { + "linkId": "PR", + "type": "group", + "item": [ + { + "linkId": "PR-name", + "type": "group", + "definition": "http://hl7.org/fhir/StructureDefinition/Patient#Patient.name", + "item": [ + { + "linkId": "PR-name-given", + "definition": "http://hl7.org/fhir/StructureDefinition/Patient#Patient.name.given", + "type": "string", + "text": "First Name" + }, + { + "linkId": "PR-name-family", + "definition": "http://hl7.org/fhir/StructureDefinition/Patient#Patient.name.family", + "type": "string", + "text": "Family Name" + } + ] + }, + { + "linkId": "PR-birthdate", + "definition": "http://hl7.org/fhir/StructureDefinition/Patient#Patient.birthDate", + "type": "date", + "text": "Date of Birth" + }, + { + "linkId": "PR-id", + "type": "group", + "definition": "http://hl7.org/fhir/StructureDefinition/Patient#Patient.identifier", + "item": [ + { + "extension" : [ + { + "url" : "http://hl7.org/fhir/StructureDefinition/questionnaire-hidden", + "valueBoolean" : true + }, + { + "url" : "http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-initialExpression", + "valueString" : "http://example.org/mrn" + } + ], + "linkId": "PR-name-id-url", + "definition": "http://hl7.org/fhir/StructureDefinition/Patient#Patient.identifier.system", + "type": "string" + }, + { + "linkId": "PR-name-id", + "definition": "http://hl7.org/fhir/StructureDefinition/Patient#Patient.identifier.value", + "type": "string", + "text": "Patient Id" + } + ] + } + ] + } + ] +} +``` + +This example extracts to a Patient resource. In order to extract to a Patient.identifier, it includes the hidden `PR-name-id-url` item to populate the `Patient.identifier.system` element programmatically. + +One major limitation of expression-based extraction is that the questionnaire must be structured the same as the resource you're extracting to. For example, there is no simple way to extract answers from a single group to multiple different FHIR resources. + +See the [SDC implementation guide on definition-based extraction](http://hl7.org/fhir/uv/sdc/extraction.html#definition-based-extraction) for more information. + +### StructureMap-based extraction + +A questionnaire using [StructureMap-based extraction](http://hl7.org/fhir/uv/sdc/extraction.html#structuremap-based-extraction) includes the `sdc-questionnaire-targetStructureMap` extension specifying the structure map to use when transforming the QuestionnaireResponse to other FHIR resources. + +```json +{ + "resourceType": "Questionnaire", + "status": "active", + "extension": [ + { + "url": "http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-targetStructureMap", + "valueCanonical": "http://example.org/fhir/StructureMap/PatientRegistration" + } + ], + "item": [ + { + "linkId": "PR", + "type": "group", + "item": [ + { + "linkId": "PR-name", + "type": "group", + "item": [ + { + "linkId": "PR-name-given", + "type": "string", + "text": "First Name" + }, + { + "linkId": "PR-name-family", + "type": "string", + "text": "Family Name" + } + ] + }, + { + "linkId": "PR-birthdate", + "type": "date", + "text": "Date of Birth" + }, + { + "linkId": "PR-name-id", + "type": "string", + "text": "Patient Id" + } + ] + } + ] +} +``` + +Structure maps are typically authored in the [FHIR mapping language](http://hl7.org/fhir/R4/mapping-language.html), although they can also be represented by the [StructureMap resource](http://hl7.org/fhir/R4/structuremap.html): + +``` +map "http://example.org/fhir/StructureMap/PatientRegistration" = 'PatientRegistration' +uses "http://hl7.org/fhir/StructureDefinition/QuestionnaireReponse" as source +uses "http://hl7.org/fhir/StructureDefinition/Bundle" as target + +group PatientRegistration(source src : QuestionnaireResponse, target bundle: Bundle) { + src -> bundle.id = uuid() "rule_bundle_id"; + src -> bundle.type = 'collection' "rule_bundle_type"; + src -> bundle.entry as entry, entry.resource = create('Patient') as patient then + ExtractPatient(src, patient) "rule_extract_patient"; +} + +group ExtractPatient(source src : QuestionnaireResponse, target tgt : Patient) { + src.item as item where(linkId = 'PR') then { + item.item as inner_item where (linkId = 'patient-0-birth-date') then { + inner_item.answer first as ans then { + ans.value as val -> tgt.birthDate = val "rule_birthdate"; + }; + }; + + item.item as inner_item where (linkId = 'PR-name-id') then { + inner_item.answer first as ans -> tgt.identifier = create('Identifier') as id then { + ans.value -> id.system = 'http://example.org/mrn' "rule_name_id_code"; + ans.value as val -> id.value = val "rule_name_id_val"; + }; + }; + + item.item as nameItem where(linkId = 'PR-name') -> tgt.name = create('HumanName') as patientName then { + nameItem.item as famItem where (linkId = 'PR-name-family') then { + famItem.answer first as family then { + family.value as val -> patientName.family = val "rule_name_family"; + }; + }; + src -> patientName.given = evaluate(nameItem, ${"$"}this.item.where(linkId = 'PR-name-given').answer.value) "rule_name_given"; + }; + }; +} +``` + +The `PatientRegistration` group creates a Bundle to contain the other resources, which is required by the Structured Data Capture library, then creates an empty Patient and continues to `ExtractPatient`. Most of the example simply searches for items by `linkId` and then navigates the questionnaire's `item` data structure to set Patient element values. For the `Patient.name` element, the example uses the same strategy for the extraction rule `rule_name_family`, but also demonstrates using the [FHIRPath language](https://hl7.org/fhirpath/) to navigate and search the questionnaire structure with the extraction rule `rule_name_given`. + +Relies on HAPI FHIR implementation which does not support all features of mapping language, mostly convenience features so if it doesn't work try a more verbose form. + +See the [FHIR mapping language specification](http://hl7.org/fhir/R4/mapping-language.html) and [FHIR mapping language tutorial](http://hl7.org/fhir/R4/mapping-tutorial.html) for more information. + +The [online fhirpath.js demo](https://hl7.github.io/fhirpath.js/) is useful for learning and iterating on [FHIRPath](https://hl7.org/fhirpath/) expressions. + +### Expression-based population + +A questionnaire using [expression-based population](http://hl7.org/fhir/uv/sdc/populate.html#expression-based-population) primarily relies on the `sdc-questionnaire-initialExpression` extension to specify the starting value for each questionnaire item: + +```json +{ + "resourceType": "Questionnaire", + "status": "active", + "item": [ + { + "linkId": "PR", + "type": "group", + "item": [ + { + "extension" : [ + { + "url" : "http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-initialExpression", + "valueExpression" : { + "language" : "text/fhirpath", + "expression" : "Patient.name.first().select(given.first() + ' ' + family.first())" + } + } + ], + "linkId": "PR-name", + "type": "string", + "text": " Full name" + }, + { + "extension" : [ + { + "url" : "http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-initialExpression", + "valueExpression" : { + "language" : "text/fhirpath", + "expression" : "Patient.birthDate" + } + } + ], + "linkId": "PR-birthdate", + "type": "date", + "text": "Date of Birth" + }, + { + "extension" : [ + { + "url" : "http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-initialExpression", + "valueExpression" : { + "language" : "text/fhirpath", + "expression" : "Patient.identifier.where(system='http://example.org/mrn').value" + } + } + ], + "linkId": "PR-name-id", + "type": "string", + "text": "Patient Id" + } + ] + } + ] +} +``` + +Some external examples use FHIR search queries (where `language` is `"application/x-fhir-query"`) - these are not well supported by the Structured Data Capture library as you must directly pass all resources needed for population. + +You can test expression-based population for the example questionnaire with this Patient resource: + +```json +{ + "resourceType": "Patient", + "identifier": [ + { + "system": "http://example.org/mrn", + "value": "abcd-efgh-ijkl-mnop" + } + ], + "active": true, + "name": [ + { + "family": "Ali", + "given": [ + "Salman" + ] + } + ], + "birthDate": "1968-09-17" +} +``` diff --git a/docs/use/SDCL/Customize-how-a-Questionnaire-is-displayed.md b/docs/use/SDCL/Customize-how-a-Questionnaire-is-displayed.md new file mode 100644 index 000000000..4ac8947ef --- /dev/null +++ b/docs/use/SDCL/Customize-how-a-Questionnaire-is-displayed.md @@ -0,0 +1,279 @@ +# Customize how a Questionnaire is displayed + +The Structured Data Capture Library provides default UI components and styles to +render FHIR Questionnaire resources based on +[Material Design](https://material.io/design) and the +[Structured Data Capture Implementation Guide](https://build.fhir.org/ig/HL7/sdc/). +In practice, you may want to customize how questionnaires look, and this guide +explains the two primary ways to do so: + +* Adjust the existing components to match the style of the rest of your + application +* Create new components to collect information in non-standard ways, e.g. + connected devices + +## Customize styles + +The Structured Data Capture Library includes a default +[theme](https://developer.android.com/guide/topics/ui/look-and-feel/themes) +[`Theme.Questionnaire`](https://github.com/google/android-fhir/blob/master/datacapture/src/main/res/values/styles.xml#L36) +which defines a number of custom attributes used to style questionnaires. For +example, `questionnaireQuestionTextStyle` defines the style for all question +text, and changing it affects all rendered questionnaires. The custom attributes +are: + +* `groupHeaderTextAppearanceQuestionnaire` +* `questionnaireQuestionTextStyle` +* `questionnaireSubtitleTextStyle` +* `questionnaireRadioButtonStyle` +* `questionnaireCheckBoxStyle` +* `questionnaireDropDownTextStyle` +* `questionnaireDropdownLayoutStyle` +* `questionnaireTextInputLayoutStyle` +* `questionnaireTextInputEditTextStyle` +* `questionnaireChipStyle` +* `questionnaireDialogTitleStyle` +* `questionnaireDialogButtonStyle` +* `questionnaireAddAnotherAnswerButtonStyle` +* `questionnaireErrorTextStyle` +* `questionnaireButtonStyle` +* `questionnaireSubmitButtonStyle` + +To customize the styles used to render questionnaires, create a theme with +different values for the custom attributes. A simple way to do this is to extend +the `Theme.Questionnaire` theme and override the attributes you want to change. + +For example, to change the appearance of question text, open your project's +`res/values/styles.xml` file and add a new theme that extends +`Theme.Questionnaire`, then set the `headerTextAppearanceQuestionnaire` +attribute to your preferred value: + +```xml + +``` + +Next, edit your application's default theme, typically in the +`res/values/themes.xml` file, and add the attribute `questionnaire_theme` set to +the new theme you just created: + +```xml + +``` + +## Custom Style per Question Item + +With this change, you can apply individual custom styles per question item. If a custom style is not mentioned in the question item, the default style will be applied, which is present in the DataCapture module or overridden in the application. + +### Add a Custom Style Extension to the Question Item + +```json +{ + "extension": [ + { + "url": "https://github.com/google/android-fhir/tree/master/datacapture/android-style", + "extension": [ + { + "url": "prefix_text_view", + "valueString": "CustomStyle_1" + }, + { + "url": "question_text_view", + "valueString": "CustomStyle_1" + }, + { + "url": "subtitle_text_view", + "valueString": "CustomStyle_2" + } + ] + } + ] +} +``` +### Custom Style Extension URL +"https://github.com/google/android-fhir/tree/master/datacapture/android-style" + +It identifies extensions for applying the custom style to a given questionnaire item. + +### Question Item View +* `prefix_text_view`: Used to show the prefix value of the question item. +* `question_text_view`: Used to show the text value of the question item. +* `subtitle_text_view`: Used to show the instructions of the question item. + For more information about supported views, please see the [Question Item View](https://github.com/google/android-fhir/blob/master/datacapture/src/main/java/com/google/android/fhir/datacapture/extensions/MoreQuestionnaireItemComponents.kt). + +### Custom Style Values +In the above example: + +`CustomStyle_1` is the custom style for prefix_text_view and question_text_view. +`CustomStyle_2` is the custom style for subtitle_text_view. +Both styles are defined in the application. + +### Custom Style Attributes +* `questionnaire_textAppearance`: Specifies the text appearance for the questionnaire text. Example: `@style/TextAppearance.AppCompat.Headline` +* `questionnaire_background`: Specifies the background for the view. Example: `@color/background_color or #FFFFFF` + +For more information on custom style attributes, please see the [QuestionnaireCustomStyle](https://github.com/google/android-fhir/blob/master/datacapture/src/main/res/values/attrs.xml) + +### Example Custom Styles + +``` + + + + + + + + +``` + +The above custom styles are defined in the `res/values/styles.xml` of the application. + +### questionnaire.json with custom style +``` +{ + "resourceType": "Questionnaire", + "item": [ + { + "linkId": "1", + "text": "Question text custom style", + "type": "display", + "extension": [ + { + "url": "https://github.com/google/android-fhir/tree/master/datacapture/android-style", + "extension": [ + { + "url": "prefix_text_view", + "valueString": "CustomStyle_1" + }, + { + "url": "question_text_view", + "valueString": "CustomStyle_1" + }, + { + "url": "subtitle_text_view", + "valueString": "CustomStyle_2" + } + ] + } + ], + "item": [ + { + "extension": [ + { + "url": "http://hl7.org/fhir/StructureDefinition/questionnaire-displayCategory", + "valueCodeableConcept": { + "coding": [ + { + "system": "http://hl7.org/fhir/questionnaire-display-category", + "code": "instructions" + } + ] + } + } + ], + "linkId": "1.3", + "text": "Instructions custom style.", + "type": "display" + } + ] + } ] +} +``` + +## Custom questionnaire components + +The Structured Data Capture Library uses +[custom UI components](https://github.com/google/android-fhir/tree/master/datacapture/src/main/java/com/google/android/fhir/datacapture/views) +to render questions in a questionnaire. There are predefined components for most +question item types so you do not need to do anything special for most +questions. However, if you want to render a question in a way not described in +the FHIR standard of the SDC implementation guide, you can create a custom +component. + +### Create a custom component + +This guide outlines the step-by-step process to create and incorporate custom widgets into FHIR questionnaires using the Android FHIR SDK. + +Note: Examples given below are all from the [catalog](https://github.com/google/android-fhir/tree/master/catalog) application in this repository. We recommend you try out the application to see these concepts in action. + +1. Create a layout for the Custom Component + + Design an XML layout file to define the visual structure and appearance of your custom widget. + + * Example: See this [example location_widget_view.xml](https://github.com/google/android-fhir/blob/master/contrib/locationwidget/src/main/res/layout/location_widget_view.xml). + +2. Create a `QuestionnaireItemViewHolderFactory` class + + Implement a class that extends [QuestionnaireItemViewHolderFactory](https://github.com/google/android-fhir/blob/master/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/QuestionnaireItemViewHolderFactory.kt#L35). This class will be responsible for creating and managing the view holder for your custom component. + + * Example: Check out this [example LocationWidgetViewHolderFactory](https://github.com/google/android-fhir/blob/master/contrib/locationwidget/src/main/java/com/google/android/fhir/datacapture/contrib/views/locationwidget/LocationWidgetViewHolderFactory.kt#L29). + + Within this class: + + * Pass the layout resource ID to the constructor. + * Override `getQuestionnaireItemViewHolderDelegate()` to return a `QuestionnaireItemViewHolderDelegate` implementation. This delegate should include: + * `init`: Initializes the `RecyclerView.ViewHolder`. + * `bind`: Binds data to the `RecyclerView.ViewHolder`. + * `displayValidationResult`: Displays validation feedback for user input. + * `setReadOnly`: Configures the UI for read-only mode. + +3. Create `QuestionnaireItemViewHolderFactoryMatcher` Objects + + For each custom `ViewHolderFactory`, create a corresponding factory matcher object - [QuestionnaireItemViewHolderFactoryMatcher](https://github.com/google/android-fhir/blob/master/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireFragment.kt#L538) + + * Example: Refer to these [matcher examples](https://github.com/google/android-fhir/blob/master/catalog/src/main/java/com/google/android/fhir/catalog/ContribQuestionnaireItemViewHolderFactoryMatchersProviderFactory.kt#L38C15-L45C16). + + Each matcher should define: + + * `factory`: The custom `ViewHolderFactory` instance. + * `matches`: A predicate function that takes a [`Questionnaire.QuestionnaireItemComponent`](https://hapifhir.io/hapi-fhir/apidocs/hapi-fhir-structures-r4/org/hl7/fhir/r4/model/Questionnaire.QuestionnaireItemComponent.html) and returns `true` if this factory is suitable for rendering that item. + +4. Create a `QuestionnaireItemViewHolderFactoryMatchersProviderFactory` + + Create a factory class that implements this interface. It acts as a central registry for your custom widget associations. + + * Example: This [example factory class](https://github.com/google/android-fhir/blob/master/catalog/src/main/java/com/google/android/fhir/catalog/ContribQuestionnaireItemViewHolderFactoryMatchersProviderFactory.kt) demonstrates this. + +5. Register the Factory in `DataCaptureConfig` + + In your `DataCaptureConfig` object, set the `questionnaireItemViewHolderFactoryMatchersProviderFactory` property to your custom factory instance. + + * Example: See how this is done in [this code](https://github.com/google/android-fhir/blob/master/catalog/src/main/java/com/google/android/fhir/catalog/CatalogApplication.kt#L42C5-L47C8). + +6. Use the Custom Widget in Your Questionnaire Fragment + + When building your `QuestionnaireFragment`, call the `setCustomQuestionnaireItemViewHolderFactoryMatchersProvider` method on the builder, providing the string identifier associated with your custom widget. + + * Example: This [usage example](https://github.com/google/android-fhir/blob/master/catalog/src/main/java/com/google/android/fhir/catalog/DemoQuestionnaireFragment.kt#L142C13-L150C23) shows how to set up the fragment. + + +## Localize questionnaires + +When rendering your questionnaire, the library will look for the +[translation extension](https://www.hl7.org/fhir/R4/languages.html##ext), and if +the lang element matches the application default locale, will use the value of +the content element of the extension instead of the text element of the +questionnaire item. You can also use the +[Locale.setDefault()](https://developer.android.com/reference/java/util/Locale#setDefault\(java.util.Locale\)) +method to manually set the locale to check against. diff --git a/docs/use/SDCL/Demo-app.md b/docs/use/SDCL/Demo-app.md new file mode 100644 index 000000000..db3f56c23 --- /dev/null +++ b/docs/use/SDCL/Demo-app.md @@ -0,0 +1,11 @@ +# Demo App + +![Catalog App Hero Image](https://user-images.githubusercontent.com/7772901/171482222-7b3a4b55-3cb5-4deb-bf25-bc1e44fe9017.png) + +The *Structured Data Capture Catalog* demonstrates the capabilities of Structured Data Capture Library. In the Structured Data Capture Catalog you can see and interact with components and layouts that are supported by the Structured Data Capture library. The components and layouts are interactive, so you can see various states, interactions and transitions. + +To try out the Structured Data Capture Catalog app, you can either run the catalog module in Android Studio or run the following Gradle command: + +```shell +./gradlew :catalog:installDebug +``` diff --git a/docs/use/SDCL/FHIR-specification-support.md b/docs/use/SDCL/FHIR-specification-support.md new file mode 100644 index 000000000..3cb7e9aad --- /dev/null +++ b/docs/use/SDCL/FHIR-specification-support.md @@ -0,0 +1,213 @@ +# FHIR specification support + +There are two specifications that serve as requirements for the library: + +* [FHIR Questionnaire](https://www.hl7.org/fhir/questionnaire.html) +* [Structured Data Capture Specification](http://hl7.org/fhir/uv/sdc/) + +In this section, we will provide details on how each requirement in these two specifications is supported by the library. + +## FHIR Questionnaire + +### Questionnaire item + +The following table summarizes the library's support for the key fields in the [questionnaire item element](https://www.hl7.org/fhir/questionnaire-definitions.html#Questionnaire.item). + +| | Support | Notes | +|:------------------|:-------:|:--------------------------------| +| linkId | ✅ | | +| definition | ✅ | See [Definition-based extraction](http://build.fhir.org/ig/HL7/sdc/extraction.html#definition-based-extraction) | +| code | ⛔️ | | +| prefix | ✅ | | +| text | ✅ | | +| type | ✅ | See [Questionnaire item type](#questionnaire-item-type) | +| enableWhen | ✅ | | +| enableBehavior | ✅ | | +| required | ✅ | | +| repeats | ✅ | | +| readOnly | ✅ | | +| maxLength | ✅ | For text only | +| answerConstraint | ⛔️ | | +| answerValueSet | ✅ | | +| answerOption | ✅ | | +| initial | ✅ | | +| item | ⚠️ | Items nested under group are supported (see [#726](https://github.com/google/android-fhir/issues/726)); items nested under non-group questions are not yet supported (see [#910](https://github.com/google/android-fhir/issues/910)). | + +### Questionnaire item type + +The following table summarizes the library's support for [questionnaire item types](https://www.hl7.org/fhir/codesystem-item-type.html). + +|Item type | Support | Example | Notes | +|:------------------|:-------:|:------------------------------------------------------------------------------------------------------------|:-------------------------------------------------------------| +| Group | ✅ | | | +| Display | ✅ | | | +| Question | ✅ | | See sub-types below | +| Boolean | ✅ | [JSON](https://github.com/google/android-fhir/blob/master/catalog/src/main/assets/component_boolean_choice.json) | | +| Decimal | ✅ | | | +| Integer | ✅ | | | +| Date | ✅ | [JSON](https://github.com/google/android-fhir/blob/master/catalog/src/main/assets/component_date_picker.json) | | +| Date time | ✅ | [JSON](https://github.com/google/android-fhir/blob/master/catalog/src/main/assets/component_date_time_picker.json) | | +| Time | ⛔️ | | | +| String | ✅ | | Rendered as single-line text box | +| Text | ✅ | | Rendered as multi-line text box | +| Url | ⛔️ | | | +| Choice | ✅ | | | +| ~~Open Choice~~ | ✅ | | Deprecated in R5. Use `answerConstraint` instead. | +| Attachment | ✅ | [JSON](https://github.com/google/android-fhir/blob/master/catalog/src/main/assets/component_attachment.json) | | +| Reference | ⛔️ | | | +| Quantity | ✅ | | | + +## Structured Data Capture Specification + +### Advanced form rendering + +This section summarizes the library's support for [advanced form rendering](http://build.fhir.org/ig/HL7/sdc/rendering.html). + +#### Text appearance + +| Code | Support | Example | Notes | +|:-------------------|:-------:|:--------|:-------------------------| +| rendering-style | ⛔️ | | | +| rendering-xhtml | ⛔️ | | | +| displayCatagory | ⚠️ | | `instructions` code only | +| openLabel | ⛔️ | | | +| hidden | ✅ | | | +| itemMedia | ✅ | [JSON](https://github.com/google/android-fhir/blob/master/catalog/src/main/assets/component_item_media.json) | | +| itemAnswerMedia | ✅ | | | + +#### Control appearance + +| Control appearance | Support | Notes | +|:-------------------|:--------|:------------------| +| itemControl | ✅ | See below | +| choiceOrientation | ✅ | | +| sliderStepValue | ✅ | | +| width | ⛔️ | | +| collapsible | ⛔️ | | + +##### Item control + +The [`itemControl`](https://build.fhir.org/ig/HL7/fhir-extensions/ValueSet-questionnaire-item-control.html) extension allows for the selection of a specific control widget for questionnaire items. See [example usage](http://build.fhir.org/ig/HL7/sdc/examples.html#itemControl). Item controls are only compatible with certain questionnaire item types (e.g. drop down item control cannot be applied to boolean type questions). This is listed in the `Compatible item type` column in the following table. Unsupported item controls are omitted in the table below. + +| Item control | Compatible item type | Example | Notes | Image | +|:--------------|:---------------------|:--------|:----------------------------------------------|:-----:| +| Page | Group | [JSON](https://github.com/google/android-fhir/blob/master/catalog/src/main/assets/layout_paginated.json) | | | +| Fly-over | Display | | | +| Help-Button | Display | [JSON](https://github.com/google/android-fhir/blob/master/catalog/src/main/assets/component_help.json) | | | +| Auto-complete | Choice | [JSON](https://github.com/google/android-fhir/blob/master/catalog/src/main/assets/component_auto_complete.json) | Filter-forward based on inlined answerOptions | | +| Drop down | Choice | [JSON](https://github.com/google/android-fhir/blob/master/catalog/src/main/assets/component_dropdown.json) | Default for 4 or more answer options | ![Drop Down](sdc-ref-dropdown.png) | +| Check-box | Choice | [JSON](https://github.com/google/android-fhir/blob/master/catalog/src/main/assets/component_multi_select_choice.json) | Default for repeat answers | | +| Radio Button | Choice | [JSON](https://github.com/google/android-fhir/blob/master/catalog/src/main/assets/component_single_choice.json) | Default for less than 4 answer options | | +| Slider | Integer | [JSON](https://github.com/google/android-fhir/blob/master/catalog/src/main/assets/component_slider.json) | | | + +#### Additional display content + +| Code | Support | Notes | +|:-------------------|:-------:|:----------------------------------------------------------------| +| supportLink | ⛔️ | | +| choiceColumn | ⛔️ | | +| optionPrefix | ⛔️ | | +| valueset-label | ⛔️ | | +| entryFormat | ⚠️ | Used in SDC for user guidance, not for validation. However, we use it to validate date where appropriate. See [#1850](https://github.com/google/android-fhir/issues/1850). | +| shortText | ⛔️ | | + +#### Other + +| Code | Support | Notes | +|:-------------------------|:-------:|:-----------------------------------| +| required | ✅ | | +| repeats | ✅ | | +| readOnly | ✅ | | +| rendering-styleSensitive | ⛔️ | | +| optionalDisplay | ⛔️ | | + +### Enhanced behavior + +This section summarizes the library's support for [form behavior and calculation](http://hl7.org/fhir/uv/sdc/behavior.html). + +#### Value constraints + +The library supports the following [value constraints](http://hl7.org/fhir/uv/sdc/behavior.html#value-constraints) which limit the allowed values for answers. + +| Value constraint | Support | Notes | +|:-------------------|:-------:|:--------------| +| maxLength | ✅ | For text only | +| minLength | ✅ | For text only | +| regex | ✅ | | +| minValue | ✅ | | +| maxValue | ✅ | | +| minQuantity | ⛔️ | | +| maxQuantity | ⛔️ | | +| maxDecimalPlaces | ✅ | | +| mimeType | ⛔️ | | +| maxSize | ⛔️ | | + +#### Choice restriction + +The library supports the following [choice restrictions](http://hl7.org/fhir/uv/sdc/behavior.html#choice-restriction). Note some of these are already covered in [Questionnaire item](#questionnaire-item) support. + +| Choice restriction | Support | Notes | +|:---------------------|:-------:|:--------------------------------| +| answerOption | ✅ | | +| answerValueSet | ✅ | | +| answerExpression | ✅ | | +| required | ✅ | | +| repeats | ✅ | | +| readOnly | ✅ | | +| minOccurs | ⛔️ | | +| maxOccurs | ⛔️ | | +| optionExclusive | ✅ | | +| unitOption | ⛔️ | | +| unitValueSet | ⛔️ | | +| referenceResource | ⛔️ | | +| referenceProfile | ⛔️ | | +| candidateExpression | ✅ | | +| lookupQuestionnaire | ⛔️ | | + +#### Calculations + +| Code | Support | Example | Notes | +|:---------------------|:-------:|:--------|:-----------------------------------| +| cqf-library | ⛔️ | | | +| launchContext | ✅ | | | +| variable | ✅ | | | +| initialExpression | ✅ | | | +| calculatedExpression | ✅ | [JSON](https://github.com/google/android-fhir/blob/master/catalog/src/main/assets/behavior_calculated_expression.json) | +| cqf-calculatedValue | ⛔️ | | | +| cqf-expression | ⚠️ | [JSON](https://github.com/google/android-fhir/blob/master/catalog/src/main/assets/behavior_dynamic_question_text.json) | `text` only | + +#### Other control + +| Code | Support | Notes | +|:---------------------|:-------:|:-----------------------------------| +| entryMode | ✅ | | +| intial | ✅ | | +| enableWhen | ✅ | | +| enableWhenBehavior | ✅ | | +| enableWhenExpression | ✅ | | +| usageMode | ⛔️ | | +| constraint | ⛔️ | | +| endpoint | ⛔️ | | +| signatureRequired | ⛔️ | | +| ordinalValue | ⛔️ | | +| text | ⛔️ | | + +## Expression extensions + +This section summarizes the library's support for [expressions](http://build.fhir.org/ig/HL7/sdc/expressions.html). + +| Name | Support | Example | Notes | +| ---------------------------- | :-----: | :------ | ----------------------------------------------------------- | +| variable | ✅ | | | +| answerExpression | ✅ | | | +| initialExpression | ✅ | | | +| candidateExpression | ⚠️ | | [#1038](https://github.com/google/android-fhir/issues/1038) | +| contextExpression | ⛔️ | | | +| calculatedExpression | ✅ | [JSON](https://github.com/google/android-fhir/blob/master/catalog/src/main/assets/behavior_calculated_expression.json) | | +| enableWhenExpression | ✅ | [JSON](https://github.com/google/android-fhir/blob/master/catalog/src/main/assets/behavior_skip_logic.json) | | +| answerOptionToggleExpression | ⛔️ | | | +| itemPopulationContext | ⛔️ | | | +| itemExtractionContext | ⛔️ | | | +| constraint | ⛔️ | | | +| library | ⛔️ | | [#1060](https://github.com/google/android-fhir/issues/1060) | +| launchContext | ✅ | | | diff --git a/docs/use/SDCL/Getting-Started.md b/docs/use/SDCL/Getting-Started.md new file mode 100644 index 000000000..3d20bf071 --- /dev/null +++ b/docs/use/SDCL/Getting-Started.md @@ -0,0 +1,179 @@ +# Getting Started + +This page describes how to configure an Android Studio project to use the +Structured Data Capture Library, and simple examples of how to use the library. + +This guide is intended for developers who are familiar with basic Android +development with Kotlin, and proficient in working with +[FHIR](http://hl7.org/fhir/) concepts and resources. + +## Dependencies + +The Structured Data Capture Library is available through +[Google's Maven repository](https://maven.google.com/web/index.html), which +should be already configured for Android projects created in Android Studio 3.0 +and higher. + +Add the following dependencies to your module's app-level `build.gradle` file, +typically `app/build.gradle`: + +```gradle +dependencies { + // ... + + implementation 'com.google.android.fhir:data-capture:1.0.0' + implementation 'androidx.fragment:fragment-ktx:1.5.5' +} +``` + +The minimum API level supported is 24. The library also requires +[Android Gradle Plugin](https://developer.android.com/studio/releases/gradle-plugin) +version 4.0.0 or later for +[Java 8+ API desugaring support](https://developer.android.com/studio/write/java8-support#library-desugaring). + +The following examples assume you already have a +[FHIR questionnaire](https://www.hl7.org/fhir/questionnaire.html) as a JSON +file. If you need one, the FHIR specification has +[several examples](https://www.hl7.org/fhir/questionnaire-examples.html) like +[this one](https://www.hl7.org/fhir/questionnaire-example-f201-lifelines.json). + +## Display a Questionnaire + +`QuestionnaireFragment` is the interface for working with +[FHIR questionnaires](https://www.hl7.org/fhir/questionnaire.html), and also a +[fragment](https://developer.android.com/guide/fragments) for rendering them. +See the QuestionnaireFragment guide for details. To render a questionnaire in +your app, follow these steps: + +1. Add a `FragmentContainerView` to your activity's layout to contain the + Questionnaire. + + ```xml + + + + + + + ``` + +1. Create a bundle with the JSON questionnaire content for the fragment, in + this case as a `String` read from a JSON file stored in the `assets` folder. + + ```kotlin + // Small questionnaires can be read directly as a string, larger ones + // should be passed as a URI instead. + val questionnaireJsonString = + application.assets.open("questionnaire.json") + .bufferedReader().use { it.readText() } + + val bundle = bundleOf( QuestionnaireFragment.EXTRA_QUESTIONNAIRE_JSON_STRING to + questionnaireJsonString ) + ``` + +1. Set the fragment to the `FragmentContainerView`. + + ```kotlin + if (savedInstanceState == null) { + supportFragmentManager.commit { + setReorderingAllowed(true) + add(R.id.fragment_container_view, args = bundle) + } + } + ``` + +## Get a QuestionnaireResponse + +The `getQuestionnaireResponse` function of `QuestionnaireFragment` returns the +current form answers in a +[FHIR QuestionnaireResponse](http://www.hl7.org/fhir/questionnaireresponse.html), +specifically as an instance of the +[HAPI FHIR Structures data model's QuestionnaireResponse](https://hapifhir.io/hapi-fhir/apidocs/hapi-fhir-structures-r4/org/hl7/fhir/r4/model/QuestionnaireResponse.html). +This provides a +[convenient interface](https://hapifhir.io/hapi-fhir/docs/model/working_with_resources.html) +to work with the data: + +```kotlin +val fragment: QuestionnaireFragment = + supportFragmentManager.findFragmentById(R.id.fragment_container_view) as QuestionnaireFragment +val questionnaireResponse = fragment.getQuestionnaireResponse() + +// For example, convert the whole response to a string +val questionnaireResponseString = context.newJsonParser().encodeResourceToString(questionnaireResponse) +Log.d( + "response", questionnaireResponseString +) +``` + +This could be in a callback for the user after pressing a Submit button. + +## Extract FHIR Resources from a QuestionnaireResponse + +`ResourceMapper` converts data between a QuestionnaireResponse and other FHIR +resources by implementing data +[extraction](http://build.fhir.org/ig/HL7/sdc/extraction.html) and +[population](http://build.fhir.org/ig/HL7/sdc/populate.html) from the +[HL7 Structured Data Capture Implementation Guide](http://build.fhir.org/ig/HL7/sdc/). +See the ResourceMapper guide for more information. + +The Structured Data Capture Library supports two mechanisms for data extraction: +[Definition-based extraction](http://hl7.org/fhir/uv/sdc/extraction.html#definition-based-extraction) +and +[StuctureMap-based extraction](http://hl7.org/fhir/uv/sdc/extraction.html#structuremap-based-extraction). + +Both methods require a `Questionnaire` and corresponding +`QuestionnaireResponse`: + +```kotlin +val jsonParser = FhirContext.forCached(FhirVersionEnum.R4).newJsonParser() + +val myQuestionnaire = + jsonParser.parseResource(questionnaireJson) as Questionnaire + +// also need the QuestionnaireResponse, probably like this +val myQuestionnaireResponse = fragment.getQuestionnaireResponse() +``` + +For Definition-based extraction, just pass the `Questionnaire` and +`QuestionnaireResponse` to `ResourceMapper.extract()`. + +```kotlin +lifecycle.coroutineScope.launch { + val bundle = + ResourceMapper.extract(questionnaire, questionnaireResponse) +} +``` + +StructureMap-based Extraction also requires a `StructureMapExtractionContext` +that contains the StructureMap. For example, if your StructureMap is written in +the [FHIR Mapping Language](http://hl7.org/fhir/R4/mapping-language.html): + +```kotlin +val mappingStr = "map ..." +lifecycle.coroutineScope.launch { + val bundle = + ResourceMapper.extract( + myQuestionnaire, + myQuestionnaireResponse, + StructureMapExtractionContext(context = applicationContext) { _, worker -> + StructureMapUtilities(worker).parse(mappingStr, "") + }, + ) +} +``` + +The `extract` function is a suspend function, so these examples must be called +from an appropriate +[coroutine](https://developer.android.com/kotlin/coroutines). + +## Further reading + +* [Use QuestionnaireFragment](Use-QuestionnaireFragment.md) +* [Use ResourceMapper](Use-ResourceMapper.md) +* [Customize how a Questionnaire is displayed](Customize-how-a-Questionnaire-is-displayed.md) +* [Reference external data](Reference-external-data.md) diff --git a/docs/use/SDCL/Reference-external-data.md b/docs/use/SDCL/Reference-external-data.md new file mode 100644 index 000000000..dee42706d --- /dev/null +++ b/docs/use/SDCL/Reference-external-data.md @@ -0,0 +1,33 @@ +# Reference external data + +A questionnaire may reference external information. If so, you must provide a `DataCaptureConfig` by implementing the `DataCaptureConfig.Provider` interface in your application's [Application](https://developer.android.com/reference/android/app/Application) class. Be sure to provide the same configuration throughout your application's lifecycle to avoid any configuration issues. + +## Value sets + +If a value set is not included as a [contained resource](http://www.hl7.org/fhir/references.html#contained) within the questionnaire or structure map that references it, you must provide an implementation of `ExternalAnswerValueSetResolver.resolve()` which describes how to translate a value set's canonical URL to the list of actual codes: + +``` +class FhirApplication : Application(), DataCaptureConfig.Provider { + + private val dataCaptureConfiguration by lazy { + DataCaptureConfig( + valueSetResolverExternal = + object : ExternalAnswerValueSetResolver { + override suspend fun resolve(uri: String): List { + return lookupCodesFromDb(uri) + } + } + ) + } + + override fun getDataCaptureConfig(): DataCaptureConfig { + return dataCaptureConfiguration + } +} +``` + +`ExternalAnswerValueSetResolver` is used for any URI that does not look like an internal reference where the URI starts with `#`. + +## FHIR NPM packages + +FHIR resources published in [FHIR Packages](https://confluence.hl7.org/display/FHIR/NPM+Package+Specification) can be used to provide context while performing StructureMap-based data extraction. diff --git a/docs/use/SDCL/Use-QuestionnaireFragment.md b/docs/use/SDCL/Use-QuestionnaireFragment.md new file mode 100644 index 000000000..81a7d4e02 --- /dev/null +++ b/docs/use/SDCL/Use-QuestionnaireFragment.md @@ -0,0 +1,181 @@ +# Use `QuestionnaireFragment` + +`QuestionnaireFragment` is the main interface for displaying FHIR Questionnaires +and getting user responses as FHIR QuestionnareResponses. In this section, we +will discuss how to use `QuestionnaireFragment` in more detail. To use +`QuestionnaireFragment` effectively, you should also be familiar with using +[Fragments in Android](https://developer.android.com/guide/fragments). + +## Display a Questionnaire in your application + +Place a `QuestionnaireFragment` in your activity or fragment whenever you want +to display a questionnaire. First, add a FragmentContainerView to your +activity's layout to contain the rendered Questionnaire, as shown in the +following example: + +```xml + +``` + +Next, define the content of the FHIR Questionnaire to render. The Questionnaire +must be JSON-encoded and can be provided as a `String` or a `URI` to a JSON +file. These values are provided in `QuestionnaireFragment.Builder` used to create a +`QuestionnaireFragment`. + +The following example reads a file in the `assets/` directory to a `String` which will be passed to the `QuestionnaireFragment`. + +```kotlin +val questionnaire: String = + application.assets.open("questionnaire.json").bufferedReader().use { it.readText() } +``` + +Providing a `String` is only suitable for Questionnaires that are a few KBs in +size, or +[you may get a TransactionTooLargeException exception](https://developer.android.com/guide/components/activities/parcelables-and-bundles#sdba). +For larger FHIR Questionnaires, use the URI-based option below. + +Alternatively, you may use a `URI` pointing to a JSON file containing the +questionnaire. The example below creates a `URI` for a questionnaire file stored +as a raw resource. + +```kotlin +val questionnaire: Uri = + Uri.parse("android.resource://" + this.packageName + "/" + R.raw.questionnaire) +``` + +How you get or create a `URI` for your questionnaire will depend on your data +source. You cannot get a `URI` for a file in the `assets/` directory. However, +you can use the following code to create a temporary copy and get a `URI` for +it. + +```kotlin +/** +* Returns a [Uri] pointing to a temporary file containing the content of the asset file with +* [filename]. +*/ +private suspend fun createUri(filename: String) = + withContext(backgroundContext) { + val application = getApplication() + val outputFile = File(application.externalCacheDir, filename) + application.assets.open(filename).use { inputStream -> + outputFile.outputStream().use { outputStream -> IOUtils.copy(inputStream, outputStream) } + } + Uri.fromFile(outputFile) + } +``` + +Use `QuestionnaireFragment.Builder` to create the `QuestionnaireFragment`. You must call one of `setQuestionnaire(questionnaireJson: String)` or +`setQuestionnaire(questionnaireUri: Uri)` in `QuestionnaireFragment.Builder`. If neither is provided, the system throws an `IllegalStateException` exception. If +both are provided, `setQuestionnaire(questionnaireUri: Uri)` takes precedence and +`setQuestionnaire(questionnaireJson: String)` is ignored. + +In a real application, the FHIR Questionnaire JSON can be loaded from a file +(like in the examples above), a database, the network, or even hardcoded, +depending on your use case. If you are using any I/O operations to load the +JSON, it should be done asynchronously such as by using +[coroutines](https://developer.android.com/kotlin/coroutines). + +Finally, use +[FragmentManager](https://developer.android.com/guide/fragments/fragmentmanager) +to programmatically set the fragment to the FragmentContainerView. + +```kotlin +if (savedInstanceState == null) { + supportFragmentManager.commit { + setReorderingAllowed(true) + add( + R.id.fragment_container_view, + QuestionnaireFragment.builder().setQuestionnaire(questionnaire).build(), + QUESTIONNAIRE_FRAGMENT_TAG + ) + } +} +``` + +This example shows the minimal code to +[add a fragment programmatically](https://developer.android.com/guide/fragments/create#add-programmatic), +but Android strongly recommends using the +[Navigation library](https://developer.android.com/guide/navigation) to manage +navigation in a real app. + +## Collect a Questionnaire response + +Once the user has finished filling out the questionnaire, you can collect the +questionnaire response. Typically you should use `FragmentManager` to find your +questionnaire fragment and then call `getQuestionnaireResponse()`. + +```kotlin +val fragment: QuestionnaireFragment = + supportFragmentManager.findFragmentById(R.id.fragment_container_view) as QuestionnaireFragment +val questionnaireResponse = fragment.getQuestionnaireResponse() +``` + +The form answers are structured as a +[FHIR QuestionnaireResponse](http://www.hl7.org/fhir/questionnaireresponse.html), +which is a structured set of answers to the questions in the corresponding FHIR +Questionnaire. More specifically, it an instance of +[org.hl7.fhir.r4.model.QuestionnaireResponse](https://hapifhir.io/hapi-fhir/apidocs/hapi-fhir-structures-r4/org/hl7/fhir/r4/model/QuestionnaireResponse.html), +a class in the [HAPI FHIR](https://hapifhir.io/) +[Structures library](https://hapifhir.io/hapi-fhir/apidocs/hapi-fhir-structures-r4/) +which is used as the in-memory data model for FHIR resources in the SDK. This +provides a relatively convenient interface to work with the data: + +```kotlin +// Get the first answer for the first item +val questionnaireResponseFirstAnswer = + questionnaireResponse.item[0].answer[0].value.asStringValue() + +// Convert the whole response to a JSON string and send to log +val jsonParser = FhirContext.forCached(FhirVersionEnum.R4).newJsonParser() +val questionnaireResponseString = jsonParser.encodeResourceToString(questionnaireResponse) +Log.d( "response", questionnaireResponseString) + +``` + +Learn more about +[working with HAPI FHIR resources](https://hapifhir.io/hapi-fhir/docs/model/working_with_resources.html). + +What you do with the questionnaire response depends on your use case: + +* If your existing application does not store data using FHIR, you can + navigate the response's item and answer structure to get and store responses + in your app's native format. +* If the questionnaire you are using implements + [form data extraction](http://build.fhir.org/ig/HL7/sdc/extraction.html), + you can use Structured Data Capture Library's `ResourceMapper` to extract + additional FHIR resources based on the questionnaire response. + +## Pre-fill a questionnaire with existing answers + +If you want your questionnaire to start with some answers already filled, +include a questionnaire response in your arguments bundle for your +`QuestionnaireFragment`. + +```kotlin +val questionnaireFragment = + QuestionnaireFragment.builder() + .setQuestionnaire(questionnaire) + .setQuestionnaireResponse(questionnaireResponse) + .build() +``` + +This may be useful if, for example, you allow a user to edit a form they have +previously submitted, or to pre-fill certain fields where you already know the +value based on another source. + +Provide one of `setQuestionnaire(questionnaireJson: String)` or +`setQuestionnaire(questionnaireUri: Uri)` in the QuestionnaireFragment.Builder. + If both are provided, `setQuestionnaire(questionnaireUri: Uri)` takes precedence and +`setQuestionnaire(questionnaireJson: String)` is ignored. + +Similar to questionnaires, do not pass questionnaire responses over a few KBs in +size as a `String` in `setQuestionnaireResponse(questionnaireResponseJson: String)`, + but instead provide a `URI` using `setQuestionnaireResponse(questionnaireResponseUri: Uri)`. + +## Further reading + +* [Customize how a questionnaire is displayed](Customize-how-a-Questionnaire-is-displayed.md) diff --git a/docs/use/SDCL/Use-QuestionnaireResponseValidator.md b/docs/use/SDCL/Use-QuestionnaireResponseValidator.md new file mode 100644 index 000000000..94a931c58 --- /dev/null +++ b/docs/use/SDCL/Use-QuestionnaireResponseValidator.md @@ -0,0 +1,7 @@ +# Use `QuestionnaireResponseValidator` + +The Structured Data Capture Library provides an API to validate a FHIR QuestionnaireResponse resource against the FHIR Questionnaire resource it is answering. + +```kotlin +val validationResult = QuestionnaireResponseValidator.validate(questionnaire, questionnaireResponse, context) +``` diff --git a/docs/use/SDCL/Use-ResourceMapper.md b/docs/use/SDCL/Use-ResourceMapper.md new file mode 100644 index 000000000..d42d6a6f2 --- /dev/null +++ b/docs/use/SDCL/Use-ResourceMapper.md @@ -0,0 +1,157 @@ +# Use `ResourceMapper` + +Mapping FHIR Questionnaire Responses to other FHIR resources (and back) allows +the structured data capture process to be more tightly integrated with clinical +workflows. + +For example, if your application has a questionnaire for new patient +registration, your ultimate goal may be to create a +[FHIR Patient resource](https://www.hl7.org/fhir/patient.html) based on the +answers provided to use in your application. Or, if your application has a +questionnaire for entering test results, you could create a FHIR Observation +resource. The process of mapping a FHIR QuestionnaireResponse to one or more +other FHIR resources is called +[extraction](http://hl7.org/fhir/uv/sdc/extraction.html). + +On the other hand, you may want to reduce data entry by loading values from +existing FHIR resources into your questionnaire. For example, if a questionnaire +asks for a patient's name and age, you can pre-populate that information from an +existing FHIR Patient resource. The process of mapping one or more FHIR +resources to a FHIR QuestionnaireResponse is called +[population](http://hl7.org/fhir/uv/sdc/populate.html). + +This section shows how to use the +[`ResourceMapper`](https://github.com/google/android-fhir/blob/master/datacapture/src/main/java/com/google/android/fhir/datacapture/mapping/ResourceMapper.kt) +class from the Structured Data Capture Library to perform extraction and +population. + +## Extract FHIR resources from a questionnaire response + +The Structured Data Capture Library supports two mechanisms for data extraction: +[Definition-based extraction](http://hl7.org/fhir/uv/sdc/extraction.html#definition-based-extraction) +and +[StuctureMap-based extraction](http://hl7.org/fhir/uv/sdc/extraction.html#structuremap-based-extraction). + +Both methods of extraction require a HAPI FHIR `Questionnaire` that includes +data extraction FHIR extensions, plus a corresponding `QuestionnaireResponse` +containing the answers to extract. You can use HAPI FHIR's +[`JsonParser.parseResource()`](https://hapifhir.io/hapi-fhir/apidocs/hapi-fhir-base/ca/uhn/fhir/parser/JsonParser.html#parseResource\(ca.uhn.fhir.parser.json.JsonLikeStructure\)) +from +[`FhirContext.newJsonParser()`](https://hapifhir.io/hapi-fhir/apidocs/hapi-fhir-base/ca/uhn/fhir/context/FhirContext.html#newJsonParser\(\)) +to deserialize your JSON content to a questionnaire: + +```kotlin +val jsonParser = FhirContext.forCached(FhirVersionEnum.R4).newJsonParser() + +// From a JSON string +val myQuestionnaire = + jsonParser.parseResource(questionnaireJsonString) as Questionnaire + +// From a URI for a JSON file +val myQuestionnaire = + jsonParser.parseResource(contentResolver.openInputStream(questionnaireUri)) as Questionnaire +``` + +The `QuestionnaireResponse` you use with data extraction is likely from calling +`QuestionnaireFragment.getQuestionnaireResponse()` after your user fills out a +questionnaire: + +```kotlin +val myQuestionnaireResponse = fragment.getQuestionnaireResponse() +``` + +[Learn more about QuestionnaireFragment](Use-QuestionnaireFragment.md) +and how to collect responses from a questionnaire. + +Next, call `ResourceMapper.extract()` to extract the FHIR resources. It returns +a +[HAPI FHIR Bundle](https://hapifhir.io/hapi-fhir/apidocs/hapi-fhir-structures-r4/org/hl7/fhir/r4/model/Bundle.html) +containing one or more FHIR resources as specified by the data extraction FHIR +extensions in the questionnaire. Note that `ResourceMapper.extract()` is a +suspend function, so the following examples must be called from an appropriate +[coroutine](https://developer.android.com/kotlin/coroutines), such as +[`ViewModelScope` if you are using a ViewModel in your app](https://medium.com/androiddevelopers/easy-coroutines-in-android-viewmodelscope-25bffb605471). + +### Definition-based extraction + +For Definition-based extraction, all the metadata required to perform data +extraction are within the questionnaire, so no additional information is +necessary. Simply pass the `Questionnaire` and `QuestionnaireResponse` to +`ResourceMapper.extract()`: + +```kotlin +val bundle = ResourceMapper.extract(questionnaire, questionnaireResponse) +``` + +### StructureMap-based extraction + +In addition to the Questionnaire and QuestionnaireResponse, StructureMap-based +Extraction also requires a `StructureMapExtractionContext` to retrieve a +StructureMap. To create a `StructureMapExtractionContext`, pass your +application's context and provide a `structureMapProvider` lambda function which +returns the `StructureMap`. + +The function includes a String parameter which contains the canonical URL for +the Structure Map referenced in the +[Target structure map extension](http://hl7.org/fhir/uv/sdc/StructureDefinition-sdc-questionnaire-targetStructureMap.html) +of the questionnaire, and a HAPI FHIR +[`IWorkerContext`](https://hapifhir.io/hapi-fhir/apidocs/hapi-fhir-structures-r4/org/hl7/fhir/r4/context/IWorkerContext.html) +which may be used with other HAPI FHIR classes, like +[`StructureMapUtilities`](https://hapifhir.io/hapi-fhir/apidocs/hapi-fhir-structures-r4/org/hl7/fhir/r4/utils/StructureMapUtilities.html). + +A StructureMap may be written in the +[FHIR Mapping Language](http://hl7.org/fhir/R4/mapping-language.html) or as a +[StructureMap FHIR resource](http://hl7.org/fhir/R4/structuremap.html). + +For example, if your StructureMap is hard-coded and written in the FHIR Mapping +Language: + +```kotlin +val mappingStr = "map ..." +val bundle = ResourceMapper.extract( + myQuestionnaire, + myQuestionnaireResponse, + StructureMapExtractionContext(context = applicationContext) { _, worker -> + StructureMapUtilities(worker).parse(mapping, "") + }, +) +``` + +As another example, this code uses a StructureMap resource as JSON based on its +URL: + +```kotlin +val bundle = ResourceMapper.extract( + myQuestionnaire, + myQuestionnaireResponse, + StructureMapExtractionContext(context = applicationContext) { targetStructureMapUrl, _ -> + val structureMapJson = getJsonFromUrl(targetStructureMapUrl) + jsonParser.parseResource(mappingJson) as StructureMap + ) + }, +) +``` + +## Populate a questionnaire response based on other FHIR resources + +The Structured Data Capture Library supports +[expression-based population](http://hl7.org/fhir/uv/sdc/populate.html#expression-based-population). +If a questionnaire implements the +[SDC Questionnaire Populate - Expression profile](http://hl7.org/fhir/uv/sdc/StructureDefinition-sdc-questionnaire-pop-exp.html), +then you can use `ResourceMapper.populate()` to generate a questionnaire +response based on the values in other FHIR resources: + +```kotlin +val questionnaireResponse = ResourceMapper.populate(questionnaire, resource1, resource2, ...) +``` + +In this example, `resource1, resource2, ...` are FHIR resources represented by +instances of +[HAPI FHIR Structures](https://hapifhir.io/hapi-fhir/apidocs/hapi-fhir-structures-r4/org/hl7/fhir/r4/model/package-summary.html). +To understand which resources you should include, look at the expression-based +population extensions used in the questionnaire, most often the +[Initial Expression extension](https://hl7.org/fhir/uv/sdc/StructureDefinition-sdc-questionnaire-initialExpression.html). + +`ResourceMapper.populate()` returns a `QuestionnaireResponse` which can be used +to +[pre-fill a questionnaire with existing answers](Use-QuestionnaireFragment.md#pre-fill-a-questionnaire-with-existing-answers). diff --git a/docs/use/SDCL/index.md b/docs/use/SDCL/index.md new file mode 100644 index 000000000..b731e0302 --- /dev/null +++ b/docs/use/SDCL/index.md @@ -0,0 +1,30 @@ +# What is the Structured Data Capture Library? + +The Structured Data Capture Library lets you easily build Android applications that capture and process healthcare data using the [FHIR Questionnaire](https://www.hl7.org/fhir/questionnaire.html). + +## Key Capabilities + +* **Render FHIR Questionnaires** Displays the questionnaire for you in a [Fragment](https://developer.android.com/guide/fragments) using consistent, [Material](https://material.io/)-based components +* **Standards-based form responses** Answers are consistently formatted as a [FHIR QuestionnaireResponse](http://www.hl7.org/fhir/questionnaireresponse.html) +* **Validate answers** Validate individual fields during user entry or entire questionnaire responses +* **Extract additional FHIR resources** Convert the QuestionnaireResponse to other FHIR resources to populate a FHIR-based health record +* **Powerful form controls** Advanced rendering and form behaviors such as skip-logic, pagination, runtime validation and multi-language support based on the [HL7 Structured Data Capture](http://build.fhir.org/ig/HL7/sdc/) implementation guide +* **Custom look and feel** Supports custom themes or creating your own question widgets + +## Why would you use the Structured Data Capture Library? + +Forms to collect data are a very common component of healthcare applications today. When building a typical Android application you need to design and code the business logic and UI for each form you create. This quickly becomes unsustainable for the many different forms used in a modern healthcare environment. + +With the Structured Data Capture Library, forms are defined using a [FHIR Questionnaire](https://www.hl7.org/fhir/questionnaire.html) and rendered for you. This separates form design from application development, allowing forms to be easily added, shared, and updated without building UI layouts, and healthcare data experts can author questionnaires without any knowledge of Android development. + +The Structured Data Capture Library also simplifies the process of creating FHIR resources based on form answers and using existing FHIR data to pre-populate forms. These two processes make data collected using FHIR Questionnaires more meaningful and useful in clinical workflows and enable more advanced use cases. + +## How does the Structured Data Capture Library work? + +There are three main public APIs in the Structured Data Capture Library: + +* [QuestionnaireFragment](Use-QuestionnaireFragment.md): the main class for rendering questionnaires +* [ResourceMapper](Use-ResourceMapper.md): handles data extraction and questionnaire population +* [QuestionnaireResponseValidator](Use-QuestionnaireResponseValidator.md): validates questionnaire responses against questionnaires + +You can also browse the pages in the navigation sidebar for more information. diff --git a/docs/use/SDCL/sdc-lib-intro-1.png b/docs/use/SDCL/sdc-lib-intro-1.png new file mode 100644 index 000000000..138f827c0 Binary files /dev/null and b/docs/use/SDCL/sdc-lib-intro-1.png differ diff --git a/docs/use/SDCL/sdc-lib-intro-2.png b/docs/use/SDCL/sdc-lib-intro-2.png new file mode 100644 index 000000000..ec6a10a73 Binary files /dev/null and b/docs/use/SDCL/sdc-lib-intro-2.png differ diff --git a/docs/use/SDCL/sdc-lib-workflow.png b/docs/use/SDCL/sdc-lib-workflow.png new file mode 100644 index 000000000..9ba7dcc37 Binary files /dev/null and b/docs/use/SDCL/sdc-lib-workflow.png differ diff --git a/docs/use/SDCL/sdc-ref-dropdown.png b/docs/use/SDCL/sdc-ref-dropdown.png new file mode 100644 index 000000000..3b0bb062f Binary files /dev/null and b/docs/use/SDCL/sdc-ref-dropdown.png differ diff --git a/docs/use/WFL/Compile-and-Execute-CQL.md b/docs/use/WFL/Compile-and-Execute-CQL.md new file mode 100644 index 000000000..863506c2d --- /dev/null +++ b/docs/use/WFL/Compile-and-Execute-CQL.md @@ -0,0 +1,127 @@ +# Compile CQL + +The `FhirOperator` allows the user to load, compile and run a CQL library completely off-line. This example uses CQL to evaluate if a given patient has completed an immunization schedule. + +With a CQL file + +```cql +val cqlLibStr = + """ +// Declare the name and version of the Library of functions +library DDCCPass version '1.0.0' + +// The version of FHIR we are using +using FHIR version '4.0.1' + +// Execute all business rules relative to a specific Patient content +context Patient + +// Define boolean valued business rule to check if there is an immunization that was completed +define "Completed Immunization": exists ("Completed Immunizations") + +// Define a list of completed immunizations for which the dose number is the same as the series dose +// Immunization resources are queried from those that reference the Patient we are executing against +define "Completed Immunizations": + [Immunization] I + where (I.protocolApplied.doseNumber.value = I.protocolApplied.seriesDoses.value) +""".trimIndent() +``` + +Assemble a FHIR Library and add the CQL as an Attachment: + +```kotlin +val library = Library().apply { + id = "DDCCPass-1.0.0" + name = "DDCCPass" + version = "1.0.0" + status = Enumerations.PublicationStatus.ACTIVE + experimental = true + url = "http://localhost/Library/DDCCPass|1.0.0" + addContent( + Attachment().apply { + contentType = "text/cql" + data = cqlLibStr.toByteArray() + } + ) +} +``` + +Keep in mind that Library `id`, `name` and `version` in the JSON must match the library `name` and `version` definitions in the CQL file. + +By only adding the original CQL source code into the Library object, the FHIR Operator will compile the CQL before running the evaluate operation. The compiled version will remain in memory for subsequent calls. + +Then with a Patient and Immunization records + +```kotlin +val patientStr = + """ + { + "resourceType": "Patient", + "id": "d4d35004-24f8-40e4-8084-1ad75924514f", + "name": [ + { + "family": "Fellhauer", + "given": [ "Monika" ] + } + ], + "birthDate": "1984-02-24" + } + """ + +val immunizationStr = + """ + { + "resourceType": "Immunization", + "id": "8aa553e8-8847-482a-8bcf-2eca4e9598ef", + "status": "completed", + "patient": { + "reference": "Patient/d4d35004-24f8-40e4-8084-1ad75924514f" + }, + "occurrenceDateTime": "2021-05-27", + "protocolApplied": [ + { + "targetDisease": [ + { + "coding": [ + { + "system": "http://snomed.info/sct", + "code": "840539006" + } + ] + } + ], + "doseNumberPositiveInt": 2, + "seriesDosesPositiveInt": 2 + } + ] + } + """.trimIndent() +``` + +Add the data objects to the Engine: + +```kotlin +val jsonParser = MyApplication.fhirContext(this).newJsonParser() + +MyApplication.fhirEngine(this).apply { + create(jsonParser.parseResource(patientStr) as Patient) + create(jsonParser.parseResource(immunizationStr) as Immunization) +} +``` + +Add the library to the Fhir Operator + +```kotlin +MyApplication.fhirOperator(this).loadLib(library) +``` + +And call `FhirOperator.evaluateLibrary` with the library URL, the Patient ID and a set of functions to evaluate. + +```kotlin +val results = FhirApplication.fhirOperator(requireContext()).evaluateLibrary( + "http://localhost/Library/DDCCPass|1.0.0", + "d4d35004-24f8-40e4-8084-1ad75924514f", + setOf("Completed Immunization")) as Parameters + +val isImmunized = results.getParameterBool("Completed Immunization") +``` diff --git a/docs/use/WFL/Demo-app.md b/docs/use/WFL/Demo-app.md new file mode 100644 index 000000000..f0deacdd8 --- /dev/null +++ b/docs/use/WFL/Demo-app.md @@ -0,0 +1,20 @@ +# Demo App + +The *Workflow Demo* app demonstrates the capabilities of CarePlan Generation API and the Activity Flow API. The app has a Patient card in the top showing the details and a carousel in the bottom with each card representing a particular phase of the activity flow. + +To run this app in Android Studio, [create a run/debug configuration](https://developer.android.com/studio/run/rundebugconfig) for the `workflow_demo` module using the [Android App](https://developer.android.com/studio/run/rundebugconfig#android-application) template and run the app using the configuration. + +Alternatively, run the following command to build and install the debug APK on your device/emulator: + +```shell +./gradlew :workflow_demo:installDebug +``` + +## Instructions +1. Click on the **Initialize** button to install the required dependencies for an activity flow. The dependencies are already bundled in the assets folder of the workflow demo app. After the dependencies are successfully installed, **Start** Button becomes enabled in the _Proposal_ card. +2. Now, click on the **Start** to generate a CarePlan which intern has a _Proposal_ Resource. This resource is then used by the app to create a new Activity Flow and the _Proposal_ card now shows the details of the resource with the **Start** button disabled now. The carousel auto moves to the next Phase Card i.e. _Plan_. +3. Repeat step 2 to move forward through the phases. +4. To restart the Activity click **Restart** Flow that removes all the resources related to the flow and moves the app back to step 2. +5. The overflow menu on the action bar may be used to switch between various Activities supported in the demo app. + +![Workflow Demo](workflow_demo_app.gif) diff --git a/docs/use/WFL/Evaluate-a-Library.md b/docs/use/WFL/Evaluate-a-Library.md new file mode 100644 index 000000000..64077be52 --- /dev/null +++ b/docs/use/WFL/Evaluate-a-Library.md @@ -0,0 +1,78 @@ +# Evaluate a Library + +Given a pre-compiled FHIR Library, the FhirOperator can evaluate functions defined within it by simply calling FhirOperator.evaluateLibrary. + +In this example, the CQL library is a single function called `ActiveProcedureStatus` that always returns true: + +```cql +library TestActivityDefinition version '1.0.0' + +define "ActiveProcedureStatus": true + +``` + +With a Library object that includes either a `text/CQL`, a `JSON`, or an `XML` formatted as a base64 attachment, + +```kotlin +val libraryStr = + """ +{ + "resourceType": "Library", + "id": "TestActivityDefinition", + "url": "http://test/fhir/Library/TestActivityDefinition|1.0.0", + "version": "1.0.0", + "name": "TestActivityDefinition", + "parameter": [ + { + "name": "ActiveProcedureStatus", + "use": "out", + "type": "boolean" + } + ], + "content": [ + { + "contentType": "text/cql", + "data": "bGlicmFyeSBUZXN0QWN0aXZpdHlEZWZpbml0aW9uIHZlcnNpb24gJzEuMC4wJw0KDQpkZWZpbmUgIkFjdGl2ZVByb2NlZHVyZVN0YXR1cyI6IHRydWUNCg==" + } + ] +} + """.trimIndent() +``` + +Add a target Patient + +```kotlin +val patientStr = + """ + { + "resourceType": "Patient", + "id": "Test-Patient" + ... + } + """.trimIndent() +``` + +Add the Patient to the Engine and the Library to the FhirOperator: + +```kotlin +val jsonParser = MyApplication.fhirContext(this).newJsonParser() + +MyApplication.fhirOperator(this).apply { + loadLib(jsonParser.parseResource(libraryStr) as Library) +} + +MyApplication.fhirEngine(this).apply { + create(jsonParser.parseResource(patientStr) as Patient) +} +``` + +And call FhirOperator.evaluateLibrary with the library URL, the Patient ID and a set of functions to evaluate + +```kotlin +val results = FhirApplication.fhirOperator(requireContext()).evaluateLibrary( + "http://test/fhir/Library/TestActivityDefinition|1.0.0", + "Test-Patient", + setOf("ActiveProcedureStatus")) as Parameters + +val isImmunized = results.getParameterBool("ActiveProcedureStatus") +``` diff --git a/docs/use/WFL/Evaluate-a-Measure.md b/docs/use/WFL/Evaluate-a-Measure.md new file mode 100644 index 000000000..c30300f5b --- /dev/null +++ b/docs/use/WFL/Evaluate-a-Measure.md @@ -0,0 +1,229 @@ +# Evaluate a Measure + +Clinical reports in FHIR are represented as a FHIR Measure resource containing terminology, a population criteria section, and at least one FHIR Library resource containing a data criteria section as well as the logic used to define the population criteria. The population criteria section typically contains initial population criteria, denominator criteria, and numerator criteria sub-components, among others. Measures are then scored according to whether a subject (or subjects) are members of the various populations. For example, a Measure for Breast Cancer screening might define an Initial Population (via CQL expressions) of "all women", a Denominator of "women over 35", and a Numerator of "women over 35 who have had breast cancer screenings in the past year". If the Measure is evaluated against a population of 100 women, 50 are over 35, and of those 25 have had breast cancer screenings in the past year, the final score would be 50%1 (total number in numerator / total number in the denominator). + +## Measure Evaluate with Population Reporting + +This Measure example calculates the proportion of the numerator over a denominator for a given patient population. In this example, we will be measuring the number of patients under 18 using a Measure object and a CQL Library to filter each group. + +With a measure: + +```kotlin +val measureStr = + """ +{ + "resourceType": "Measure", + "id": "Under18Measure", + "url": "http://localhost/Measure/Under18Measure", + "title": "Measure for Patients Under18", + "status": "draft", + "library": [ + "http://localhost/Library/PatientsUnder18|1.0.0" + ], + "scoring": { + "coding": [ + { + "code": "proportion" + } + ] + }, + "group": [ + { + "id": "Main", + "population": [ + { + "code": { + "coding": [ + { + "code": "initial-population" + } + ] + }, + "criteria": { + "language": "text/cql", + "expression": "Initial Population" + } + }, + { + "code": { + "coding": [ + { + "code": "denominator" + } + ] + }, + "criteria": { + "language": "text/cql", + "expression": "Denominator" + } + }, + { + "code": { + "coding": [ + { + "code": "numerator" + } + ] + }, + "criteria": { + "language": "text/cql", + "expression": "Numerator" + } + } + ] + } + ] +} +""".trimIndent() +``` + +And the respective Fhir Library with a base-64 encoded CQL code: + +```kotlin +val libraryStr = + """ +{ + "resourceType": "Library", + "id": "PatientsUnder18-1.0.0", + "url": "http://localhost/Library/PatientsUnder18|1.0.0", + "version": "1.0.0", + "name": "PatientsUnder18", + "status": "active", + "experimental": true, + "content": [ + { + "contentType": "text/cql", + "data": "bGlicmFyeSBQYXRpZW50c1VuZGVyMTggdmVyc2lvbiAnMS4wLjAnCgp1c2luZyBGSElSIHZlcnNpb24gJzQuMC4xJwoKaW5jbHVkZSAiRkhJUkhlbHBlcnMiIHZlcnNpb24gJzQuMC4xJyBjYWxsZWQgRkhJUkhlbHBlcnMKCmNvbnRleHQgUGF0aWVudAoKZGVmaW5lICJJbml0aWFsIFBvcHVsYXRpb24iOiBbUGF0aWVudF0gUApkZWZpbmUgIk51bWVyYXRvciI6ICJJbml0aWFsIFBvcHVsYXRpb24iIFAgd2hlcmUgQ2FsY3VsYXRlQWdlSW5ZZWFycyhQLmJpcnRoRGF0ZSkgPCAxOApkZWZpbmUgIkRlbm9taW5hdG9yIjogIkluaXRpYWwgUG9wdWxhdGlvbiIK" + } + ] +} + """.trimIndent() +``` + +Add the Measure to the Engine: + +```kotlin +val jsonParser = MyApplication.fhirContext(this).newJsonParser() + +MyApplication.fhirOperator(this).apply { + loadLib(jsonParser.parseResource(libraryStr) as Library) +} + +MyApplication.fhirEngine(this).apply { + create(jsonParser.parseResource(measureStr) as Measure) +} +``` + +For a given number of Patients: + +```kotlin +val patientBundleStr = + """ +{ + "resourceType": "Bundle", + "entry": [ + { + "resource": { + "resourceType": "Patient", + "id": "1", + "active": true, + "birthDate": "2015-04-16" + } + }, { + "resource": { + "resourceType": "Patient", + "id": "2", + "active": true, + "birthDate": "2005-04-16" + } + }, { + "resource": { + "resourceType": "Patient", + "id": "3", + "active": true, + "birthDate": "1995-04-16" + } + } + ] +} +""".trimIndent() +``` + +Add Patients to the Engine: + +```kotlin +MyApplication.fhirEngine(this).apply { + val bundle = jsonParser.parseResource(patientBundleStr) as Bundle + for (entry in bundle.entry) { + create(entry.resource) + } +} +``` + +And call FhirOperator.evaluateMeasure with the measure URL, the dates of the start and end of the report. + +```kotlin +val measureReport = + fhirOperator.evaluateMeasure( + measureUrl = "http://localhost/Measure/Under18Measure", + start = "1990-01-01", + end = "2022-12-31", + reportType = MeasureEvalType.POPULATION.toCode(), + subject = null, + practitioner = null, + lastReceivedOn = null + ) +``` + +ReportType accepts multiple options: + +* `MeasureEvalType.SUBJECT`: An evaluation generating an individual report that provides information on the performance for a given measure with respect to a single subject +* `MeasureEvalType.SUBJECTLIST`: An evaluation generating a subject list report that includes a listing of subjects that satisfied each population criteria in the measure. +* `MeasureEvalType.PATIENT`: An evaluation generating an individual report that provides information on the performance for a given measure with respect to a single patient +* `MeasureEvalType.PATIENTLIST`: An evaluation generating a patient list report that includes a listing of patients that satisfied each population criteria in the measure +* `MeasureEvalType.POPULATION`: An evaluation generating a summary report that returns the number of subjects in each population criteria for the measure + +Subject and Practitioner filter the initial population to the patient or practitioner desired. The LastReceivedOn is not yet implemented. + +The returning Measure Report should be: + +```json +{ + "resourceType": "MeasureReport", + "status": "complete", + "type": "summary", + "measure": "http://localhost/Measure/Under18Measure", + "period": { + "start": "1990-01-01T00:00:00+00:00", + "end": "2022-12-31T23:59:59+00:00" + }, + "group": [ { + "id": "Main", + "population": [ { + "code": { + "coding": [ { + "code": "initial-population" + } ] + }, + "count": 3 + }, { + "code": { + "coding": [ { + "code": "denominator" + } ] + }, + "count": 3 + }, { + "code": { + "coding": [ { + "code": "numerator" + } ] + }, + "count": 2 + } ], + "measureScore": { + "value": 0.6666666666666666 + } + } ] +} +``` diff --git a/docs/use/WFL/Generate-A-Care-Plan.md b/docs/use/WFL/Generate-A-Care-Plan.md new file mode 100644 index 000000000..abacc7af6 --- /dev/null +++ b/docs/use/WFL/Generate-A-Care-Plan.md @@ -0,0 +1,171 @@ +# Generate a Care Plan + +The $Apply operator applies a given PlanDefinition to a patient. The operator will run through the actions defined in the plan, determine applicability by evaluating the applicability conditions defined for the action and convert the applicable actions into the resulting CarePlan activities. + +With a Plan Definition and a Patient + +```kotlin +val planDefinitionStr = + """ + { + "resourceType": "PlanDefinition", + "id": "Test-PlanDefinition" + ... + } + """.trimIndent() + +val patientStr = + """ + { + "resourceType": "Patient", + "id": "Test-Patient" + ... + } + """ +``` + +Add the objects to the Engine: + +```kotlin +val jsonParser = MyApplication.fhirContext(this).newJsonParser() + +MyApplication.fhirEngine(this).apply { + create(jsonParser.parseResource(planDefinitionStr) as PlanDefinition) + create(jsonParser.parseResource(patientStr) as Patient) +} +``` + +And call FhirOperator.generateCarePlan with the ID of the PlanDefinition and the patient ID desired. An optional Encounter ID can also be provided. + +```kotlin +val carePlan = MyApplication.fhirOperator(this).generateCarePlan( + planDefinitionId = "Test-PlanDefinition", + patientId = "Test-Patient" +) +``` + +## Example: Generate a CarePlan with a Medication Request + +PlanDefinitions include a set of actions. Each action is linked to an `ActivityDefinition` and the `ActivityDefinition` can be specified as a `MedicationRequest` kind. Here's a simple example. + +With a Plan Definition, an Activity Definition, and a Patient + +```kotlin +val planDefinitionStr = + """ + { + "resourceType": "PlanDefinition", + "id": "MedRequest-Example", + "title": "This example illustrates a medication request", + "status": "active", + "action": [{ + "id": "medication-action-1", + "title": "Administer Medication 1", + "definitionCanonical": "http://localhost/ActivityDefinition/MedicationRequest-1" + }] + } + """.trimIndent() + +val activityDefinitionStr = + """ + { + "resourceType": "ActivityDefinition", + "id": "MedicationRequest-1", + "url": "http://localhost/ActivityDefinition/MedicationRequest-1", + "status": "active", + "kind": "MedicationRequest", + "productCodeableConcept": { + "text": "Medication 1" + } + } + """.trimIndent() + +val patientStr = + """ + { + "resourceType": "Patient", + "id": "Patient-Example", + "active": true, + "name": [ + { + "family": "Hadi", + "given": [ + "Bareera" + ] + } + ], + "gender": "female", + "birthDate": "1999-01-14" + ... + } + """ +``` + +Add the objects to the Engine: + +```kotlin +val jsonParser = MyApplication.fhirContext(this).newJsonParser() + +MyApplication.fhirEngine(this).apply { + create(jsonParser.parseResource(planDefinitionStr) as PlanDefinition) + create(jsonParser.parseResource(activityDefinitionStr) as ActivityDefinition) + create(jsonParser.parseResource(patientStr) as Patient) +} +``` + +And call FhirOperator.generateCarePlan with the ID of the PlanDefinition and the patient ID desired. + +```kotlin +val carePlan = MyApplication.fhirOperator(this).generateCarePlan( + planDefinitionId = "MedRequest-Example", + patientId = "Patient-Example" +) +``` + +The generated CarePlan is: + +```json +{ + "resourceType": "CarePlan", + "contained": [ + { + "resourceType": "RequestGroup", + "id": "1", + "status": "draft", + "intent": "proposal", + "action": [ + { + "resource": { + "reference": "#2" + } + } + ] + }, + { + "resourceType": "MedicationRequest", + "id": "2", + "intent": "order", + "medicationCodeableConcept": { + "text": "Medication 1" + }, + "subject": { + "reference": "Patient/Patient-Example" + } + } + ], + "instantiatesCanonical": [ + "MedRequest-Example" + ], + "status": "draft", + "subject": { + "reference": "Patient/Patient-Example" + }, + "activity": [ + { + "reference": { + "reference": "#1" + } + } + ] +} +``` diff --git a/docs/use/WFL/Getting-Started.md b/docs/use/WFL/Getting-Started.md new file mode 100644 index 000000000..54be670ac --- /dev/null +++ b/docs/use/WFL/Getting-Started.md @@ -0,0 +1,124 @@ +# Getting Started + +To get started, you need an Android Studio project first. If you are new to Android, start with the [Build Your First App](https://developer.android.com/training/basics/firstapp) tutorial. The rest of this document assumes familiarity with the fundamentals of Android development. + +The Workflow library supports Android 24 and above. Android Studio 4.0 or above is required for [Java 11 library desugaring](https://developer.android.com/studio/write/java11-default-support-table). + +This guide is intended for developers who are familiar with basic Android development with Kotlin, and proficient in working with FHIR concepts and resources. + +## Add the Workflow Library + +Once you have set up your project in Android Studio, set the `minSdk` of the app to `24` and add the Workflow modules to your app's build.gradle file: + +```gradle +dependencies { + implementation("com.google.android.fhir:workflow:0.1.0-alpha03") +} +``` + +Add the following Packaging Options to avoid compilation errors: + +```gradle +android { + //... + packagingOptions { + pickFirst('META-INF/ASL-2.0.txt') + pickFirst('META-INF/LGPL-3.0.txt') + pickFirst('META-INF/LICENSE.md') + pickFirst('META-INF/NOTICE.md') + pickFirst('META-INF/sun-jaxb.episode') + } +} +``` + +Once this is done and you have synchronized your Gradle project, proceed to the next section to start using the library's APIs. + +## Initialize and get a FhirOperator + +All workflow requests are made through a `FhirOperator` instance, which should be initialized in the `Application.onCreate` function, together with the Engine's initialization. The `FhirOperator` is your main access point to all clinical decision support implementations in the SDK. + +The snippet below demonstrates how to pre-load a single operator to your application. Notice how the Workflow module requires a choice of FhirVersion, `R4` in this case. + +```kotlin +class MyApplication : Application() { + private val inSync = LazyThreadSafetyMode.SYNCHRONIZED + + // Only initiate when used for the first time, not when the app is created. + private val fhirEngine: FhirEngine by lazy(inSync) { FhirEngineProvider.getInstance(this) } + private val fhirContext: FhirContext by lazy(inSync) { FhirContext.forCached(FhirVersionEnum.R4) } + private val fhirOperator: FhirOperator by lazy(inSync) { FhirOperator(fhirContext, fhirEngine) } + + override fun onCreate() { + super.onCreate() + + FhirEngineProvider.init( + FhirEngineConfiguration( + enableEncryptionIfSupported = true, + DatabaseErrorStrategy.RECREATE_AT_OPEN + ) + ) + + thread { + fhirOperator //Initializes the object in the background. + } + } + + companion object { + fun fhirEngine(context: Context) = (context.applicationContext as MyApplication).fhirEngine + fun fhirContext(context: Context) = (context.applicationContext as MyApplication).fhirContext + fun fhirOperator(context: Context) = (context.applicationContext as MyApplication).fhirOperator + } +} +``` + +Don't forget to add the custom MyApplication to the AndroidManifest.xml + +```xml + + +``` + +## Use FhirOperator APIs + +**1. Evaluate Individual Subject Matters:** + +```kotlin +fhirOperator.evaluateMeasure( + url = "http://fhir.org/guides/who/anc-cds/Measure/ANCIND01", + start = "2020-01-01", + end = "2020-01-31", + reportType = "subject", + subject = "charity-otala-1", + practitioner = "jane", + lastReceivedOn = null +) +``` + +**2. Evaluate Population Matters:** + +```kotlin +val measureReport : MeasureReport = + fhirOperator.evaluateMeasure( + url = "http://fhir.org/guides/who/anc-cds/Measure/ANCIND01", + start = "2019-01-01", + end = "2021-12-31", + reportType = "population", + subject = null, + practitioner = "jane", + lastReceivedOn = null + ) +``` + +**3. Generate Care Plan for a Patient:** + +```kotlin +val plan: CarePlan = fhirOperator.generateCarePlan( + planDefinitionId = "plandefinition-RuleFilters-1.0.0", + patientId = "Reportable", + encounterId = "reportable-encounter" +) +``` + +See [FhirOperatorTest.kt](https://github.com/google/android-fhir/blob/master/workflow/src/test/java/com/google/android/fhir/workflow/FhirOperatorTest.kt) for more details on how to set up data. diff --git a/docs/use/WFL/Run-an-Activity-Flow.md b/docs/use/WFL/Run-an-Activity-Flow.md new file mode 100644 index 000000000..ff2731af4 --- /dev/null +++ b/docs/use/WFL/Run-an-Activity-Flow.md @@ -0,0 +1,128 @@ +# ActivityFlow + +![Activity Flow](activity_flow.svg) + +The `ActivityFlow` class manages the workflow of clinical recommendations according to the [FHIR Clinical Practice Guidelines (CPG) specification](https://build.fhir.org/ig/HL7/cqf-recommendations/activityflow.html#activity-lifecycle---request-phases-proposal-plan-order). It implements an activity flow as defined in the FHIR CPG IG, allowing you to guide clinical recommendations through various phases (proposal, plan, order, perform). + +You can start new workflows with an appropriate request resource from the generated [CarePlan](Generate-A-Care-Plan.md) or resume existing ones from any phase. + +**Important Considerations:** + +* **Thread Safety:** The `ActivityFlow` is not thread-safe. Concurrent changes from multiple threads may lead to unexpected behavior. +* **Blocking Operations:** Some methods of `ActivityFlow` and its associated `Phase` interface may block the caller thread. Ensure these are called from a worker thread to avoid UI freezes. + +## Creating an ActivityFlow + +Use the appropriate `ActivityFlow.of()` factory function to create an instance. You can start anew flow with a `CPGRequestResource` or resume an existing flow from a `CPGRequestResource` or `CPGEventResource` based on the current state. + +**Example:** +```kotlin +val repository = FhirEngineRepository(FhirContext.forR4Cached(), fhirEngine) +val request = CPGMedicationRequest( medicationRequestGeneratedByCarePlan) +val flow = ActivityFlow.of(repository, request) +``` + +## Navigating phases + +An `ActivityFlow` progresses through a series of phases, represented by the `Phase` class. Access the current phase using `getCurrentPhase()`. + +**Example:** +```kotlin + when (val phase = flow.getCurrentPhase( )) { + is Phase.ProposalPhase -> { /* Handle proposal phase */ } + is Phase.PlanPhase -> { /* Handle plan phase */ } + // ... other phases +} +``` + +## Transitioning between the phases + +`ActivityFlow` provides functions to prepare and initiate the next phase: + +* **`prepare...()`:** Creates a new request or event for the next phase without persisting changes. +* **`initiate...()`:** Creates a new phase based on the provided request/event and persists changes to the repository. + +**Example:** +```kotlin +val preparedPlan = flow.preparePlan().getOrThrow( ) +// ... modify preparedPlan +val planPhase = flow.initiatePlan(preparedPlan).getOrThrow( ) +``` + +## Transitioning to Perform Phase + +The `preparePerform()` function requires the event type as a parameter since the perform phase can create different event resources. + +**Example:** +```kotlin +val preparedPerformEvent = flow.preparePerform( CPGMedicationDispenseEvent::class.java).getOrThrow() + // ... update preparedPerformEvent + val performPhase = flow.initiatePerform( preparedPerformEvent). getOrThrow( ) +``` + +## Updating states in a phase + +* **`RequestPhase`:** (`ProposalPhase`, `PlanPhase`, `OrderPhase`) allows updating the request state using `update()`. +```kotlin +proposalPhase.update( + proposalPhase.getRequestResource().apply { setStatus(Status.ACTIVE) } +) +``` +* **`EventPhase`:** (`PerformPhase`) allows updating the event state using `update()` and completing the phase using `complete()`. +```kotlin +performPhase.update( + performPhase.getEventResource().apply { setStatus(EventStatus.COMPLETED) } +) +``` +## API List +### Factory functions + +* `ActivityFlow.of(...)`: Various overloads for creating `ActivityFlow` instances with different resource types. Refer to the code for specific usage. + +### Phase transition API + +* `preparePlan()`: Prepares a plan resource. +* `initiatePlan(...)`: Initiates the plan phase. +* `prepareOrder()`: Prepares an order resource. +* `initiateOrder(...)`: Initiates the order phase. +* `preparePerform(...)`: Prepares an event resource for the perform phase. +* `initiatePerform(...)`: Initiates the perform phase. + +### Other API +* `getCurrentPhase()`: Returns the current `Phase` of the workflow. + +### Request phase API + +* `getRequestResource()`: Returns a copy of resource. +* `update(..)`: Updates the resource. +* `suspend(..)`: Suspends the phase. +* `resume(..)`: Resumes the phase. +* `enteredInError(..)`: Marks the request entered-in-error. +* `reject(..)`: Rejects the phase. + +### Event phase API + +* `getEventResource()`: Returns a copy of resource. +* `update(..)`: Updates the resource. +* `suspend(..)`: Suspends the phase. +* `resume(..)`: Resumes the phase. +* `enteredInError(..)`: Marks the event entered-in-error. +* `start(..)`: Starts the event. +* `notDone(..)`: Marks the event not-done. +* `stop(..)`: Stop the event. +* `complete(..)`: Marks the event as complete. + + +## Supported activities +The library currently doesn't implement all of the activities outlined in the [activity profiles](https://build.fhir.org/ig/HL7/cqf-recommendations/profiles.html#activity-profiles). New activities may be added as per the requirement from the application developers. + +| Activity | Request | Event | +|--------------------|-------------------------|-----------------------| +| Send a message | CPGCommunicationRequest | CPGCommunication | +| Order a medication | CPGMedicationRequest | CPGMedicationDispense | + +## Additional resources + +* [FHIR Clinical Practice Guidelines IG](https://build.fhir.org/ig/HL7/cqf-recommendations/) +* [Activity Flow](https://build.fhir.org/ig/HL7/cqf-recommendations/activityflow.html#activity-flow) +* [Activity Profiles](https://build.fhir.org/ig/HL7/cqf-recommendations/profiles.html#activity-profiles) \ No newline at end of file diff --git a/docs/use/WFL/activity_flow.svg b/docs/use/WFL/activity_flow.svg new file mode 100644 index 000000000..dac4eda98 --- /dev/null +++ b/docs/use/WFL/activity_flow.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/docs/use/WFL/index.md b/docs/use/WFL/index.md new file mode 100644 index 000000000..da883ad9c --- /dev/null +++ b/docs/use/WFL/index.md @@ -0,0 +1,33 @@ +# Workflow Library + +The [Workflow](https://build.fhir.org/workflow.html) Library provides decision support logic and analytics in clinical workflows. Authors of digital guidelines develop reports ([Measure](https://build.fhir.org/measure.html)) and individualized clinical procedures ([PlanDefinition](https://build.fhir.org/plandefinition.html)) to be run on FHIR Databases. Both assets rely on [FHIR Libraries](https://build.fhir.org/library.html) that can be written in [Clinical Quality Language](https://cql.hl7.org/) or [FhirPath](https://hl7.org/fhir/fhirpath.html). To execute such workflows, this library implements the two major FHIR operations, Measure [$Evaluate](https://build.fhir.org/operation-measure-evaluate-measure.html) and Plan Definition [$Apply](https://build.fhir.org/plandefinition-operation-apply.html), as well as the lower-level Library [$Evaluate](https://build.fhir.org/ig/HL7/cqf-recommendations/OperationDefinition-cpg-library-evaluate.html) operation: + +1. The [Measure](https://build.fhir.org/measure.html) resource is a reporting-definition tool that supports the evaluation of clinical measures, such as the percentage of patients who received a recommended screening or the number of patients with a specific condition who received appropriate treatment. The output of this operation will be a Report resource, which includes the results of the evaluation. The Report resource will include details about the individual measures that were evaluated, the scores for those measures, and any relevant supporting information. It may also include references to other resources that are relevant to the evaluation, such as observations or procedures that were used to calculate the measure scores. These calculations can be used to assess the performance of a healthcare provider or organization and can help identify areas for improvement. + +1. The [PlanDefinition](https://build.fhir.org/plandefinition.html) resource describes a plan or protocol for the care of a given patient. This could include a treatment plan for a specific condition, a discharge plan for a hospitalized patient, or a care plan for managing chronic illness. The output of this operation will be a CarePlan resource, which represents the plan that has been tailored and applied to the specific patient or group of patients. The CarePlan resource will include details about the actions that are part of the plan, the timing and frequency of those actions, and any relevant supporting information. It may also include references to other resources that are relevant to the plan, such as observations or procedures that are part of the plan. The Apply operator can be used to determine the specific actions or interventions that should be taken as part of a care plan, based on the patient's current status and other relevant factors. For example, it could be used to determine which medications a patient should be prescribed or to identify any necessary referrals to other healthcare providers. + +1. The [Activity Flow](https://build.fhir.org/ig/HL7/cqf-recommendations/activityflow.html#activity-flow) is based on the workflow module in FHIR. It is used to create activities for the request resources generated in a CarePlan and take them through the various phases(proposal, plan, order, and perform) of the activity lifecycle. + +1. The [Library](https://build.fhir.org/library.html) resource describes a container for clinical knowledge assets. One of these assets is a shareable library of clinical logic, written in Clinical Quality Language (CQL). Users of the Workflow library can call an evaluation operator directly from the Library resource and run individual expressions at will. The output will be Parameters resource with the results of each expression evaluated. This operator should be used when the use case does not fit into a PlanDefinition or a Measure Evaluate. + +It's recommended that these 3 types of resources are authored within the scope of a [FHIR IG](https://www.hl7.org/fhir/implementationguide.html). The IG can then be published online and imported by the Android SDK. To import an IG, Android SDK users must simply copy the required files from the IG package into the `assets` folder and parse those files using the regular FHIR Parser. + +The workflow library is dependent on the [Engine library](../FEL/index.md). Operations execute over the dataset saved in the FhirEngine instance. + +Future features of the library will provide support for Tasking and other Workflow related requirements + +## Next steps + +* [Getting Started](Getting-Started.md) +* Workflow Guides + * [Generate a Care Plan](Generate-A-Care-Plan.md) + * [Run an Activity Flow](Run-an-Activity-Flow.md) + * [Evaluate a Measure](Evaluate-a-Measure.md) +* Other Operations + * [Evaluate a Library](Evaluate-a-Library.md) + * [Compile CQL](Compile-and-Execute-CQL.md) + + +## Data safety + +This library does not collect or share any personal or sensitive [user data](https://developer.android.com/guide/topics/data/collect-share) with any third party libraries or SDKs. diff --git a/docs/use/WFL/workflow_demo_app.gif b/docs/use/WFL/workflow_demo_app.gif new file mode 100644 index 000000000..f4f88fea6 Binary files /dev/null and b/docs/use/WFL/workflow_demo_app.gif differ diff --git a/docs/use/api.md b/docs/use/api.md new file mode 100644 index 000000000..51b20f4ad --- /dev/null +++ b/docs/use/api.md @@ -0,0 +1,6 @@ +# API + +* [Engine](api/engine/1.2.0/index.html) +* [Data Capture](api/data-capture/1.3.1/index.html) +* [Workflow](api/workflow/0.1.0-beta01/index.html) +* [Knowledge](api/knowledge/0.1.0-beta01/index.html) diff --git a/docs/use/extensions.md b/docs/use/extensions.md new file mode 100644 index 000000000..df009d5d6 --- /dev/null +++ b/docs/use/extensions.md @@ -0,0 +1,11 @@ +# FHIR Extensions + +This page lists [FHIR Extensions](http://hl7.org/fhir/extensibility.html) defined by the Android FHIR SDK. + +* Dialog extension (https://github.com/google/android-fhir/StructureDefinition/dialog) + + This extension can only be used if the questionnaire item type is `choice` and has an item-control of type `check-box` or `radio-button`. + +* GPS Coordinate URL extension (https://github.com/google/android-fhir/StructureDefinition/gps-coordinate) + + This URL extension can only be used if the questionnaire item type is `decimal` and has a valueString `latitude` or `longitude` . \ No newline at end of file diff --git a/docs/use/snapshots.md b/docs/use/snapshots.md new file mode 100644 index 000000000..6485e0658 --- /dev/null +++ b/docs/use/snapshots.md @@ -0,0 +1,75 @@ +# Snapshots + +You can test the latest Android FHIR SDK libraries using the snapshot versions published on GitHub Packages. + +They are unreleased versions of the library built from the `HEAD` of the main branch and have the `-SNAPSHOT` suffix in their version numbers. + +They can be found [here](https://github.com/orgs/google/packages?repo_name=android-fhir). + +!!! warning + + The snapshots are for testing and development purposes only. They are not QA tested and not production ready. Do **NOT** use them in production. + +## Configure GitHub maven repositories in `build.gradle.kts` + +Since these artifacts are deployed on GitHub Packages, a `username`/`GitHub token` pair is required as explained in [Authenticating to GitHub Packages](https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-apache-maven-registry#authenticating-to-github-packages). The token needs at least the `read:packages` scope. + +This can be securely managed by placing the credentials in the `local.properties` file and loading them with `gradleLocalProperties`. With this approach, the file `build.gradle.kts` will look like: + +```kotlin +import com.android.build.gradle.internal.cxx.configure.gradleLocalProperties + +plugins { + ... +} + +android { + ... + repositories{ + maven { + url = uri("https://maven.pkg.github.com/google/android-fhir") + credentials { + username = gradleLocalProperties(rootDir).getProperty("gpr.user") ?: System.getenv("GPR_USER") + password = gradleLocalProperties(rootDir).getProperty("gpr.key") ?: System.getenv("GPR_KEY") + } + } + } +} + +dependencies { +} +``` + +Notice the environment variables `GPR_USER`/`GPR_KEY` used in this file. + +Then, the file `local.properties` will need to be created in the project root folder: + +```dotenv +sdk.dir= +gpr.user= +gpr.key= +``` + +## Declare dependencies + +To include the snapshots in the dependencies of your app, modify `build.gradle.kts` in your app: + +```kotlin +dependencies { + ... + implementation("com.google.android.fhir:engine:-SNAPSHOT") + implementation("com.google.android.fhir:data-capture:-SNAPSHOT") +} +``` + +The versions `<...-version>` can be found in + +## How SNAPSHOT versions are managed by Gradle + +The complete documentation can be found in the section [Declaring a changing version](https://docs.gradle.org/current/userguide/dynamic_versions.html#sub:declaring_dependency_with_changing_version). + +To summarize: + +* By default, Gradle caches changing versions of dependencies for **24 hours** +* Dependency caching can be [controlled programmatically](https://docs.gradle.org/current/userguide/dynamic_versions.html#sec:controlling_dependency_caching_programmatically) +* The `--refresh-dependencies` option in command line tells Gradle to ignore all cached versions diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 000000000..c64712fc2 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,29 @@ +# Project-wide Gradle settings. + +# IDE (e.g. Android Studio) users: +# Gradle settings configured through the IDE *will override* +# any settings specified in this file. + +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html + +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +org.gradle.jvmargs=-Xmx8g -XX:+HeapDumpOnOutOfMemoryError -XX:+UseParallelGC -Dkotlin.daemon.jvm.options=-XX:MaxMetaspaceSize=1g -Dlint.nullness.ignore-deprecated=true + +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. More details, visit +# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects +# org.gradle.parallel=true + +android.useAndroidX=true + +org.gradle.daemon=true + +org.gradle.caching=true + +FHIR_SERVER_BASE_URL=https://hapi.fhir.org/baseR4/ + +kotlin.daemon.jvmargs=-Xmx16g + +kotlin.native.ignoreDisabledTargets=true \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 000000000..b9586c493 --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,227 @@ +# see https://docs.gradle.org/current/userguide/platforms.html + +[versions] +accompanist-themeadapter-material3 = "0.36.0" +android-fhir-common = "0.1.0-alpha05" +android-fhir-engine = "0.1.0-beta05" +android-fhir-knowledge = "0.1.0-beta01" +android-gradle-plugin = "8.13.0" +androidx-activity = "1.7.2" +androidx-activity-compose = "1.11.0" +androidx-appcompat = "1.6.1" +androidx-arch-core = "2.2.0" +androidx-benchmark = "1.4.0-rc01" +androidx-benchmark-macro = "1.4.0-rc01" +androidx-compose-bom = "2025.11.01" +androidx-compose-material3 = "1.4.0" +androidx-compose-ui = "1.9.4" +androidx-constraintlayout = "2.1.4" +androidx-core = "1.10.1" +androidx-datastore = "1.0.0" +androidx-espresso = "3.7.0" +androidx-fragment = "1.6.0" +androidx-lifecycle = "2.8.7" +androidx-lifecycle-viewmodel = "2.9.3" +androidx-navigation = "2.6.0" +androidx-navigation-compose = "2.9.5" +androidx-recyclerview = "1.4.0" +androidx-room = "2.7.1" +androidx-sqlite = "2.5.0" +androidx-test-core = "1.6.1" +androidx-test-ext-junit = "1.1.5" +androidx-test-rules = "1.5.0" +androidx-test-runner = "1.5.0" +androidx-tracing = "1.3.0" +androidx-work = "2.8.1" +apache-commons-compress = "1.21" +compose-hotreload = "1.0.0-rc04" +compose-multiplatform = "1.9.3" +desugar-jdk-libs = "2.0.3" +dokka = "1.9.20" +fhir-path = "1.0.0-beta01" +fhir-ucum = "1.0.3" +filekit = "0.12.0" +fladle-version = "0.17.4" +glide = "4.16.0" +gradle = "8.13.0" +guava = "32.1.3-android" +hapi-fhir = "6.8.0" +hapi-fhir-core = "6.0.22" +hapi-fhir-structures-r4 = "6.10.0" +json-assert = "1.5.1" +json-tools-patch = "1.13" +junit = "4.13.2" +kermit = "2.0.8" +kotest = "6.0.7" +kotlin = "2.2.20" +kotlin-fhir = "1.0.0-beta02" +kotlinpoet = "2.2.0" +kotlinx-coroutines = "1.10.2" +kotlinx-serialization-json = "1.9.0" +kotlinx-coroutines-swing = "1.10.2" +kotlinx-io-core = "0.8.0" +licensee-gradle-plugin = "1.8.0" +logback-android = "3.0.0" +material = "1.13.0" +material-icons-extended = "1.7.3" +mlkit-barcode-scanning = "16.1.1" +mlkit-object-detection = "16.2.3" +mlkit-object-detection-custom = "16.3.1" +mockito-inline = "4.0.0" +mockito-kotlin = "3.2.0" +navigation-compose = "2.9.1" +nimbus = "9.31" +okhttp3 = "4.11.0" +opencds-cqf-fhir = "3.8.0" +play-services-location = "21.0.1" +retrofit = "2.9.0" +robolectric = "4.10.3" +ruler-gradle-plugin = "1.4.0" +spotless-plugin-gradle = "6.22.0" +sqlcipher = "4.5.4" +timber = "5.0.1" +truth = "1.1.5" +uiautomator = "2.3.0" +woodstox = "6.5.1" +xerces = "2.12.2" +xml-unit = "2.9.1" +zxing = "3.4.1" +lifecycle-runtime-testing = "2.10.0" + +[libraries] +accompanist-themeadapter-material3 = { module = "com.google.accompanist:accompanist-themeadapter-material3", version.ref = "accompanist-themeadapter-material3"} +android-fhir-common = { module = "com.google.android.fhir:common", version.ref = "android-fhir-common" } +android-fhir-engine = { module = "com.google.android.fhir:engine", version.ref = "android-fhir-engine" } +android-fhir-knowledge = { module = "com.google.android.fhir:knowledge", version.ref = "android-fhir-knowledge" } +androidx-activity = { module = "androidx.activity:activity", version.ref = "androidx-activity" } +androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "androidx-activity-compose" } +androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidx-appcompat" } +androidx-arch-core-testing = { module = "androidx.arch.core:core-testing", version.ref = "androidx-arch-core" } +androidx-benchmark-junit4 = { module = "androidx.benchmark:benchmark-junit4", version.ref = "androidx-benchmark" } +androidx-benchmark-macro-junit4 = { module = "androidx.benchmark:benchmark-macro-junit4", version.ref = "androidx-benchmark-macro" } +androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "androidx-compose-bom" } +androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3", version.ref = "androidx-compose-material3" } +androidx-compose-ui = { group = "androidx.compose.ui", name = "ui", version.ref = "androidx-compose-ui"} +androidx-compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics", version.ref = "androidx-compose-ui" } +androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4", version.ref = "androidx-compose-ui" } +androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest", version.ref = "androidx-compose-ui" } +androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling", version.ref = "androidx-compose-ui" } +androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview", version.ref = "androidx-compose-ui" } +androidx-constraintlayout = { module = "androidx.constraintlayout:constraintlayout", version.ref = "androidx-constraintlayout" } +androidx-core = { module = "androidx.core:core-ktx", version.ref = "androidx-core" } +androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "androidx-datastore" } +androidx-fragment = { module = "androidx.fragment:fragment-ktx", version.ref = "androidx-fragment" } +androidx-fragment-testing = { module = "androidx.fragment:fragment-testing", version.ref = "androidx-fragment" } +androidx-lifecycle-livedata = { module = "androidx.lifecycle:lifecycle-livedata-ktx", version.ref = "androidx-lifecycle" } +androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "androidx-lifecycle" } +androidx-lifecycle-viewmodel = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "androidx-lifecycle" } +androidx-lifecycle-viewmodel-compose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle-viewmodel" } +androidx-lifecycle-runtime-compose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidx-lifecycle-viewmodel" } +androidx-lifecycle-runtime-testing = { module = "androidx.lifecycle:lifecycle-runtime-testing", version.ref = "androidx-lifecycle-viewmodel" } +androidx-lifecycle-viewmodel-savedstate = { module = "androidx.lifecycle:lifecycle-viewmodel-savedstate", version.ref = "androidx-lifecycle" } +androidx-navigation-fragment = { module = "androidx.navigation:navigation-fragment-ktx", version.ref = "androidx-navigation" } +androidx-navigation-ui = { module = "androidx.navigation:navigation-ui-ktx", version.ref = "androidx-navigation" } +androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "androidx-navigation-compose" } +androidx-recyclerview = { module = "androidx.recyclerview:recyclerview", version.ref = "androidx-recyclerview" } +androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "androidx-room" } +androidx-room-room = { module = "androidx.room:room-ktx", version.ref = "androidx-room" } +androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "androidx-room" } +androidx-room-testing = { module = "androidx.room:room-testing", version.ref = "androidx-room" } +androidx-sqlite = { module = "androidx.sqlite:sqlite-ktx", version.ref = "androidx-sqlite" } +androidx-test-core = { module = "androidx.test:core", version.ref = "androidx-test-core" } +androidx-test-espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "androidx-espresso" } +androidx-test-espresso-contrib = { module = "androidx.test.espresso:espresso-contrib", version.ref = "androidx-espresso" } +androidx-test-ext-junit = { module = "androidx.test.ext:junit", version.ref = "androidx-test-ext-junit" } +androidx-test-ext-junit-ktx = { module = "androidx.test.ext:junit-ktx", version.ref = "androidx-test-ext-junit" } +androidx-test-rules = { module = "androidx.test:rules", version.ref = "androidx-test-rules" } +androidx-test-runner = { module = "androidx.test:runner", version.ref = "androidx-test-runner" } +androidx-tracing-tracing = { module = "androidx.tracing:tracing", version.ref = "androidx-tracing" } +androidx-tracing-tracing-android = { module = "androidx.tracing:tracing-android", version.ref = "androidx-tracing" } +androidx-tracing-tracing-ktx = { module = "androidx.tracing:tracing-ktx", version.ref = "androidx-tracing" } +androidx-uiautomator = { module = "androidx.test.uiautomator:uiautomator", version.ref = "uiautomator" } +androidx-work-runtime = { module = "androidx.work:work-runtime-ktx", version.ref = "androidx-work" } +androidx-work-testing = { module = "androidx.work:work-testing", version.ref = "androidx-work" } +fhir-path = { module = "com.google.fhir:fhir-path", version.ref = "fhir-path" } +fladle = { module = "com.osacky.flank.gradle:fladle", version.ref = "fladle-version" } +apache-commons-compress = { module = "org.apache.commons:commons-compress", version.ref = "apache-commons-compress" } +desugar-jdk-libs = { module = "com.android.tools:desugar_jdk_libs", version.ref = "desugar-jdk-libs" } +fhir-ucum = { module = "org.fhir:ucum", version.ref = "fhir-ucum" } +filekit-dialogs-compose = { module = "io.github.vinceglb:filekit-dialogs-compose", version.ref = "filekit" } +glide = { module = "com.github.bumptech.glide:glide", version.ref = "glide" } +gradle = { module = "com.android.tools.build:gradle", version.ref = "gradle" } +guava = { module = "com.google.guava:guava", version.ref = "guava" } +hapi-fhir-caching-guava = { module = "ca.uhn.hapi.fhir:hapi-fhir-caching-guava", version.ref = "hapi-fhir" } +hapi-fhir-core-convertors = { module = "ca.uhn.hapi.fhir:org.hl7.fhir.convertors", version.ref = "hapi-fhir-core" } +hapi-fhir-structures-r4 = { module = "ca.uhn.hapi.fhir:hapi-fhir-structures-r4", version.ref = "hapi-fhir" } +hapi-fhir-structures-r4-v6100 = { module = "ca.uhn.hapi.fhir:hapi-fhir-structures-r4", version.ref = "hapi-fhir-structures-r4" } +hapi-fhir-validation = { module = "ca.uhn.hapi.fhir:hapi-fhir-validation", version.ref = "hapi-fhir" } +hapi-fhir-validation-r4 = { module = "ca.uhn.hapi.fhir:hapi-fhir-validation-resources-r4", version.ref = "hapi-fhir" } +json-assert = { module = "org.skyscreamer:jsonassert", version.ref = "json-assert" } +json-tools-patch = { module = "com.github.java-json-tools:json-patch", version.ref = "json-tools-patch" } +junit = { module = "junit:junit", version.ref = "junit" } +kermit = { module = "co.touchlab:kermit", version.ref = "kermit" } +kotest-assertions-core = {module = "io.kotest:kotest-assertions-core", version.ref="kotest"} +kotlin-fhir = { module = "com.google.fhir:fhir-model", version.ref = "kotlin-fhir" } +kotlin-stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib", version.ref = "kotlin" } +kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } +kotlin-test-junit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" } +kotlinpoet = { module = "com.squareup:kotlinpoet", version.ref = "kotlinpoet" } +kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinx-coroutines" } +kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" } +kotlinx-coroutines-playservices = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-play-services", version.ref = "kotlinx-coroutines" } +kotlinx-coroutines-swing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines-swing" } +kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" } +kotlinx-io-core = { module = "org.jetbrains.kotlinx:kotlinx-io-core", version.ref = "kotlinx-io-core" } +kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization-json" } +licensee-gradle-plugin = { module = "app.cash.licensee:licensee-gradle-plugin", version.ref = "licensee-gradle-plugin" } +logback-android = { module = "com.github.tony19:logback-android", version.ref = "logback-android" } +material = { group = "com.google.android.material", name = "material", version.ref = "material" } +mlkit-barcode-scanning = { module = "com.google.mlkit:barcode-scanning", version.ref = "mlkit-barcode-scanning" } +mlkit-obj-detection = { module = "com.google.mlkit:object-detection", version.ref = "mlkit-object-detection" } +mlkit-obj-detection-custom = { module = "com.google.mlkit:object-detection-custom", version.ref = "mlkit-object-detection-custom" } +mockito-inline = { module = "org.mockito:mockito-inline", version.ref = "mockito-inline" } +mockito-kotlin = { module = "org.mockito.kotlin:mockito-kotlin", version.ref = "mockito-kotlin" } +nimbus = { module = "com.nimbusds:nimbus-jose-jwt", version.ref = "nimbus" } +okhttp3-okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp3" } +okhttp3-interceptor = { module = "com.squareup.okhttp3:logging-interceptor", version.ref = "okhttp3" } +okhttp3-mock-web-server = { module = "com.squareup.okhttp3:mockwebserver", version.ref = "okhttp3" } +material-icons-extended = { module = "org.jetbrains.compose.material:material-icons-extended", version.ref = "material-icons-extended" } +navigation-compose = { module = "org.jetbrains.androidx.navigation:navigation-compose", version.ref = "navigation-compose" } +opencds-cqf-fhir-cr = { module = "org.opencds.cqf.fhir:cqf-fhir-cr", version.ref = "opencds-cqf-fhir" } +opencds-cqf-fhir-jackson = { module = "org.opencds.cqf.fhir:cqf-fhir-jackson", version.ref = "opencds-cqf-fhir" } +opencds-cqf-fhir-utility = { module = "org.opencds.cqf.fhir:cqf-fhir-utility", version.ref = "opencds-cqf-fhir" } +play-services-location = { module = "com.google.android.gms:play-services-location", version.ref = "play-services-location" } +robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" } +retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" } +retrofit-converter-gson = { module = "com.squareup.retrofit2:converter-gson", version.ref = "retrofit" } +ruler-gradle-plugin = { module = "com.spotify.ruler:ruler-gradle-plugin", version.ref = "ruler-gradle-plugin" } +spotless-plugin-gradle = { module = "com.diffplug.spotless:spotless-plugin-gradle", version.ref = "spotless-plugin-gradle" } +sqlcipher = { module = "net.zetetic:android-database-sqlcipher", version.ref = "sqlcipher" } +timber = { module = "com.jakewharton.timber:timber", version.ref = "timber" } +truth = { module = "com.google.truth:truth", version.ref = "truth" } +woodstox = { module = "com.fasterxml.woodstox:woodstox-core", version.ref = "woodstox" } +xerces = { module = "xerces:xercesImpl", version.ref = "xerces" } +xml-unit = { module = "org.xmlunit:xmlunit-core", version.ref = "xml-unit" } +zxing = { module = "com.google.zxing:core", version.ref = "zxing" } + +[bundles] +androidx-tracing = ["androidx-tracing-tracing", "androidx-tracing-tracing-android", "androidx-tracing-tracing-ktx"] + +[plugins] +android-application = { id = "com.android.application" } +android-application-build-src = { id = "com.android.application", version.ref = "android-gradle-plugin" } +android-kotlin-multiplatform-library = { id = "com.android.kotlin.multiplatform.library", version.ref = "android-gradle-plugin" } +android-library = { id = "com.android.library" } +android-test = { id = "com.android.test" } +androidx-benchmark = { id = "androidx.benchmark" } +androidx-navigation-safeargs = { id = "androidx.navigation.safeargs.kotlin" } +compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +compose-hotreload = { id = "org.jetbrains.compose.hot-reload", version.ref = "compose-hotreload" } +compose-multiplatform = { id = "org.jetbrains.compose", version.ref = "compose-multiplatform" } +dokka = { id = "org.jetbrains.dokka", version.ref = "dokka" } +kotlin-android = { id = "org.jetbrains.kotlin.android" } +kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose" } +kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } +kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization" } +kotlin-serialization-build-src = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } +ksp = { id = "com.google.devtools.ksp" } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 000000000..ccebba771 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 000000000..c6f003026 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip +networkTimeout=10000 +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 000000000..79a61d421 --- /dev/null +++ b/gradlew @@ -0,0 +1,244 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 000000000..6689b85be --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,92 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/kokoro/README.md b/kokoro/README.md new file mode 100644 index 000000000..a654e471f --- /dev/null +++ b/kokoro/README.md @@ -0,0 +1,17 @@ +## Kokoro Infrastructure + +The files in this directory serve as plumbing for running tests under Kokoro, +our internal CI tool. If there are any changes required to these config files, +please file an issue. + +The [build script](./gcp_ubuntu/kokoro_build.sh) runs each time a PR is opened, +or when a commit is pushed to an open PR, or when an a PR is merged into the +master branch. The script runs on a Google Compute Engine machine hosted in our +own Google Cloud project, with public access to the logs. + +The script downloads the dependencies it needs, compiles, builds, and unit tests +the code, and then uses Firebase Test Lab to run instrumentation tests. Code +coverage reports are then uploaded to Codecov, where they are displayed on the +GitHub Pull Request/Repo Homepage. + +**WARNING**:Do NOT run the script on your local machine. diff --git a/kokoro/gcp_ubuntu/common.cfg b/kokoro/gcp_ubuntu/common.cfg new file mode 100644 index 000000000..319ba2da1 --- /dev/null +++ b/kokoro/gcp_ubuntu/common.cfg @@ -0,0 +1,24 @@ +# -*- protobuffer -*- +# proto-file: google3/devtools/kokoro/config/proto/build.proto +# proto-message: BuildConfig + +# Fetch Codecov token prior to job starting +before_action { + fetch_keystore { + keystore_resource { + keystore_config_id: 76773 + keyname: "android-fhir-codecov-token" + backend: "blade:keystore-fastconfigpush" + } + } +} + +# List artifacts to upload after each build, regardless of whether the build +# succeeds or fails +action { + define_artifacts { + regex: "github/android-fhir/test-results/**" + regex: "github/android-fhir/build/ci-repo/**" + strip_prefix: "github/android-fhir" + } +} diff --git a/kokoro/gcp_ubuntu/continuous.cfg b/kokoro/gcp_ubuntu/continuous.cfg new file mode 100644 index 000000000..7280310e2 --- /dev/null +++ b/kokoro/gcp_ubuntu/continuous.cfg @@ -0,0 +1,7 @@ +# -*- protobuffer -*- +# proto-file: google3/devtools/kokoro/config/proto/build.proto +# proto-message: BuildConfig + +# Location of the bash script. Should have value /. +# github_scm.name is specified in the job configuration. +build_file: "android-fhir/kokoro/gcp_ubuntu/kokoro_build.sh" diff --git a/kokoro/gcp_ubuntu/kokoro_build.sh b/kokoro/gcp_ubuntu/kokoro_build.sh new file mode 100755 index 000000000..5e633dd4a --- /dev/null +++ b/kokoro/gcp_ubuntu/kokoro_build.sh @@ -0,0 +1,143 @@ +#!/bin/bash +# +# Copyright 2022 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +# This script should NOT be run locally. + +# Script to run CI pipeline for the Android FHIR SDK. The script downloads the +# dependencies it needs, compiles, builds, and unit tests the code, and then +# uses Firebase Test Lab to run instrumentation tests. Code coverage reports are +# then uploaded to Codecov, where they are displayed on the GitHub Pull Request. + +# Fail on any error. +set -e +# Display commands being run. +# WARNING: please only enable 'set -x' if necessary for debugging, and be very +# careful if you handle credentials (e.g. from Keystore) with 'set -x': +# statements like "export VAR=$(cat /tmp/keystore/credentials)" will result in +# the credentials being printed in build logs. +# Additionally, recursive invocation with credentials as command-line +# parameters, will print the full command, with credentials, in the build logs. +# set -x +# Code under repo is checked out to ${KOKORO_ARTIFACTS_DIR}/git. +# The final directory name in this path is determined by the scm name specified +# in the job configuration. +#export JAVA_HOME="/usr/lib/jvm/java-1.17.0-openjdk-amd64" +export ANDROID_HOME=${HOME}/android_sdk +#export PATH=$PATH:$JAVA_HOME/bin:${ANDROID_HOME}/cmdline-tools/latest/bin +export PATH=$PATH:${ANDROID_HOME}/cmdline-tools/latest/bin +export GCS_BUCKET="android-fhir-build-artifacts" + +# Uploads files generated from builds and tests to GCS when this script exits. +# If videos were recorded from Firebase, prints their URL to the console so users +# can click on and view. +# See: https://cloud.google.com/storage/docs/gsutil/commands/cp +# for documentation on the gsutil command used in this function +function zip_artifacts() { + mkdir -p test-results + zip test-results/build.zip ./*/build -r -q + find . -type f -regex ".*[t|androidT]est-results/.*xml" \ + -exec cp {} test-results/ \; + + echo "URLs for screen capture videos:" + gsutil ls gs://$GCS_BUCKET/$KOKORO_BUILD_ARTIFACTS_SUBDIR/**/*.mp4 \ + | sed 's|gs://|https://storage.googleapis.com/|' +} + +function installJdk21() { + wget --quiet https://download.java.net/openjdk/jdk21/ri/openjdk-21+35_linux-x64_bin.tar.gz + tar xvf openjdk-21+35_linux-x64_bin.tar.gz + sudo mv jdk-21/ /opt/jdk-21/ + echo 'export JAVA_HOME=/opt/jdk-21' | sudo tee /etc/profile.d/java21.sh + echo 'export PATH=$JAVA_HOME/bin:$PATH'|sudo tee -a /etc/profile.d/java21.sh + source /etc/profile.d/java21.sh +# echo $JAVA_HOME + java --version +} + +# Installs dependencies to run CI pipeline. Dependencies are: +# 1. npm to run spotlessApply +# 2. Android Command Line tools, accepting its licenses +# 3. Build tools to compile code +function setup() { + sudo npm cache clean -f + sudo npm install -g n + sudo n 16.18.0 + installJdk21 +# sudo apt install -y openjdk-17-jdk + + gcloud components update --quiet + + wget https://dl.google.com/android/repository/commandlinetools-linux-8512546_latest.zip \ + -O ${HOME}/android_sdk.zip -q + unzip ${HOME}/android_sdk.zip -d ${ANDROID_HOME} + mkdir ${ANDROID_HOME}/cmdline-tools/latest + mv ${ANDROID_HOME}/cmdline-tools/bin ${ANDROID_HOME}/cmdline-tools/latest + mv ${ANDROID_HOME}/cmdline-tools/lib ${ANDROID_HOME}/cmdline-tools/latest + + yes | sdkmanager --licenses > /dev/null + sdkmanager --update > /dev/null + sdkmanager "platforms;android-30" "build-tools;30.0.2" > /dev/null +} + +# Checks if code conforms to style guide, builds the code, then runs unit tests. +function build_only() { + ./gradlew spotlessCheck --scan --stacktrace + ./gradlew build -x :datacapture:build --scan --stacktrace + ./gradlew check -x :datacapture:check --scan --stacktrace +} + +# Runs instrumentation tests using Firebase Test Lab, and retrieves the code +# coverage reports. +function device_tests() { + ./gradlew packageDebugAndroidTest --scan --stacktrace + ./gradlew packageReleaseAndroidTest --scan --stacktrace + .github/workflows/runFlank.sh +} + +# Generates JaCoCo reports and uploads to Codecov: https://about.codecov.io/ +# Before uploading to Codecov, run an Integrity Check on the Uploader binary. +# See: https://docs.codecov.com/docs/codecov-uploader#using-the-uploader-with-codecovio-cloud +function code_coverage() { + ./gradlew jacocoTestReport --scan --stacktrace + + curl https://keybase.io/codecovsecurity/pgp_keys.asc \ + | gpg --no-default-keyring --keyring trustedkeys.gpg --import + curl -Os https://uploader.codecov.io/latest/linux/codecov + curl -Os https://uploader.codecov.io/latest/linux/codecov.SHA256SUM + curl -Os https://uploader.codecov.io/latest/linux/codecov.SHA256SUM.sig + gpgv codecov.SHA256SUM.sig codecov.SHA256SUM + shasum -a 256 -c codecov.SHA256SUM + chmod +x codecov + + # Don't write secrets to the logs + set +x + ./codecov \ + -f common/build/reports/jacoco/jacocoTestReport/jacocoTestReport.xml \ + -f datacapture/build/reports/jacoco/jacocoTestReport/jacocoTestReport.xml \ + -f engine/build/reports/jacoco/jacocoTestReport/jacocoTestReport.xml \ + -f knowledge/build/reports/jacoco/jacocoTestReport/jacocoTestReport.xml \ + -f workflow/build/reports/jacoco/jacocoTestReport/jacocoTestReport.xml \ + -t "$(cat "${KOKORO_KEYSTORE_DIR}/76773_android-fhir-codecov-token")" +} + +setup +cd ${KOKORO_ARTIFACTS_DIR}/github/android-fhir +trap zip_artifacts EXIT + +build_only +device_tests +code_coverage diff --git a/kokoro/gcp_ubuntu/presubmit.cfg b/kokoro/gcp_ubuntu/presubmit.cfg new file mode 100644 index 000000000..7280310e2 --- /dev/null +++ b/kokoro/gcp_ubuntu/presubmit.cfg @@ -0,0 +1,7 @@ +# -*- protobuffer -*- +# proto-file: google3/devtools/kokoro/config/proto/build.proto +# proto-message: BuildConfig + +# Location of the bash script. Should have value /. +# github_scm.name is specified in the job configuration. +build_file: "android-fhir/kokoro/gcp_ubuntu/kokoro_build.sh" diff --git a/license-header.txt b/license-header.txt new file mode 100644 index 000000000..9d5daf06a --- /dev/null +++ b/license-header.txt @@ -0,0 +1,16 @@ +/* + * Copyright $YEAR Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + diff --git a/mkdocs.yaml b/mkdocs.yaml new file mode 100644 index 000000000..6137ab351 --- /dev/null +++ b/mkdocs.yaml @@ -0,0 +1,107 @@ +site_name: Android FHIR SDK +site_url: https://google.github.io/android-fhir/ +repo_url: https://github.com/google/android-fhir/ +edit_uri: blob/master/docs/ +copyright: Copyright 2024 The Android FHIR SDK Authors + +nav: + - Home: index.md + - Users: + - FHIR Engine Library: + - Introduction: use/FEL/index.md + - Getting Started: use/FEL/Getting-started.md + - Guides: + - use/FEL/Manage-FHIR-resources-locally.md + - use/FEL/Search-FHIR-resources.md + - use/FEL/Sync-data-with-FHIR-server.md + - use/FEL/Privacy-Security.md + - use/FEL/Demo-app.md + - use/FEL/Benchmarking.md + - Reference: + - use/FEL/FHIR-search-specification-support.md + - Structured Data Capture Library: + - Introduction: use/SDCL/index.md + - Getting Started: use/SDCL/Getting-Started.md + - Guides: + - use/SDCL/Use-QuestionnaireFragment.md + - use/SDCL/Use-QuestionnaireResponseValidator.md + - use/SDCL/Use-ResourceMapper.md + - use/SDCL/Customize-how-a-Questionnaire-is-displayed.md + - use/SDCL/Reference-external-data.md + - use/SDCL/Author-questionnaires.md + - Demo App: use/SDCL/Demo-app.md + - Reference: + - FHIR specification support: use/SDCL/FHIR-specification-support.md + - Workflow Library: + - Introduction: use/WFL/index.md + - use/WFL/Getting-Started.md + - Guides: + - use/WFL/Generate-A-Care-Plan.md + - use/WFL/Evaluate-a-Measure.md + - use/WFL/Evaluate-a-Library.md + - use/WFL/Compile-and-Execute-CQL.md + - use/WFL/Run-an-Activity-Flow.md + - Demo App: use/WFL/Demo-app.md + - use/extensions.md + - API Doc: use/api.md + - Use Snapshots: use/snapshots.md + - Contributors: + - Prerequisites: contrib/prereqs.md + - Libraries: contrib/libs.md + - Database: contrib/db.md + - Testing: contrib/test.md + - Troubleshooting: contrib/troubleshoot.md + - Code Style: contrib/style.md + - Git Guideline: contrib/git.md + - Codespaces: contrib/codespaces.md + - Documentation: contrib/docs.md + - Roadmap: contrib/roadmap.md + - Community: community.md + - faq.md + +# The following settings were originally partially inspired by +# https://github.com/enola-dev/enola/blob/main/mkdocs.yaml + +# https://squidfunk.github.io/mkdocs-material/reference/admonitions/ +markdown_extensions: + - admonition + - pymdownx.details + - pymdownx.superfences + +theme: + name: material + features: + - content.action.edit + - content.code.copy + - navigation.expand + - navigation.footer + - navigation.instant + - navigation.tracking + - navigation.path + - navigation.top + - search.suggest + - search.share + +markdown_extensions: + # https://facelessuser.github.io/pymdown-extensions/extensions/magiclink/ + - pymdownx.magiclink + +plugins: + - search + - git-revision-date-localized + - minify: + minify_html: true + +# https://www.mkdocs.org/user-guide/configuration/#validation +# Nota Bene: There is no "error" level in MkDocs, but the build-docs.bash script +# uses "mkdocs build --strict", which cause MkDocs to abort the build on any warnings. +validation: + nav: + not_found: warn + absolute_links: warn + omitted_files: warn + links: + anchors: warn + not_found: warn + absolute_links: warn + unrecognized_links: warn diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 000000000..af7542dff --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,31 @@ +import androidx.build.gradle.gcpbuildcache.GcpBuildCache +import androidx.build.gradle.gcpbuildcache.GcpBuildCacheServiceFactory + +plugins { + id("com.gradle.enterprise") version ("3.10") + id("androidx.build.gradle.gcpbuildcache") version "1.0.0-beta07" + id("org.gradle.toolchains.foojay-resolver-convention") version ("0.5.0") +} + +gradleEnterprise { + buildScan { + termsOfServiceUrl = "https://gradle.com/terms-of-service" + termsOfServiceAgree = "yes" + capture { isTaskInputFiles = true } + } +} + +val kokoroRun = providers.environmentVariable("KOKORO_BUILD_ID").isPresent + +if (kokoroRun == true) { + buildCache { + registerBuildCacheService(GcpBuildCache::class, GcpBuildCacheServiceFactory::class) + remote(GcpBuildCache::class) { + projectId = "android-fhir-build" + bucketName = "android-fhir-build-cache" + isPush = true + } + } +} + +include(":datacapture")