diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6f9c4e734e4..ea49da6400a 100755 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,8 +4,15 @@ name: CI on: + workflow_dispatch: pull_request: branches: [ main ] + merge_group: + branches: [ main ] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true env: FHIRCORE_USERNAME: ${{ secrets.FHIRCORE_USERNAME }} @@ -15,26 +22,27 @@ env: jobs: engine-tests: - runs-on: macos-13 + runs-on: ubuntu-latest strategy: matrix: - api-level: [30] + api-level: [34] + steps: - - name: Cancel Previous workflow runs - uses: styfle/cancel-workflow-action@0.9.1 - with: - access_token: ${{ github.token }} - - name: Checkout 🛎️ - uses: actions/checkout@v2 - with: - fetch-depth: 2 + uses: actions/checkout@v4 - name: Set up JDK 17 uses: actions/setup-java@v1 with: + distribution: temurin java-version: 17 + - name: Enable KVM + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + - name: Add empty local.properties run: touch local.properties working-directory: android @@ -46,19 +54,19 @@ jobs: - name: Grant execute permission for gradlew run: chmod +x gradlew working-directory: android - + - name: Copy CI gradle.properties run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties && cat ~/.gradle/gradle.properties - - name: Setup Gradle cache - uses: gradle/gradle-build-action@v2 + - name: Gradle cache + uses: gradle/actions/setup-gradle@v3 - name: Spotless check engine module run: ./gradlew -PlocalPropertiesFile=local.properties :engine:spotlessCheck :engine:ktlintCheck --stacktrace - working-directory: android - + working-directory: android + - name: Load AVD cache - uses: actions/cache@v2 + uses: actions/cache@v4 id: avd-cache with: path: | @@ -77,44 +85,53 @@ jobs: emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none disable-animations: false script: echo "Generated AVD snapshot for caching." - + - name: Run Engine module unit and instrumentation tests and generate coverage report uses: reactivecircus/android-emulator-runner@v2 - with: + with: working-directory: android api-level: ${{ matrix.api-level }} arch: x86_64 - force-avd-creation: true + force-avd-creation: false emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none disable-animations: true script: ./gradlew -PlocalPropertiesFile=local.properties :engine:clean :engine:fhircoreJacocoReport --stacktrace + - name: Upload Test reports + if: ${{ !cancelled() }} + uses: actions/upload-artifact@v4 + with: + name: engine-test-reports + path: android/engine/build/reports + + - name: Upload Engine module test coverage report to Codecov - if: matrix.api-level == 30 # Only upload coverage on API level 30 + if: matrix.api-level == 34 # Only upload coverage on API level 34 working-directory: android run: bash <(curl -s https://codecov.io/bash) -F engine -f "engine/build/reports/jacoco/fhircoreJacocoReport/fhircoreJacocoReport.xml" geowidget-tests: - runs-on: macos-13 + runs-on: ubuntu-latest strategy: matrix: - api-level: [30] + api-level: [34] + steps: - - name: Cancel Previous workflow runs - uses: styfle/cancel-workflow-action@0.9.1 - with: - access_token: ${{ github.token }} - - name: Checkout 🛎️ - uses: actions/checkout@v2 - with: - fetch-depth: 2 + uses: actions/checkout@v4 - name: Set up JDK 17 uses: actions/setup-java@v1 with: + distribution: temurin java-version: 17 + - name: Enable KVM + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + - name: Add empty local.properties run: touch local.properties working-directory: android @@ -126,19 +143,19 @@ jobs: - name: Grant execute permission for gradlew run: chmod +x gradlew working-directory: android - + - name: Copy CI gradle.properties run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties && cat ~/.gradle/gradle.properties - - name: Setup Gradle cache - uses: gradle/gradle-build-action@v2 + - name: Gradle cache + uses: gradle/actions/setup-gradle@v3 - name: Spotless check geowidget module run: ./gradlew -PlocalPropertiesFile=local.properties :geowidget:spotlessCheck --stacktrace - working-directory: android - + working-directory: android + - name: Load AVD cache - uses: actions/cache@v2 + uses: actions/cache@v4 id: avd-cache with: path: | @@ -157,10 +174,10 @@ jobs: emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none disable-animations: false script: echo "Generated AVD snapshot for caching." - + - name: Run Geowidget module unit and instrumentation tests and generate coverage report uses: reactivecircus/android-emulator-runner@v2 - with: + with: working-directory: android api-level: ${{ matrix.api-level }} arch: x86_64 @@ -169,31 +186,40 @@ jobs: disable-animations: true script: ./gradlew -PlocalPropertiesFile=local.properties :geowidget:clean :geowidget:fhircoreJacocoReport --stacktrace + - name: Upload Test reports + if: ${{ !cancelled() }} + uses: actions/upload-artifact@v4 + with: + name: geowidget-test-reports + path: android/geowidget/build/reports + - name: Upload Geowidget module test coverage report to Codecov - if: matrix.api-level == 30 # Only upload coverage on API level 30 + if: matrix.api-level == 34 # Only upload coverage on API level 34 working-directory: android run: bash <(curl -s https://codecov.io/bash) -F geowidget -f "geowidget/build/reports/jacoco/fhircoreJacocoReport/fhircoreJacocoReport.xml" quest-tests: - runs-on: macos-13 + runs-on: ubuntu-latest strategy: matrix: - api-level: [30] - steps: - - name: Cancel Previous workflow runs - uses: styfle/cancel-workflow-action@0.9.1 - with: - access_token: ${{ github.token }} + api-level: [34] + + steps: - name: Checkout 🛎️ - uses: actions/checkout@v2 - with: - fetch-depth: 2 + uses: actions/checkout@v4 - name: Set up JDK 17 uses: actions/setup-java@v1 with: + distribution: temurin java-version: 17 + - name: Enable KVM + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + - name: Add empty local.properties run: touch local.properties working-directory: android @@ -209,15 +235,15 @@ jobs: - name: Copy CI gradle.properties run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties && cat ~/.gradle/gradle.properties - - name: Setup Gradle cache - uses: gradle/gradle-build-action@v2 + - name: Gradle cache + uses: gradle/actions/setup-gradle@v3 - name: Spotless check quest application run: ./gradlew -PlocalPropertiesFile=local.properties :quest:spotlessCheck --stacktrace :quest:ktlintCheck --stacktrace working-directory: android - name: Load AVD cache - uses: actions/cache@v2 + uses: actions/cache@v4 id: avd-cache with: path: | @@ -237,7 +263,7 @@ jobs: disable-animations: false script: echo "Generated AVD snapshot for caching." - - name: Run Quest module unit and instrumentation tests and generate coverage report + - name: Run Quest module unit and instrumentation tests and generate unit tests coverage report uses: reactivecircus/android-emulator-runner@v2 with: working-directory: android @@ -246,10 +272,42 @@ jobs: force-avd-creation: true emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none disable-animations: true - script: ./gradlew clean -PlocalPropertiesFile=local.properties :quest:fhircoreJacocoReport --stacktrace -Pandroid.testInstrumentationRunnerArguments.notPackage=org.smartregister.fhircore.quest.performance - # ./gradlew -PlocalPropertiesFile=local.properties :quest:clean && ./gradlew -PlocalPropertiesFile=local.properties :quest:assembleOpensrpDebugAndroidTest --stacktrace && /Users/runner/Library/Android/sdk/platform-tools/adb install quest/build/outputs/apk/androidTest/opensrp/debug/quest-opensrp-debug-androidTest.apk && ./gradlew -PlocalPropertiesFile=local.properties :quest:assembleOpensrpDebug --stacktrace && /Users/runner/Library/Android/sdk/platform-tools/adb install quest/build/outputs/apk/opensrp/debug/quest-opensrp-debug.apk && /Users/runner/Library/Android/sdk/platform-tools/adb shell am instrument -w -e package org.smartregister.fhircore.quest.ui.profile -e coverage "true" org.smartregister.opensrp.test/org.smartregister.fhircore.quest.QuestTestRunner && /Users/runner/Library/Android/sdk/platform-tools/adb shell run-as org.smartregister.opensrp cat "/data/user/0/org.smartregister.opensrp/files/coverage.ec" > quest/coverage.ec && ./gradlew -PlocalPropertiesFile=local.properties :quest:fhircoreJacocoReport --stacktrace + script: ./gradlew clean -PlocalPropertiesFile=local.properties :quest:fhircoreJacocoReport --info -Pandroid.testInstrumentationRunnerArguments.notPackage=org.smartregister.fhircore.quest.performance + + - name: Run Quest module unit and instrumentation tests and generate aggregated coverage report (Disabled) + if: false + uses: reactivecircus/android-emulator-runner@v2 + with: + working-directory: android + api-level: ${{ matrix.api-level }} + arch: x86_64 + force-avd-creation: true + emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none + disable-animations: true + run: | + set -e + ./gradlew -PlocalPropertiesFile=local.properties :quest:clean + ./gradlew -PlocalPropertiesFile=local.properties :quest:assembleOpensrpDebugAndroidTest --stacktrace + /Users/martin/Library/Android/sdk/platform-tools/adb install quest/build/outputs/apk/androidTest/opensrp/debug/quest-opensrp-debug-androidTest.apk + ./gradlew -PlocalPropertiesFile=local.properties :quest:assembleOpensrpDebug --stacktrace + /Users/martin/Library/Android/sdk/platform-tools/adb install quest/build/outputs/apk/opensrp/debug/quest-opensrp-debug.apk + /Users/martin/Library/Android/sdk/platform-tools/adb shell am instrument -w \ + --no-window-animation \ + -e coverage "true" \ + -e debug false \ + org.smartregister.opensrp.test/org.smartregister.fhircore.quest.QuestTestRunner + /Users/martin/Library/Android/sdk/platform-tools/adb shell run-as org.smartregister.opensrp \ + cat "/data/user/0/org.smartregister.opensrp/files/coverage.ec" > quest/coverage.ec + ./gradlew -PlocalPropertiesFile=local.properties :quest:fhircoreJacocoReport --stacktrace + + - name: Upload Test reports + if: ${{ !cancelled() }} + uses: actions/upload-artifact@v4 + with: + name: quest-test-reports + path: android/quest/build/reports - name: Upload Quest module test coverage report to Codecov - if: matrix.api-level == 30 # Only upload coverage on API level 30 + if: matrix.api-level == 34 # Only upload coverage on API level 34 working-directory: android run: bash <(curl -s https://codecov.io/bash) -F quest -f "quest/build/reports/jacoco/fhircoreJacocoReport/fhircoreJacocoReport.xml" diff --git a/CHANGELOG.md b/CHANGELOG.md index 458ba75bd24..6b6de709108 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,8 +8,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [1.1.1] - 2024-05-20 ### Added -- Added a new class (PdfGenerator) for generating PDF documents from HTML content using Android's WebView and PrintManager -- Introduced a new class (HtmlPopulator) to populate HTML templates with data from a Questionnaire Response +- Added the in-app PDF Generation feature + 1. Added a new class (PdfGenerator) for generating PDF documents from HTML content using Android's WebView and PrintManager + 2. Introduced a new class (HtmlPopulator) to populate HTML templates with data from a Questionnaire Response + 3. Implemented functionality to launch PDF generation using a configuration setup +- Added Save draft MVP functionality ## [1.1.0] - 2024-02-15 diff --git a/LICENSE b/LICENSE index 7c07594ee6f..301d5cd1996 100644 --- a/LICENSE +++ b/LICENSE @@ -187,7 +187,7 @@ same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright 2021-2023, Ona Systems, Inc. + Copyright 2021-2024, Ona Systems, Inc. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/android/build.gradle.kts b/android/build.gradle.kts index 3dd868e56f2..837b0237748 100644 --- a/android/build.gradle.kts +++ b/android/build.gradle.kts @@ -44,7 +44,8 @@ allprojects { mavenCentral() maven(url = "https://oss.sonatype.org/content/repositories/snapshots") maven(url = "https://s01.oss.sonatype.org/content/repositories/snapshots") - maven(url = "https://jcenter.bintray.com/") + maven(url = "https://repo.spring.io/plugins-release") + maven(url = "https://repository.liferay.com/nexus/content/repositories/public") apply(plugin = "org.owasp.dependencycheck") tasks.dependencyCheckAggregate{ dependencyCheck.formats.add("XML") diff --git a/android/buildSrc/src/main/kotlin/BuildConfigs.kt b/android/buildSrc/src/main/kotlin/BuildConfigs.kt index d2175235cb7..b4ac17129a9 100644 --- a/android/buildSrc/src/main/kotlin/BuildConfigs.kt +++ b/android/buildSrc/src/main/kotlin/BuildConfigs.kt @@ -3,10 +3,13 @@ object BuildConfigs { const val compileSdk = 34 const val targetSdk = 34 const val versionCode = 11 - const val versionName = "2.0.0" + const val versionName = "2.0.1" const val applicationId = "org.smartregister.opensrp" const val jvmToolchain = 17 const val kotlinCompilerExtensionVersion = "1.5.8" const val jacocoVersion ="0.8.11" const val ktLintVersion = "0.49.0" -} \ No newline at end of file + const val enableUnitTestCoverage = true + const val enableAndroidTestCoverage = false +} + diff --git a/android/engine/build.gradle.kts b/android/engine/build.gradle.kts index 9eb47518aa4..018945e4572 100644 --- a/android/engine/build.gradle.kts +++ b/android/engine/build.gradle.kts @@ -3,7 +3,7 @@ import org.gradle.api.tasks.testing.logging.TestLogEvent plugins { `jacoco-report` - `ktlint` + ktlint id("com.android.library") id("kotlin-android") id("kotlin-kapt") @@ -31,7 +31,10 @@ android { } buildTypes { - getByName("debug") { enableUnitTestCoverage = true } + getByName("debug") { + enableUnitTestCoverage = BuildConfigs.enableUnitTestCoverage + enableAndroidTestCoverage = BuildConfigs.enableAndroidTestCoverage + } create("debugNonProxy") { initWith(getByName("debug")) @@ -159,11 +162,10 @@ dependencies { api(libs.glide) api(libs.knowledge) { exclude(group = "org.slf4j", module = "jcl-over-slf4j") } api(libs.p2p.lib) - api(libs.jjwt) + api(libs.java.jwt) api(libs.fhir.common.utils) { exclude(group = "org.slf4j", module = "jcl-over-slf4j") } api(libs.runtime.livedata) api(libs.foundation) - api(libs.fhir.common.utils) api(libs.kotlinx.serialization.json) api(libs.work.runtime.ktx) api(libs.prettytime) @@ -173,16 +175,12 @@ dependencies { api(libs.timber) api(libs.converter.gson) api(libs.json.path) - api(libs.commons.jexl3) { exclude(group = "commons-logging", module = "commons-logging") } - api(libs.easy.rules.jexl) { - exclude(group = "commons-logging", module = "commons-logging") - exclude(group = "org.apache.commons", module = "commons-jexl3") - } + api(libs.easy.rules.jexl) { exclude(group = "commons-logging", module = "commons-logging") } api(libs.data.capture) { isTransitive = true exclude(group = "ca.uhn.hapi.fhir") - exclude(group = "com.google.android.fhir", module = "engine") exclude(group = "com.google.android.fhir", module = "common") + exclude(group = "org.smartregister", module = "common") exclude(group = "org.slf4j", module = "jcl-over-slf4j") } api(libs.cqf.fhir.cr) { @@ -195,27 +193,22 @@ dependencies { exclude(group = "xerces") exclude(group = "com.github.java-json-tools") exclude(group = "org.codehaus.woodstox") - exclude(group = "com.google.android.fhir", module = "common") exclude(group = "com.google.android.fhir", module = "engine") + exclude(group = "org.smartregister", module = "engine") exclude(group = "com.github.ben-manes.caffeine") } api(libs.contrib.barcode) { isTransitive = true exclude(group = "org.smartregister", module = "data-capture") exclude(group = "ca.uhn.hapi.fhir") - exclude(group = "com.google.android.fhir", module = "common") - exclude(group = "com.google.android.fhir", module = "engine") } api(libs.contrib.locationwidget) { isTransitive = true exclude(group = "org.smartregister", module = "data-capture") exclude(group = "ca.uhn.hapi.fhir") - exclude(group = "com.google.android.fhir", module = "common") - exclude(group = "com.google.android.fhir", module = "engine") } api(libs.fhir.engine) { isTransitive = true - exclude(group = "com.google.android.fhir", module = "common") exclude(group = "com.github.ben-manes.caffeine") } @@ -263,6 +256,7 @@ dependencies { implementation(Dependencies.HapiFhir.structuresR4) { exclude(module = "junit") } implementation(Dependencies.HapiFhir.guavaCaching) implementation(Dependencies.HapiFhir.validationR4) + implementation(Dependencies.HapiFhir.validationR5) implementation(Dependencies.HapiFhir.validation) { exclude(module = "commons-logging") exclude(module = "httpclient") diff --git a/android/engine/src/androidTest/java/org/smartregister/fhircore/engine/util/extension/FhirEngineExtensionKtTest.kt b/android/engine/src/androidTest/java/org/smartregister/fhircore/engine/util/extension/FhirEngineExtensionKtTest.kt new file mode 100644 index 00000000000..162818f6e9b --- /dev/null +++ b/android/engine/src/androidTest/java/org/smartregister/fhircore/engine/util/extension/FhirEngineExtensionKtTest.kt @@ -0,0 +1,98 @@ +/* + * Copyright 2021-2024 Ona Systems, Inc + * + * 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 org.smartregister.fhircore.engine.util.extension + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import androidx.test.filters.MediumTest +import com.google.android.fhir.FhirEngine +import com.google.android.fhir.FhirEngineConfiguration +import com.google.android.fhir.FhirEngineProvider +import com.google.android.fhir.search.search +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.runTest +import org.hl7.fhir.r4.model.Patient +import org.hl7.fhir.r4.model.Questionnaire +import org.hl7.fhir.r4.model.Resource +import org.hl7.fhir.r4.model.ResourceType +import org.junit.After +import org.junit.Assert +import org.junit.Before +import org.junit.Test + +@MediumTest +class FhirEngineExtensionKtTest { + + private val context = ApplicationProvider.getApplicationContext() + private lateinit var fhirEngine: FhirEngine + + @Before + fun setUp() { + FhirEngineProvider.init(FhirEngineConfiguration(testMode = true)) + fhirEngine = FhirEngineProvider.getInstance(context) + + val patients = (0..1000).map { Patient().apply { id = "test-patient-$it" } } + val questionnaires = (0..3).map { Questionnaire().apply { id = "test-questionnaire-$it" } } + runBlocking { fhirEngine.create(*patients.toTypedArray(), *questionnaires.toTypedArray()) } + } + + @After + fun tearDown() { + runBlocking { fhirEngine.clearDatabase() } + FhirEngineProvider.cleanup() + } + + @Test + fun test_search_time_searches_sequentially_and_short_running_query_waits() = runTest { + val fetchedResources = mutableListOf() + + val patients = fhirEngine.search {}.map { it.resource } + fetchedResources += patients + + val questionnaires = fhirEngine.search {}.map { it.resource } + fetchedResources += questionnaires + + val indexOfResultOfShortQuery = + fetchedResources.indexOfFirst { it.resourceType == ResourceType.Questionnaire } + val indexOfResultOfLongQuery = + fetchedResources.indexOfFirst { it.resourceType == ResourceType.Patient } + Assert.assertTrue(indexOfResultOfShortQuery > indexOfResultOfLongQuery) + } + + @Test + fun test_batchedSearch_returns_short_running_query_and_long_running_does_not_block() { + val fetchedResources = mutableListOf() + runBlocking { + launch { + val patients = fhirEngine.batchedSearch {}.map { it.resource } + fetchedResources += patients + } + + launch { + val questionnaires = fhirEngine.search {} + fetchedResources + questionnaires + } + } + + val indexOfResultOfShortQuery = + fetchedResources.indexOfFirst { it.resourceType == ResourceType.Questionnaire } + val indexOfResultOfLongQuery = + fetchedResources.indexOfFirst { it.resourceType == ResourceType.Patient } + Assert.assertTrue(indexOfResultOfShortQuery < indexOfResultOfLongQuery) + } +} diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/ConfigurationRegistry.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/ConfigurationRegistry.kt index c09dba86c9d..50579cce387 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/ConfigurationRegistry.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/ConfigurationRegistry.kt @@ -19,47 +19,48 @@ package org.smartregister.fhircore.engine.configuration import android.content.Context import android.database.SQLException import ca.uhn.fhir.context.ConfigurationException -import ca.uhn.fhir.context.FhirContext import ca.uhn.fhir.parser.DataFormatException import com.google.android.fhir.FhirEngine import com.google.android.fhir.db.ResourceNotFoundException import com.google.android.fhir.get import com.google.android.fhir.knowledge.KnowledgeManager +import com.google.android.fhir.sync.download.ResourceSearchParams import dagger.hilt.android.qualifiers.ApplicationContext -import java.io.File import java.io.FileNotFoundException +import java.io.InputStreamReader import java.net.UnknownHostException -import java.nio.charset.StandardCharsets -import java.util.LinkedList import java.util.Locale import java.util.PropertyResourceBundle import java.util.ResourceBundle import javax.inject.Inject import javax.inject.Singleton +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json import okhttp3.RequestBody.Companion.toRequestBody -import okio.ByteString.Companion.decodeBase64 import org.hl7.fhir.r4.model.Base import org.hl7.fhir.r4.model.Binary import org.hl7.fhir.r4.model.Bundle import org.hl7.fhir.r4.model.Composition +import org.hl7.fhir.r4.model.Enumerations import org.hl7.fhir.r4.model.ImplementationGuide import org.hl7.fhir.r4.model.ListResource import org.hl7.fhir.r4.model.MetadataResource +import org.hl7.fhir.r4.model.Parameters import org.hl7.fhir.r4.model.Resource import org.hl7.fhir.r4.model.ResourceType +import org.hl7.fhir.r4.model.SearchParameter import org.jetbrains.annotations.VisibleForTesting import org.json.JSONObject import org.smartregister.fhircore.engine.BuildConfig +import org.smartregister.fhircore.engine.configuration.app.ApplicationConfiguration import org.smartregister.fhircore.engine.configuration.app.ConfigService -import org.smartregister.fhircore.engine.configuration.profile.ProfileConfiguration -import org.smartregister.fhircore.engine.configuration.register.RegisterConfiguration import org.smartregister.fhircore.engine.data.remote.fhir.resource.FhirResourceDataSource import org.smartregister.fhircore.engine.di.NetworkModule -import org.smartregister.fhircore.engine.domain.model.FhirResourceConfig -import org.smartregister.fhircore.engine.domain.model.ResourceConfig +import org.smartregister.fhircore.engine.domain.model.MultiSelectViewAction import org.smartregister.fhircore.engine.util.DispatcherProvider +import org.smartregister.fhircore.engine.util.KnowledgeManagerUtil import org.smartregister.fhircore.engine.util.SharedPreferenceKey import org.smartregister.fhircore.engine.util.SharedPreferencesHelper import org.smartregister.fhircore.engine.util.extension.camelCase @@ -71,10 +72,9 @@ import org.smartregister.fhircore.engine.util.extension.extractLogicalIdUuid import org.smartregister.fhircore.engine.util.extension.fileExtension import org.smartregister.fhircore.engine.util.extension.generateMissingId import org.smartregister.fhircore.engine.util.extension.interpolate -import org.smartregister.fhircore.engine.util.extension.referenceValue import org.smartregister.fhircore.engine.util.extension.retrieveCompositionSections +import org.smartregister.fhircore.engine.util.extension.retrieveRelatedEntitySyncLocationState import org.smartregister.fhircore.engine.util.extension.searchCompositionByIdentifier -import org.smartregister.fhircore.engine.util.extension.tryDecodeJson import org.smartregister.fhircore.engine.util.extension.updateLastUpdated import org.smartregister.fhircore.engine.util.helper.LocalizationHelper import retrofit2.HttpException @@ -100,9 +100,7 @@ constructor( val localizationHelper: LocalizationHelper by lazy { LocalizationHelper(this) } private val supportedFileExtensions = listOf("json", "properties") private var _isNonProxy = BuildConfig.IS_NON_PROXY_APK - private val fhirContext = FhirContext.forR4Cached() - private val authConfiguration = configService.provideAuthConfiguration() - private val jsonParser = fhirContext.newJsonParser() + private val mutex = Mutex() /** * Retrieve configuration for the provided [ConfigType]. The JSON retrieved from [configsJsonMap] @@ -183,7 +181,9 @@ constructor( val resourceBundle = configsJsonMap[bundleName.camelCase()] // Convention for config map keys is camelCase if (resourceBundle != null) { - return PropertyResourceBundle(resourceBundle.byteInputStream()) + return PropertyResourceBundle( + InputStreamReader(resourceBundle.byteInputStream(), Charsets.UTF_8), + ) } if (bundleName.contains("_")) { return retrieveResourceBundleConfiguration( @@ -369,19 +369,23 @@ constructor( * @return A list of strings of config files. */ private fun retrieveAssetConfigs(context: Context, appId: String): MutableList { - val filesQueue = LinkedList() + val filesQueue = ArrayDeque() val configFiles = mutableListOf() context.assets.list(String.format(BASE_CONFIG_PATH, appId))?.onEach { if (!supportedFileExtensions.contains(it.fileExtension)) { filesQueue.addLast(String.format(BASE_CONFIG_PATH, appId) + "/$it") - } else configFiles.add(String.format(BASE_CONFIG_PATH, appId) + "/$it") + } else { + configFiles.add(String.format(BASE_CONFIG_PATH, appId) + "/$it") + } } while (filesQueue.isNotEmpty()) { val currentPath = filesQueue.removeFirst() context.assets.list(currentPath)?.onEach { if (!supportedFileExtensions.contains(it.fileExtension)) { filesQueue.addLast("$currentPath/$it") - } else configFiles.add("$currentPath/$it") + } else { + configFiles.add("$currentPath/$it") + } } } return configFiles @@ -404,18 +408,16 @@ constructor( */ @Throws(UnknownHostException::class, HttpException::class) suspend fun fetchNonWorkflowConfigResources() { + Timber.d("Triggered fetching application configurations remotely") configCacheMap.clear() sharedPreferencesHelper.read(SharedPreferenceKey.APP_ID.name, null)?.let { appId -> val parsedAppId = appId.substringBefore(TYPE_REFERENCE_DELIMITER).trim() - val patientRelatedResourceTypes = mutableListOf() val compositionResource = fetchRemoteCompositionByAppId(parsedAppId) compositionResource?.let { composition -> composition .retrieveCompositionSections() .asSequence() - .filter { - it.hasFocus() && it.focus.hasReferenceElement() - } // is focus.identifier a necessary check + .filter { it.hasFocus() && it.focus.hasReferenceElement() } .groupBy { section -> section.focus.reference.substringBefore( TYPE_REFERENCE_DELIMITER, @@ -425,35 +427,33 @@ constructor( .filter { entry -> entry.key in FILTER_RESOURCE_LIST } .forEach { entry: Map.Entry> -> if (entry.key == ResourceType.List.name) { - processCompositionListResources( - entry, - patientRelatedResourceTypes = patientRelatedResourceTypes, - ) + processCompositionListResources(entry) } else { val chunkedResourceIdList = entry.value.chunked(MANIFEST_PROCESSOR_BATCH_SIZE) chunkedResourceIdList.forEach { sectionComponents -> Timber.d( - "Fetching config resource ${entry.key}: with ids ${sectionComponents.joinToString(",")}", + "Fetching config resource ${entry.key}: with ids ${ + sectionComponents.joinToString( + ",", + ) + }", ) - processCompositionManifestResources( + fetchResources( resourceType = entry.key, resourceIdList = sectionComponents.map { sectionComponent -> sectionComponent.focus.extractId() }, - patientRelatedResourceTypes = patientRelatedResourceTypes, ) } } } - saveSyncSharedPreferences(patientRelatedResourceTypes.toList()) - // Save composition after fetching all the referenced section resources addOrUpdate(compositionResource) - Timber.d("Done fetching application configurations remotely") + Timber.d("Done saving composition resource") } } } @@ -505,60 +505,58 @@ constructor( } } - private suspend fun processCompositionManifestResources( + private suspend fun fetchResources( resourceType: String, resourceIdList: List, - patientRelatedResourceTypes: MutableList, ): Bundle { val resultBundle = if (isNonProxy()) { fhirResourceDataSourceGetBundle(resourceType, resourceIdList) - } else + } else { fhirResourceDataSource.post( requestBody = generateRequestBundle(resourceType, resourceIdList) .encodeResourceToString() .toRequestBody(NetworkModule.JSON_MEDIA_TYPE), ) + } - processResultBundleEntries(resultBundle.entry, patientRelatedResourceTypes) + processResultBundleEntries(resultBundle.entry) return resultBundle } - private suspend fun processCompositionManifestResources( + suspend fun fetchResources( gatewayModeHeaderValue: String? = null, - searchPath: String, - patientRelatedResourceTypes: MutableList, + url: String, ) { - val resultBundle = fetchResourceBundle(gatewayModeHeaderValue, searchPath) - val nextPageUrl = resultBundle.getLink(PAGINATION_NEXT)?.url ?: "" + val resultBundle = + runCatching { + if (gatewayModeHeaderValue.isNullOrEmpty()) { + fhirResourceDataSource.getResource(url) + } else { + fhirResourceDataSource.getResourceWithGatewayModeHeader(gatewayModeHeaderValue, url) + } + } + .onFailure { throwable -> + Timber.e("Error occurred while retrieving resource via URL $url", throwable) + } + .getOrThrow() - processResultBundleEntries(resultBundle.entry, patientRelatedResourceTypes) + val nextPageUrl = resultBundle.getLink(PAGINATION_NEXT)?.url - if (nextPageUrl.isNotEmpty()) { - processCompositionManifestResources( - gatewayModeHeaderValue, - nextPageUrl, - patientRelatedResourceTypes, - ) - } - } + processResultBundleEntries(resultBundle.entry) - private suspend fun fetchResourceBundle( - gatewayModeHeaderValue: String?, - searchPath: String, - ): Bundle { - return if (gatewayModeHeaderValue.isNullOrEmpty()) { - fhirResourceDataSource.getResource(searchPath) - } else { - fhirResourceDataSource.getResourceWithGatewayModeHeader(gatewayModeHeaderValue, searchPath) + if (!nextPageUrl.isNullOrEmpty()) { + fetchResources( + gatewayModeHeaderValue = gatewayModeHeaderValue, + url = nextPageUrl, + ) } } private suspend fun processResultBundleEntries( resultBundleEntries: List, - patientRelatedResourceTypes: MutableList, ) { resultBundleEntries.forEach { bundleEntryComponent -> when (bundleEntryComponent.resource) { @@ -569,24 +567,17 @@ constructor( is Bundle -> { val thisBundle = entryComponent.resource as Bundle addOrUpdate(thisBundle) - thisBundle.entry.forEach { innerEntryComponent -> - saveListEntryResource(innerEntryComponent) - } + processResultBundleEntries(thisBundle.entry) } - else -> saveListEntryResource(entryComponent) + else -> addOrUpdate(entryComponent.resource) } } } - is Binary -> { - val binary = bundleEntryComponent.resource as Binary - processResultBundleBinaries(binary, patientRelatedResourceTypes) - addOrUpdate(bundleEntryComponent.resource) - } else -> { if (bundleEntryComponent.resource != null) { addOrUpdate(bundleEntryComponent.resource) Timber.d( - "Fetched and processed resources ${bundleEntryComponent.resource.resourceType}/${bundleEntryComponent.resource.id}", + "Fetched and processed resources ${bundleEntryComponent.resource.resourceType}/${bundleEntryComponent.resource.idPart}", ) } } @@ -594,13 +585,6 @@ constructor( } } - private suspend fun saveListEntryResource(entryComponent: Bundle.BundleEntryComponent) { - addOrUpdate(entryComponent.resource) - Timber.d( - "Fetched and processed List reference ${entryComponent.resource.resourceType}/${entryComponent.resource.id}", - ) - } - /** * Update this stored resources with the passed resource, or create it if not found. If the * resource is a Metadata Resource save it in the Knowledge Manager @@ -616,14 +600,22 @@ constructor( } /** - * Knowledge manager [MetadataResource]s install Here we install all resources types of + * Knowledge manager [MetadataResource]s install. Here we install all resources types of * [MetadataResource] as per FHIR Spec.This supports future use cases as well */ try { - if (resource is MetadataResource && resource.name != null) { - knowledgeManager.install( - writeToFile(resource.overwriteCanonicalURL()), - ) + if (resource is MetadataResource) { + mutex.withLock { + knowledgeManager.install( + KnowledgeManagerUtil.writeToFile( + context = context, + configService = configService, + metadataResource = resource, + subFilePath = + "${KnowledgeManagerUtil.KNOWLEDGE_MANAGER_ASSETS_SUBFOLDER}/${resource.resourceType}/${resource.idElement.idPart}.json", + ), + ) + } } } catch (exception: Exception) { Timber.e(exception) @@ -631,26 +623,6 @@ constructor( } } - private fun MetadataResource.overwriteCanonicalURL() = - this.apply { - url = - url - ?: """${authConfiguration.fhirServerBaseUrl.trimEnd { it == '/' }}/${this.referenceValue()}""" - } - - fun writeToFile(resource: Resource): File { - val fileName = - if (resource is MetadataResource && resource.name != null) { - resource.name - } else { - resource.idElement.idPart - } - - return File(context.filesDir, "$fileName.json").apply { - writeText(jsonParser.encodeResourceToString(resource)) - } - } - /** * Using this [FhirEngine] and [DispatcherProvider], for all passed resources, make sure they all * have IDs or generate if they don't, then pass them to create. @@ -700,41 +672,26 @@ constructor( private suspend fun fhirResourceDataSourceGetBundle( resourceType: String, resourceIds: List, - ): Bundle { - val bundleEntryComponents = mutableListOf() - - resourceIds.forEach { - val responseBundle = - fhirResourceDataSource.getResource("$resourceType?${Composition.SP_RES_ID}=$it") - responseBundle.let { - bundleEntryComponents.add( - Bundle.BundleEntryComponent().apply { resource = it.entry?.firstOrNull()?.resource }, - ) - } - } - return Bundle().apply { + ): Bundle = + Bundle().apply { type = Bundle.BundleType.COLLECTION - entry = bundleEntryComponents + entry = + resourceIds + .map { + fhirResourceDataSource.getResource("$resourceType?${Composition.SP_RES_ID}=$it").entry + } + .flatten() } - } - - fun clearConfigsCache() = configCacheMap.clear() private suspend fun processCompositionListResources( - resourceGroup: - Map.Entry< - String, - List, - >, - patientRelatedResourceTypes: MutableList, + sectionComponentEntry: Map.Entry>, ) { if (isNonProxy()) { - val chunkedResourceIdList = resourceGroup.value.chunked(MANIFEST_PROCESSOR_BATCH_SIZE) + val chunkedResourceIdList = sectionComponentEntry.value.chunked(MANIFEST_PROCESSOR_BATCH_SIZE) chunkedResourceIdList.forEach { - processCompositionManifestResources( - resourceType = resourceGroup.key, + fetchResources( + resourceType = sectionComponentEntry.key, resourceIdList = it.map { sectionComponent -> sectionComponent.focus.extractId() }, - patientRelatedResourceTypes = patientRelatedResourceTypes, ) .entry .forEach { bundleEntryComponent -> @@ -744,70 +701,92 @@ constructor( val list = bundleEntryComponent.resource as ListResource list.entry.forEach { listEntryComponent -> val resourceKey = - listEntryComponent.item.reference.substringBefore( - TYPE_REFERENCE_DELIMITER, - ) + listEntryComponent.item.reference.substringBefore(TYPE_REFERENCE_DELIMITER) val resourceId = listEntryComponent.item.reference.extractLogicalIdUuid() val listResourceUrlPath = "$resourceKey?$ID=$resourceId&_count=$DEFAULT_COUNT" - fhirResourceDataSource.getResource(listResourceUrlPath).entry.forEach { - listEntryResourceBundle -> - addOrUpdate(listEntryResourceBundle.resource) - Timber.d("Fetched and processed List reference $listResourceUrlPath") - } + fetchResources(gatewayModeHeaderValue = null, url = listResourceUrlPath) } } } } } } else { - resourceGroup.value.forEach { - processCompositionManifestResources( + sectionComponentEntry.value.forEach { + fetchResources( gatewayModeHeaderValue = FHIR_GATEWAY_MODE_HEADER_VALUE, - searchPath = - "${resourceGroup.key}?$ID=${it.focus.extractId()}&_page=1&_count=$DEFAULT_COUNT", - patientRelatedResourceTypes = patientRelatedResourceTypes, + url = + "${sectionComponentEntry.key}?$ID=${it.focus.extractId()}&_page=1&_count=$DEFAULT_COUNT", ) } } } - private fun FhirResourceConfig.dependentResourceTypes(target: MutableList) { - this.baseResource.dependentResourceTypes(target) - this.relatedResources.forEach { it.dependentResourceTypes(target) } - } - - private fun ResourceConfig.dependentResourceTypes(target: MutableList) { - target.add(resource) - relatedResources.forEach { it.dependentResourceTypes(target) } - } + suspend fun loadResourceSearchParams(): + Pair>, ResourceSearchParams> { + val syncConfig = retrieveResourceConfiguration(ConfigType.Sync) + val appConfig = retrieveConfiguration(ConfigType.Application) + val customResourceSearchParams = mutableMapOf>() + val fhirResourceSearchParams = mutableMapOf>() + val organizationResourceTag = + configService.defineResourceTags().find { it.type == ResourceType.Organization.name } + val mandatoryTags = configService.provideResourceTags(sharedPreferencesHelper) + + val locationIds = + context.retrieveRelatedEntitySyncLocationState(MultiSelectViewAction.SYNC_DATA).map { + it.locationId + } - fun processResultBundleBinaries( - binary: Binary, - patientRelatedResourceTypes: MutableList, - ) { - binary.data.decodeToString().decodeBase64()?.string(StandardCharsets.UTF_8)?.let { - val config = - it.tryDecodeJson() ?: it.tryDecodeJson() - - when (config) { - is RegisterConfiguration -> - config.fhirResource.dependentResourceTypes( - patientRelatedResourceTypes, - ) - is ProfileConfiguration -> - config.fhirResource.dependentResourceTypes( - patientRelatedResourceTypes, - ) + syncConfig.parameter + .map { it.resource as SearchParameter } + .forEach { searchParameter -> + val paramName = searchParameter.name + val paramLiteral = "#$paramName" // e.g. #organization in expression for replacement + val paramExpression = searchParameter.expression + val expressionValue = + when (paramName) { + ORGANIZATION -> + mandatoryTags + .firstOrNull { + it.system.contentEquals(organizationResourceTag?.tag?.system, ignoreCase = true) + } + ?.code + COUNT -> appConfig.remoteSyncPageSize.toString() + else -> paramExpression + }?.let { paramExpression?.replace(paramLiteral, it) } + + // Create query param for each ResourceType p e.g.[Patient=[name=Abc, organization=111] + searchParameter.base + .mapNotNull { it.code } + .forEach { code -> + if (searchParameter.type == Enumerations.SearchParamType.SPECIAL) { + val resourceQueryParamMap = + customResourceSearchParams + .getOrPut(code) { mutableMapOf() } + .apply { + expressionValue?.let { value -> put(searchParameter.code, value) } + if (locationIds.isNotEmpty()) { + put(SYNC_LOCATION_IDS, locationIds.joinToString(",")) + } + } + customResourceSearchParams[code] = resourceQueryParamMap + } else { + val resourceType = ResourceType.fromCode(code) + val resourceQueryParamMap = + fhirResourceSearchParams + .getOrPut(resourceType) { mutableMapOf() } + .apply { + expressionValue?.let { value -> put(searchParameter.code, value) } + if (locationIds.isNotEmpty()) { + put(SYNC_LOCATION_IDS, locationIds.joinToString(",")) + } + } + fhirResourceSearchParams[resourceType] = resourceQueryParamMap + } + } } - } + return Pair(customResourceSearchParams, fhirResourceSearchParams) } - fun saveSyncSharedPreferences(resourceTypes: List) = - sharedPreferencesHelper.write( - SharedPreferenceKey.REMOTE_SYNC_RESOURCES.name, - resourceTypes.distinctBy { it.name }, - ) - companion object { const val BASE_CONFIG_PATH = "configs/%s" const val COMPOSITION_CONFIG_PATH = "configs/%s/composition_config.json" @@ -825,6 +804,7 @@ constructor( const val DEFAULT_COUNT = 200 const val PAGINATION_NEXT = "next" const val RESOURCES_PATH = "resources/" + const val SYNC_LOCATION_IDS = "_syncLocations" /** * The list of resources whose types can be synced down as part of the Composition configs. diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/PdfConfig.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/PdfConfig.kt new file mode 100644 index 00000000000..819d95eb852 --- /dev/null +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/PdfConfig.kt @@ -0,0 +1,42 @@ +/* + * Copyright 2021-2024 Ona Systems, Inc + * + * 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 org.smartregister.fhircore.engine.configuration + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize +import kotlinx.serialization.Serializable +import org.smartregister.fhircore.engine.util.extension.interpolate + +@Serializable +@Parcelize +data class PdfConfig( + val title: String? = null, + val titleSuffix: String? = null, + val structureReference: String? = null, + val subjectReference: String? = null, + val questionnaireReferences: List = emptyList(), +) : java.io.Serializable, Parcelable { + + fun interpolate(computedValuesMap: Map) = + this.copy( + title = title?.interpolate(computedValuesMap), + titleSuffix = titleSuffix?.interpolate(computedValuesMap), + structureReference = structureReference?.interpolate(computedValuesMap), + subjectReference = subjectReference?.interpolate(computedValuesMap), + questionnaireReferences = questionnaireReferences.map { it.interpolate(computedValuesMap) }, + ) +} diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/QuestionnaireConfig.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/QuestionnaireConfig.kt index b4baff78b31..ff865ce5666 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/QuestionnaireConfig.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/QuestionnaireConfig.kt @@ -46,6 +46,7 @@ data class QuestionnaireConfig( val confirmationDialog: ConfirmationDialog? = null, val groupResource: GroupResourceConfig? = null, val taskId: String? = null, + val encounterId: String? = null, val saveDraft: Boolean = false, val snackBarMessage: SnackBarMessageConfig? = null, val eventWorkflows: List = emptyList(), @@ -65,12 +66,14 @@ data class QuestionnaireConfig( val managingEntityRelationshipCode: String? = null, val uniqueIdAssignment: UniqueIdAssignmentConfig? = null, val linkIds: List? = null, + val showSubmitAnywayButton: String = "false", ) : java.io.Serializable, Parcelable { fun interpolate(computedValuesMap: Map) = this.copy( id = id.interpolate(computedValuesMap).extractLogicalIdUuid(), taskId = taskId?.interpolate(computedValuesMap), + encounterId = encounterId?.interpolate(computedValuesMap), title = title?.interpolate(computedValuesMap), type = type.interpolate(computedValuesMap), managingEntityRelationshipCode = @@ -98,6 +101,7 @@ data class QuestionnaireConfig( uniqueIdAssignment?.copy(linkId = uniqueIdAssignment.linkId.interpolate(computedValuesMap)), linkIds = linkIds?.onEach { it.linkId.interpolate(computedValuesMap) }, saveButtonText = saveButtonText?.interpolate(computedValuesMap), + showSubmitAnywayButton = showSubmitAnywayButton.interpolate(computedValuesMap), ) } @@ -163,5 +167,5 @@ enum class LinkIdType : Parcelable { READ_ONLY, BARCODE, LOCATION, - IDENTIFIER, + PREPOPULATION_EXCLUSION, } diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/app/ApplicationConfiguration.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/app/ApplicationConfiguration.kt index ce6ce3763f2..27076b4a8fa 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/app/ApplicationConfiguration.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/app/ApplicationConfiguration.kt @@ -21,6 +21,7 @@ import org.smartregister.fhircore.engine.configuration.ConfigType import org.smartregister.fhircore.engine.configuration.Configuration import org.smartregister.fhircore.engine.configuration.event.EventWorkflow import org.smartregister.fhircore.engine.domain.model.LauncherType +import org.smartregister.fhircore.engine.util.extension.DEFAULT_FORMAT_SDF_DD_MM_YYYY @Serializable data class ApplicationConfiguration( @@ -57,6 +58,7 @@ data class ApplicationConfiguration( id = null, ), val codingSystems: List = emptyList(), + var dateFormat: String = DEFAULT_FORMAT_SDF_DD_MM_YYYY, ) : Configuration() enum class SyncStrategy { diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/app/ConfigService.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/app/ConfigService.kt index 9f238c46e79..502cb2cde88 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/app/ConfigService.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/app/ConfigService.kt @@ -73,7 +73,7 @@ interface ConfigService { /** * Provide a list of custom search parameters. * - * @return list of predefined custom group search parameters. + * @return list of predefined custom search parameters. */ fun provideCustomSearchParameters(): List { val activeGroupSearchParameter = @@ -87,11 +87,53 @@ interface ConfigService { description = "Search the active field" } - return listOf(activeGroupSearchParameter) + val flagStatusSearchParameter = + SearchParameter().apply { + url = "http://smartregister.org/SearchParameter/flag-status" + addBase("Flag") + name = STATUS_SEARCH_PARAM + code = STATUS_SEARCH_PARAM + type = Enumerations.SearchParamType.TOKEN + expression = "Flag.status" + description = "Search the status field" + } + + val medicationSortSearchParameter = + SearchParameter().apply { + url = MEDICATION_SORT_URL + addBase("Medication") + name = SORT_SEARCH_PARAM + code = SORT_SEARCH_PARAM + type = Enumerations.SearchParamType.NUMBER + expression = "Medication.extension.where(url = '$MEDICATION_SORT_URL').value" + description = "Search the sort field" + } + + val patientSearchParameter = + SearchParameter().apply { + url = "http://smartregister.org/SearchParameter/patient-search" + addBase("Patient") + name = SEARCH_PARAM + code = SEARCH_PARAM + type = Enumerations.SearchParamType.STRING + expression = "Patient.name.text | Patient.identifier.value" + description = "Search patients by name and identifier fields" + } + + return listOf( + activeGroupSearchParameter, + flagStatusSearchParameter, + medicationSortSearchParameter, + patientSearchParameter, + ) } companion object { const val ACTIVE_SEARCH_PARAM = "active" const val APP_VERSION = "AppVersion" + const val STATUS_SEARCH_PARAM = "status" + const val SORT_SEARCH_PARAM = "sort" + const val SEARCH_PARAM = "search" + const val MEDICATION_SORT_URL = "http://smartregister.org/SearchParameter/medication-sort" } } diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/app/LoginConfig.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/app/LoginConfig.kt index ae3d60868bc..1d4506f5e91 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/app/LoginConfig.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/app/LoginConfig.kt @@ -27,4 +27,5 @@ data class LoginConfig( val logoHeight: Int = 120, val logoWidth: Int = 140, val showAppTitle: Boolean = true, + val supervisorContactNumber: String? = null, ) diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/geowidget/GeoWidgetConfiguration.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/geowidget/GeoWidgetConfiguration.kt index 3daadcc499d..45158b8ea35 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/geowidget/GeoWidgetConfiguration.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/geowidget/GeoWidgetConfiguration.kt @@ -20,6 +20,7 @@ import kotlinx.serialization.Serializable import org.smartregister.fhircore.engine.configuration.ConfigType import org.smartregister.fhircore.engine.configuration.Configuration import org.smartregister.fhircore.engine.configuration.QuestionnaireConfig +import org.smartregister.fhircore.engine.configuration.register.NoResultsConfig import org.smartregister.fhircore.engine.domain.model.ActionConfig import org.smartregister.fhircore.engine.domain.model.FhirResourceConfig import org.smartregister.fhircore.engine.domain.model.RuleConfig @@ -40,6 +41,8 @@ data class GeoWidgetConfiguration( val servicePointConfig: ServicePointConfig?, val summaryBottomSheetConfig: SummaryBottomSheetConfig? = null, val actions: List? = emptyList(), + val noResults: NoResultsConfig? = null, + val filterDataByRelatedEntityLocation: Boolean? = null, ) : Configuration() @Serializable diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/migration/MigrationConfig.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/migration/MigrationConfig.kt index 1cceb2f6ba3..4f3a67a8950 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/migration/MigrationConfig.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/migration/MigrationConfig.kt @@ -30,6 +30,7 @@ data class MigrationConfig( val purgeAffectedResources: Boolean = false, val createLocalChangeEntitiesAfterPurge: Boolean = true, val resourceFilterExpression: ResourceFilterExpression? = null, + val secondaryResources: List? = null, ) : java.io.Serializable @Serializable diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/navigation/NavigationMenuConfig.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/navigation/NavigationMenuConfig.kt index 760f9f2700b..f545c4481e4 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/navigation/NavigationMenuConfig.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/navigation/NavigationMenuConfig.kt @@ -16,14 +16,15 @@ package org.smartregister.fhircore.engine.configuration.navigation -import android.graphics.Bitmap import android.os.Parcelable import kotlinx.parcelize.Parcelize -import kotlinx.serialization.Contextual import kotlinx.serialization.Serializable import org.smartregister.fhircore.engine.domain.model.ActionConfig import org.smartregister.fhircore.engine.util.extension.interpolate +const val ICON_TYPE_LOCAL = "local" +const val ICON_TYPE_REMOTE = "remote" + @Serializable @Parcelize data class NavigationMenuConfig( @@ -46,7 +47,6 @@ data class ImageConfig( val alpha: Float = 1.0f, val imageType: ImageType = ImageType.SVG, val contentScale: ContentScaleType = ContentScaleType.FIT, - @Contextual var decodedBitmap: Bitmap? = null, ) : Parcelable, java.io.Serializable { fun interpolate(computedValuesMap: Map): ImageConfig { return this.copy( @@ -56,9 +56,6 @@ data class ImageConfig( } } -const val ICON_TYPE_LOCAL = "local" -const val ICON_TYPE_REMOTE = "remote" - enum class ImageType { JPEG, PNG, diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/register/NoResultsConfig.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/register/NoResultsConfig.kt index 29bf73054a6..5492bab9743 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/register/NoResultsConfig.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/register/NoResultsConfig.kt @@ -26,5 +26,6 @@ import org.smartregister.fhircore.engine.configuration.navigation.NavigationMenu data class NoResultsConfig( val title: String = "", val message: String = "", + val textColor: String? = null, val actionButton: NavigationMenuConfig? = null, ) : Parcelable, java.io.Serializable diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/register/RegisterConfiguration.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/register/RegisterConfiguration.kt index 5f89284a1d6..7f6d5c7dddf 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/register/RegisterConfiguration.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/register/RegisterConfiguration.kt @@ -21,6 +21,8 @@ import org.hl7.fhir.r4.model.ResourceType import org.smartregister.fhircore.engine.configuration.ConfigType import org.smartregister.fhircore.engine.configuration.Configuration import org.smartregister.fhircore.engine.configuration.navigation.NavigationMenuConfig +import org.smartregister.fhircore.engine.configuration.workflow.ActionTrigger +import org.smartregister.fhircore.engine.domain.model.ActionConfig import org.smartregister.fhircore.engine.domain.model.FhirResourceConfig import org.smartregister.fhircore.engine.domain.model.RuleConfig import org.smartregister.fhircore.engine.domain.model.TopScreenSectionConfig @@ -47,4 +49,12 @@ data class RegisterConfiguration( val registerFilter: RegisterFilterConfig? = null, val filterDataByRelatedEntityLocation: Boolean = false, val topScreenSection: TopScreenSectionConfig? = null, -) : Configuration() + val onSearchByQrSingleResultActions: List? = null, + val infiniteScroll: Boolean = false, +) : Configuration() { + val onSearchByQrSingleResultValidActions = + onSearchByQrSingleResultActions?.filter { it.trigger == ActionTrigger.ON_SEARCH_SINGLE_RESULT } + + val showSearchByQrCode = + !onSearchByQrSingleResultValidActions.isNullOrEmpty() || searchBar?.searchByQrCode == true +} diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/register/RegisterContentConfig.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/register/RegisterContentConfig.kt index fb3cc52ac5d..43ba2ab4a13 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/register/RegisterContentConfig.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/register/RegisterContentConfig.kt @@ -23,7 +23,10 @@ import org.smartregister.fhircore.engine.domain.model.RuleConfig data class RegisterContentConfig( val separator: String? = null, val display: String? = null, + val placeholderColor: String? = null, val rules: List? = null, val visible: Boolean? = null, val computedRules: List? = null, + val searchByQrCode: Boolean? = null, + val dataFilterFields: List = emptyList(), ) diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/view/ButtonProperties.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/view/ButtonProperties.kt index 8996c628f50..50b073be0ca 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/view/ButtonProperties.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/view/ButtonProperties.kt @@ -43,6 +43,7 @@ data class ButtonProperties( override val fillMaxHeight: Boolean = false, override val clickable: String = "false", override val visible: String = "true", + override val opacity: Float? = null, val contentColor: String? = null, val enabled: String = "true", val text: String? = null, @@ -91,7 +92,9 @@ data class ButtonProperties( val interpolated = this.status.interpolate(computedValuesMap) return if (ServiceStatus.values().map { it.name }.contains(interpolated)) { ServiceStatus.valueOf(interpolated) - } else ServiceStatus.UPCOMING + } else { + ServiceStatus.UPCOMING + } } } diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/view/CardViewProperties.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/view/CardViewProperties.kt index 77a1d415819..dad41a6b3ab 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/view/CardViewProperties.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/view/CardViewProperties.kt @@ -35,6 +35,7 @@ data class CardViewProperties( override val fillMaxHeight: Boolean = false, override val clickable: String = "true", override val visible: String = "true", + override val opacity: Float? = null, val content: List = emptyList(), val elevation: Int = 5, val cornerSize: Int = 6, diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/view/ColumnProperties.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/view/ColumnProperties.kt index 6053c584591..d19380d0d48 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/view/ColumnProperties.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/view/ColumnProperties.kt @@ -37,6 +37,7 @@ data class ColumnProperties( override val fillMaxHeight: Boolean = false, override val clickable: String = "false", override val visible: String = "true", + override val opacity: Float? = null, val spacedBy: Int = 8, val wrapContent: Boolean = false, val arrangement: ColumnArrangement? = null, diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/view/CompoundTextProperties.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/view/CompoundTextProperties.kt index 91f5e043437..928a67a9181 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/view/CompoundTextProperties.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/view/CompoundTextProperties.kt @@ -35,6 +35,7 @@ data class CompoundTextProperties( override val alignment: ViewAlignment = ViewAlignment.NONE, override val fillMaxWidth: Boolean = false, override val fillMaxHeight: Boolean = false, + override val opacity: Float? = null, override val clickable: String = "false", override val visible: String = "true", val primaryText: String? = null, @@ -54,6 +55,7 @@ data class CompoundTextProperties( val textCase: TextCase? = null, val overflow: TextOverFlow? = null, val letterSpacing: Int = 0, + val textInnerPadding: Int = 0, ) : ViewProperties(), Parcelable { override fun interpolate(computedValuesMap: Map): CompoundTextProperties { return this.copy( diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/view/DividerProperties.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/view/DividerProperties.kt index 7c7e7b4529c..34500c89294 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/view/DividerProperties.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/view/DividerProperties.kt @@ -35,6 +35,7 @@ data class DividerProperties( override val fillMaxHeight: Boolean = false, override val clickable: String = "false", override val visible: String = "true", + override val opacity: Float? = null, val thickness: Float = 0.5f, ) : ViewProperties(), Parcelable { override fun interpolate(computedValuesMap: Map): DividerProperties { diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/view/ImageProperties.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/view/ImageProperties.kt index 76598f4461e..34e347cc4dc 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/view/ImageProperties.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/view/ImageProperties.kt @@ -40,10 +40,11 @@ data class ImageProperties( override val fillMaxHeight: Boolean = false, override val clickable: String = "false", override val visible: String = "true", + override val opacity: Float? = null, val tint: String? = null, val text: String? = null, val imageConfig: ImageConfig? = null, - val size: Int? = null, + val size: Int? = 22, val shape: ImageShape? = null, val textColor: String? = null, val actions: List = emptyList(), diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/view/ListProperties.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/view/ListProperties.kt index 92341c9c5a7..eabdb9bfc46 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/view/ListProperties.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/view/ListProperties.kt @@ -39,12 +39,15 @@ data class ListProperties( override val fillMaxHeight: Boolean = false, override val clickable: String = "false", override val visible: String = "true", + override val opacity: Float? = null, val id: String = "listId", val registerCard: RegisterCardConfig, val showDivider: Boolean = true, val emptyList: NoResultsConfig? = null, val orientation: ListOrientation = ListOrientation.VERTICAL, - val resources: List = emptyList(), + val resources: List = emptyList(), + val spacerHeight: Int = 6, + val enableTopBottomSpacing: Boolean = true, ) : ViewProperties(), Parcelable { override fun interpolate(computedValuesMap: Map): ListProperties { return this.copy( @@ -61,12 +64,13 @@ enum class ListOrientation { @Serializable @Parcelize -data class ListResource( +data class ListResourceConfig( val id: String? = null, val relatedResourceId: String? = null, val resourceType: ResourceType, val conditionalFhirPathExpression: String? = null, val sortConfig: SortConfig? = null, val fhirPathExpression: String? = null, - val relatedResources: List = emptyList(), + val relatedResources: List = emptyList(), + val isRevInclude: Boolean = true, ) : Parcelable, java.io.Serializable diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/view/PersonalDataProperties.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/view/PersonalDataProperties.kt index be035e0b53a..58283b6ecf5 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/view/PersonalDataProperties.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/view/PersonalDataProperties.kt @@ -35,6 +35,7 @@ data class PersonalDataProperties( override val fillMaxHeight: Boolean = false, override val clickable: String = "false", override val visible: String = "true", + override val opacity: Float? = null, val personalDataItems: List = emptyList(), ) : ViewProperties(), Parcelable { override fun interpolate(computedValuesMap: Map): PersonalDataProperties { diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/view/RowProperties.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/view/RowProperties.kt index 59e4becccc3..29f8a26ec13 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/view/RowProperties.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/view/RowProperties.kt @@ -37,6 +37,7 @@ data class RowProperties( override val fillMaxHeight: Boolean = false, override val clickable: String = "false", override val visible: String = "true", + override val opacity: Float? = null, val spacedBy: Int = 8, val arrangement: RowArrangement? = null, val wrapContent: Boolean = false, diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/view/ServiceCardProperties.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/view/ServiceCardProperties.kt index e91f774a044..e7bad1877a1 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/view/ServiceCardProperties.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/view/ServiceCardProperties.kt @@ -36,9 +36,11 @@ data class ServiceCardProperties( override val fillMaxHeight: Boolean = false, override val clickable: String = "true", override val visible: String = "true", + override val opacity: Float? = null, val details: List = emptyList(), val showVerticalDivider: Boolean = false, val serviceMemberIcons: String? = null, + val serviceMemberIconsTint: String? = null, val serviceButton: ButtonProperties? = null, val services: List? = null, val actions: List = emptyList(), @@ -46,6 +48,7 @@ data class ServiceCardProperties( override fun interpolate(computedValuesMap: Map): ServiceCardProperties { return this.copy( backgroundColor = backgroundColor?.interpolate(computedValuesMap), + serviceMemberIconsTint = serviceMemberIconsTint?.interpolate(computedValuesMap), visible = visible.interpolate(computedValuesMap), serviceMemberIcons = serviceMemberIcons?.interpolate(computedValuesMap), clickable = clickable.interpolate(computedValuesMap), diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/view/SpacerProperties.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/view/SpacerProperties.kt index 46de6500d9a..a61c52ed802 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/view/SpacerProperties.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/view/SpacerProperties.kt @@ -33,6 +33,7 @@ data class SpacerProperties( override val alignment: ViewAlignment = ViewAlignment.NONE, override val fillMaxWidth: Boolean = false, override val fillMaxHeight: Boolean = false, + override val opacity: Float? = null, override val clickable: String = "false", override val visible: String = "true", val height: Float? = null, diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/view/StackViewProperties.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/view/StackViewProperties.kt index 65fff51f32b..0b138974b23 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/view/StackViewProperties.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/view/StackViewProperties.kt @@ -27,7 +27,7 @@ import org.smartregister.fhircore.engine.util.extension.interpolate data class StackViewProperties( override val viewType: ViewType = ViewType.STACK, override val weight: Float = 0f, - override val backgroundColor: String? = "#FFFFFF", + override val backgroundColor: String? = null, override val padding: Int = 0, override val borderRadius: Int = 0, override val alignment: ViewAlignment = ViewAlignment.NONE, @@ -35,8 +35,8 @@ data class StackViewProperties( override val fillMaxHeight: Boolean = false, override val clickable: String = "false", override val visible: String = "true", - val opacity: Float = 0f, - val size: Int? = 0, + override val opacity: Float? = null, + val size: Int = 0, val children: List = emptyList(), ) : ViewProperties(), Parcelable { override fun interpolate(computedValuesMap: Map): StackViewProperties { diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/view/ViewProperties.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/view/ViewProperties.kt index 13cc3467474..b9028dd1ecc 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/view/ViewProperties.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/view/ViewProperties.kt @@ -16,7 +16,6 @@ package org.smartregister.fhircore.engine.configuration.view -import java.util.LinkedList import kotlinx.serialization.Serializable import org.smartregister.fhircore.engine.domain.model.ViewType @@ -37,6 +36,7 @@ abstract class ViewProperties : java.io.Serializable { abstract val fillMaxHeight: Boolean abstract val clickable: String abstract val visible: String + abstract val opacity: Float? abstract fun interpolate(computedValuesMap: Map): ViewProperties } @@ -47,18 +47,17 @@ abstract class ViewProperties : java.io.Serializable { */ fun List.retrieveListProperties(): List { val listProperties = mutableListOf() - val viewPropertiesLinkedList: LinkedList = LinkedList(this) - while (viewPropertiesLinkedList.isNotEmpty()) { - val properties = viewPropertiesLinkedList.removeFirst() + val viewPropertiesQueue: ArrayDeque = ArrayDeque(this) + while (viewPropertiesQueue.isNotEmpty()) { + val properties = viewPropertiesQueue.removeFirst() if (properties.viewType == ViewType.LIST) { listProperties.add(properties as ListProperties) } when (properties.viewType) { - ViewType.COLUMN -> viewPropertiesLinkedList.addAll((properties as ColumnProperties).children) - ViewType.ROW -> viewPropertiesLinkedList.addAll((properties as RowProperties).children) - ViewType.CARD -> viewPropertiesLinkedList.addAll((properties as CardViewProperties).content) - ViewType.LIST -> - viewPropertiesLinkedList.addAll((properties as ListProperties).registerCard.views) + ViewType.COLUMN -> viewPropertiesQueue.addAll((properties as ColumnProperties).children) + ViewType.ROW -> viewPropertiesQueue.addAll((properties as RowProperties).children) + ViewType.CARD -> viewPropertiesQueue.addAll((properties as CardViewProperties).content) + ViewType.LIST -> viewPropertiesQueue.addAll((properties as ListProperties).registerCard.views) else -> {} } } diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/view/ViewPropertiesSerializer.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/view/ViewPropertiesSerializer.kt index 7dec5bc376f..7c3a47ab6fa 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/view/ViewPropertiesSerializer.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/view/ViewPropertiesSerializer.kt @@ -29,12 +29,14 @@ object ViewPropertiesSerializer : JsonContentPolymorphicSerializer(ViewProperties::class) { override fun selectDeserializer( element: JsonElement, - ): DeserializationStrategy { + ): DeserializationStrategy { val jsonObject = element.jsonObject val viewType = jsonObject[VIEW_TYPE]?.jsonPrimitive?.content - require(viewType != null && ViewType.values().contains(ViewType.valueOf(viewType))) { + require( + viewType != null && ViewType.entries.toTypedArray().contains(ViewType.valueOf(viewType)), + ) { """Ensure that supported `viewType` property is included in your register view properties configuration. - Supported types: ${ViewType.values()} + Supported types: ${ViewType.entries.toTypedArray()} Parsed JSON: $jsonObject """ .trimMargin() diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/workflow/ActionTrigger.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/workflow/ActionTrigger.kt index c4e15333ada..2cf6a836772 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/workflow/ActionTrigger.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/workflow/ActionTrigger.kt @@ -28,4 +28,7 @@ enum class ActionTrigger { /** Action that is triggered when a Questionnaire has been submitted */ ON_QUESTIONNAIRE_SUBMISSION, + + /** Action triggered on search returning single result */ + ON_SEARCH_SINGLE_RESULT, } diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/workflow/ApplicationWorkflow.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/workflow/ApplicationWorkflow.kt index 296efdaffc7..ea58e771f56 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/workflow/ApplicationWorkflow.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/workflow/ApplicationWorkflow.kt @@ -59,4 +59,7 @@ enum class ApplicationWorkflow { /** A workflow that launches location selector widget * */ LAUNCH_LOCATION_SELECTOR, + + /** A workflow to launch pdf generation */ + LAUNCH_PDF_GENERATION, } diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/cql/R4FhirModelResolverExt.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/cql/R4FhirModelResolverExt.kt deleted file mode 100644 index e48b2a4dc5a..00000000000 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/cql/R4FhirModelResolverExt.kt +++ /dev/null @@ -1,45 +0,0 @@ -/* - * Copyright 2021-2024 Ona Systems, Inc - * - * 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 org.smartregister.fhircore.engine.cql - -import org.opencds.cqf.cql.engine.fhir.model.R4FhirModelResolver -import timber.log.Timber - -class R4FhirModelResolverExt : R4FhirModelResolver() { - override fun resolveType(typeName: String): Class<*> { - // TODO https://github.com/DBCG/cql_engine/issues/537 - // FHIR has a bug that does not allow processing inner static classes - // i.e. Dosage.DosageDoseAndRateComponent hence creating those on own. - // This is would be represented by Dosage.DoseAndRate in cql and should resolve to above - // mentioned - if (typeName.matches(Regex("\\w+\\.\\w+"))) { - val path = typeName.split(".") - val cls = - this.packageNames.firstNotNullOfOrNull { - kotlin - .runCatching { - // builds package.Dosage$DosageDoseAndRateComponent from Dosage.DoseAndRate - Class.forName("$it.${path[0]}$${path[0]}${path[1]}Component") - } - .onFailure { Timber.e(it.stackTraceToString()) } - .getOrNull() - } - if (cls != null) return cls - } - return super.resolveType(typeName) - } -} diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/data/local/ContentCache.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/data/local/ContentCache.kt new file mode 100644 index 00000000000..ebe30aab941 --- /dev/null +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/data/local/ContentCache.kt @@ -0,0 +1,49 @@ +/* + * Copyright 2021-2024 Ona Systems, Inc + * + * 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 org.smartregister.fhircore.engine.data.local + +import androidx.collection.LruCache +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import org.hl7.fhir.r4.model.Resource +import org.hl7.fhir.r4.model.ResourceType +import org.smartregister.fhircore.engine.util.DispatcherProvider + +@Singleton +class ContentCache @Inject constructor(private val dispatcherProvider: DispatcherProvider) { + private val maxMemory: Int = (Runtime.getRuntime().maxMemory() / 1024).toInt() + private val cacheSize: Int = maxMemory / 8 + private val cache = LruCache(cacheSize) + private val mutex = Mutex() + + suspend fun saveResource(resource: T): T { + val key = "${resource.resourceType.name}/${resource.idPart}" + return withContext(dispatcherProvider.io()) { + mutex.withLock { cache.put(key, resource.copy()) } + @Suppress("UNCHECKED_CAST") + getResource(resource.resourceType, resource.idPart)!! as T + } + } + + fun getResource(type: ResourceType, id: String) = cache["$type/$id"]?.copy() + + suspend fun invalidate() = + withContext(dispatcherProvider.io()) { mutex.withLock { cache.evictAll() } } +} diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/data/local/DefaultRepository.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/data/local/DefaultRepository.kt index d22cc02127b..63c0e4fc664 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/data/local/DefaultRepository.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/data/local/DefaultRepository.kt @@ -18,7 +18,6 @@ package org.smartregister.fhircore.engine.data.local import android.content.Context import androidx.annotation.VisibleForTesting -import androidx.compose.ui.state.ToggleableState import ca.uhn.fhir.context.FhirContext import ca.uhn.fhir.parser.IParser import ca.uhn.fhir.rest.gclient.DateClientParam @@ -27,9 +26,11 @@ import ca.uhn.fhir.rest.gclient.ReferenceClientParam import ca.uhn.fhir.rest.gclient.StringClientParam import ca.uhn.fhir.rest.gclient.TokenClientParam import com.google.android.fhir.FhirEngine +import com.google.android.fhir.SearchResult import com.google.android.fhir.datacapture.extensions.logicalId import com.google.android.fhir.db.ResourceNotFoundException import com.google.android.fhir.get +import com.google.android.fhir.getResourceType import com.google.android.fhir.search.Order import com.google.android.fhir.search.Search import com.google.android.fhir.search.filter.ReferenceParamFilterCriterion @@ -37,7 +38,6 @@ import com.google.android.fhir.search.filter.TokenParamFilterCriterion import com.google.android.fhir.search.has import com.google.android.fhir.search.include import com.google.android.fhir.search.revInclude -import com.google.android.fhir.search.search import com.jayway.jsonpath.Configuration import com.jayway.jsonpath.JsonPath import com.jayway.jsonpath.Option @@ -46,8 +46,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext import java.util.LinkedList import java.util.UUID import javax.inject.Inject -import kotlinx.coroutines.flow.firstOrNull -import kotlinx.coroutines.runBlocking +import kotlin.math.min import kotlinx.coroutines.withContext import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonElement @@ -58,7 +57,6 @@ import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive import org.hl7.fhir.instance.model.api.IBaseResource -import org.hl7.fhir.r4.model.Coding import org.hl7.fhir.r4.model.Enumerations import org.hl7.fhir.r4.model.Group import org.hl7.fhir.r4.model.IdType @@ -75,10 +73,10 @@ import org.smartregister.fhircore.engine.configuration.app.ConfigService import org.smartregister.fhircore.engine.configuration.event.EventWorkflow import org.smartregister.fhircore.engine.configuration.profile.ManagingEntityConfig import org.smartregister.fhircore.engine.configuration.register.ActiveResourceFilterConfig -import org.smartregister.fhircore.engine.datastore.syncLocationIdsProtoStore import org.smartregister.fhircore.engine.domain.model.Code import org.smartregister.fhircore.engine.domain.model.DataQuery import org.smartregister.fhircore.engine.domain.model.FhirResourceConfig +import org.smartregister.fhircore.engine.domain.model.MultiSelectViewAction import org.smartregister.fhircore.engine.domain.model.RelatedResourceCount import org.smartregister.fhircore.engine.domain.model.RepositoryResourceData import org.smartregister.fhircore.engine.domain.model.ResourceConfig @@ -89,6 +87,7 @@ import org.smartregister.fhircore.engine.rulesengine.ConfigRulesExecutor import org.smartregister.fhircore.engine.util.DispatcherProvider import org.smartregister.fhircore.engine.util.SharedPreferencesHelper import org.smartregister.fhircore.engine.util.extension.asReference +import org.smartregister.fhircore.engine.util.extension.batchedSearch import org.smartregister.fhircore.engine.util.extension.encodeResourceToString import org.smartregister.fhircore.engine.util.extension.extractId import org.smartregister.fhircore.engine.util.extension.extractLogicalIdUuid @@ -96,9 +95,11 @@ import org.smartregister.fhircore.engine.util.extension.filterBy import org.smartregister.fhircore.engine.util.extension.filterByResourceTypeId import org.smartregister.fhircore.engine.util.extension.generateMissingId import org.smartregister.fhircore.engine.util.extension.loadResource +import org.smartregister.fhircore.engine.util.extension.retrieveRelatedEntitySyncLocationState import org.smartregister.fhircore.engine.util.extension.updateFrom import org.smartregister.fhircore.engine.util.extension.updateLastUpdated import org.smartregister.fhircore.engine.util.fhirpath.FhirPathDataExtractor +import org.smartregister.fhircore.engine.util.pmap import timber.log.Timber open class DefaultRepository @@ -113,22 +114,30 @@ constructor( open val fhirPathDataExtractor: FhirPathDataExtractor, open val parser: IParser, @ApplicationContext open val context: Context, + open val contentCache: ContentCache, ) { - suspend inline fun loadResource(resourceId: String): T? { - return withContext(dispatcherProvider.io()) { fhirEngine.loadResource(resourceId) } - } + suspend inline fun loadResource(resourceId: String): T? = + fhirEngine.loadResource(resourceId) + @Throws(ResourceNotFoundException::class) suspend fun loadResource(resourceId: String, resourceType: ResourceType): Resource = - withContext(dispatcherProvider.io()) { fhirEngine.get(resourceType, resourceId) } + fhirEngine.get(resourceType, resourceId) + @Throws(ResourceNotFoundException::class) suspend fun loadResource(reference: Reference) = - withContext(dispatcherProvider.io()) { - IdType(reference.reference).let { - fhirEngine.get(ResourceType.fromCode(it.resourceType), it.idPart) - } + IdType(reference.reference).let { + fhirEngine.get(ResourceType.fromCode(it.resourceType), it.idPart) } + suspend inline fun loadResourceFromCache(resourceId: String): T? { + val resourceType = getResourceType(T::class.java) + val resource = + contentCache.getResource(resourceType, resourceId) + ?: fhirEngine.loadResource(resourceId)?.let { contentCache.saveResource(it) } + return resource as? T + } + suspend inline fun searchResourceFor( token: TokenClientParam, subjectType: ResourceType, @@ -136,22 +145,20 @@ constructor( dataQueries: List = listOf(), configComputedRuleValues: Map, ): List = - withContext(dispatcherProvider.io()) { - fhirEngine - .search { - filterByResourceTypeId(token, subjectType, subjectId) - dataQueries.forEach { - filterBy( - dataQuery = it, - configComputedRuleValues = configComputedRuleValues, - ) - } + fhirEngine + .batchedSearch { + filterByResourceTypeId(token, subjectType, subjectId) + dataQueries.forEach { + filterBy( + dataQuery = it, + configComputedRuleValues = configComputedRuleValues, + ) } - .map { it.resource } - } + } + .map { it.resource } suspend inline fun search(search: Search) = - fhirEngine.search(search).map { it.resource } + fhirEngine.batchedSearch(search).map { it.resource } suspend inline fun count(search: Search) = fhirEngine.count(search) @@ -163,17 +170,13 @@ constructor( * param [addResourceTags] */ suspend fun create(addResourceTags: Boolean = true, vararg resource: Resource): List { - return withContext(dispatcherProvider.io()) { - preProcessResources(addResourceTags, *resource) - fhirEngine.create(*resource) - } + preProcessResources(addResourceTags, *resource) + return fhirEngine.create(*resource) } suspend fun createRemote(addResourceTags: Boolean = true, vararg resource: Resource) { - return withContext(dispatcherProvider.io()) { - preProcessResources(addResourceTags, *resource) - fhirEngine.create(*resource, isLocalOnly = true) - } + preProcessResources(addResourceTags, *resource) + fhirEngine.create(*resource, isLocalOnly = true) } private fun preProcessResources(addResourceTags: Boolean, vararg resource: Resource) { @@ -199,23 +202,19 @@ constructor( resourceId: String, softDelete: Boolean = false, ) { - withContext(dispatcherProvider.io()) { - if (softDelete) { - val resource = fhirEngine.get(resourceType, resourceId) - softDelete(resource) - } else { - fhirEngine.delete(resourceType, resourceId) - } + if (softDelete) { + val resource = fhirEngine.get(resourceType, resourceId) + softDelete(resource) + } else { + fhirEngine.delete(resourceType, resourceId) } } suspend fun delete(resource: Resource, softDelete: Boolean = false) { - withContext(dispatcherProvider.io()) { - if (softDelete) { - softDelete(resource) - } else { - fhirEngine.delete(resource.resourceType, resource.logicalId) - } + if (softDelete) { + softDelete(resource) + } else { + fhirEngine.delete(resource.resourceType, resource.logicalId) } } @@ -244,37 +243,37 @@ constructor( * param [addMandatoryTags] */ suspend fun addOrUpdate(addMandatoryTags: Boolean = true, resource: R) { - return withContext(dispatcherProvider.io()) { - resource.updateLastUpdated() - try { - fhirEngine.get(resource.resourceType, resource.logicalId).run { - val updateFrom = updateFrom(resource) - fhirEngine.update(updateFrom) - } - } catch (resourceNotFoundException: ResourceNotFoundException) { - create(addMandatoryTags, resource) + resource.updateLastUpdated() + try { + fhirEngine.get(resource.resourceType, resource.logicalId).run { + val updateFrom = updateFrom(resource) + fhirEngine.update(updateFrom) } + } catch (resourceNotFoundException: ResourceNotFoundException) { + create(addMandatoryTags, resource) } } suspend fun update(resource: R) { - return withContext(dispatcherProvider.io()) { - resource.updateLastUpdated() - fhirEngine.update(resource) - } + resource.updateLastUpdated() + fhirEngine.update(resource) + } + + suspend fun applyDbTransaction(block: suspend () -> Unit) { + fhirEngine.withTransaction { block.invoke() } } suspend fun loadManagingEntity(group: Group) = group.managingEntity?.let { reference -> fhirEngine - .search { + .batchedSearch { filter(RelatedPerson.RES_ID, { value = of(reference.extractId()) }) } .map { it.resource } .firstOrNull() ?.let { relatedPerson -> fhirEngine - .search { + .batchedSearch { filter( Patient.RES_ID, { value = of(relatedPerson.patient.extractId()) }, @@ -476,94 +475,116 @@ constructor( } protected suspend fun retrieveRelatedResources( - resources: List, + resource: Resource, relatedResourcesConfigs: List?, - relatedResourceWrapper: RelatedResourceWrapper, configComputedRuleValues: Map, ): RelatedResourceWrapper { - val countResourceConfigs = relatedResourcesConfigs?.filter { it.resultAsCount } - countResourceConfigs?.forEach { resourceConfig -> - if (resourceConfig.searchParameter.isNullOrEmpty()) { - Timber.e("Search parameter require to perform count query. Current config: $resourceConfig") + val relatedResourceWrapper = RelatedResourceWrapper() + val relatedResourcesQueue = + ArrayDeque, List?>>().apply { + addFirst(Pair(listOf(resource), relatedResourcesConfigs)) } + while (relatedResourcesQueue.isNotEmpty()) { + val (currentResources, currentRelatedResourceConfigs) = relatedResourcesQueue.removeFirst() + val relatedResourceCountConfigs = + currentRelatedResourceConfigs + ?.asSequence() + ?.filter { it.resultAsCount && !it.searchParameter.isNullOrEmpty() } + ?.toList() + + relatedResourceCountConfigs?.forEach { resourceConfig -> + val search = + Search(resourceConfig.resource).apply { + val filters = + currentResources.map { + val apply: ReferenceParamFilterCriterion.() -> Unit = { + value = it.logicalId.asReference(it.resourceType).reference + } + apply + } + filter( + ReferenceClientParam(resourceConfig.searchParameter), + *filters.toTypedArray(), + ) + applyConfiguredSortAndFilters( + resourceConfig = resourceConfig, + sortData = true, + configComputedRuleValues = configComputedRuleValues, + ) + } - // Count for each related resource or aggregate total count in one query; as configured - if (resourceConfig.resultAsCount && !resourceConfig.searchParameter.isNullOrEmpty()) { + val key = resourceConfig.id ?: resourceConfig.resource.name if (resourceConfig.countResultConfig?.sumCounts == true) { - val search = - Search(resourceConfig.resource).apply { - val filters = - resources.map { - val apply: ReferenceParamFilterCriterion.() -> Unit = { - value = it.logicalId.asReference(it.resourceType).reference - } - apply - } - filter( - ReferenceClientParam(resourceConfig.searchParameter), - *filters.toTypedArray(), - ) - applyConfiguredSortAndFilters( - resourceConfig = resourceConfig, - sortData = true, - configComputedRuleValues = configComputedRuleValues, - ) - } - val key = resourceConfig.id ?: resourceConfig.resource.name search.count( onSuccess = { - relatedResourceWrapper.relatedResourceCountMap[key] = - LinkedList().apply { - add( - RelatedResourceCount( - count = it, - ), - ) - } + relatedResourceWrapper.relatedResourceCountMap + .getOrPut(key) { mutableListOf() } + .apply { add(RelatedResourceCount(count = it)) } }, onFailure = { Timber.e( it, - "Error retrieving total count for all related resourced identified by $key", + "Error retrieving total count for all related resources identified by $key", ) }, ) } else { computeCountForEachRelatedResource( - resources = resources, + resources = currentResources, resourceConfig = resourceConfig, relatedResourceWrapper = relatedResourceWrapper, configComputedRuleValues = configComputedRuleValues, ) } } - } - searchIncludedResources( - relatedResourcesConfigs = relatedResourcesConfigs, - resources = resources, - relatedResourceWrapper = relatedResourceWrapper, - configComputedRuleValues = configComputedRuleValues, - ) + val searchResults = + searchIncludedResources( + relatedResourcesConfigs = currentRelatedResourceConfigs, + resources = currentResources, + configComputedRuleValues = configComputedRuleValues, + ) + val fwdIncludedRelatedConfigsMap = + currentRelatedResourceConfigs + ?.revIncludeRelatedResourceConfigs(false) + ?.groupBy { it.searchParameter!! } + ?.mapValues { it.value.first() } + + val revIncludedRelatedConfigsMap = + currentRelatedResourceConfigs + ?.revIncludeRelatedResourceConfigs(true) + ?.groupBy { "${it.resource.name}_${it.searchParameter}".lowercase() } + ?.mapValues { it.value.first() } + + searchResults.forEach { searchResult -> + searchResult.included?.forEach { entry -> + updateResourceWrapperAndQueue( + key = entry.key, + defaultKey = entry.value.firstOrNull()?.resourceType?.name, + resources = entry.value, + relatedResourcesConfigsMap = fwdIncludedRelatedConfigsMap, + relatedResourceWrapper = relatedResourceWrapper, + relatedResourcesQueue = relatedResourcesQueue, + ) + } + searchResult.revIncluded?.forEach { entry -> + val (resourceType, searchParam) = entry.key + val key = "${resourceType.name}_$searchParam".lowercase() + updateResourceWrapperAndQueue( + key = key, + defaultKey = entry.value.firstOrNull()?.resourceType?.name, + resources = entry.value, + relatedResourcesConfigsMap = revIncludedRelatedConfigsMap, + relatedResourceWrapper = relatedResourceWrapper, + relatedResourcesQueue = relatedResourcesQueue, + ) + } + } + } return relatedResourceWrapper } - protected suspend fun Search.count( - onSuccess: (Long) -> Unit = {}, - onFailure: (Throwable) -> Unit = { throwable -> - Timber.e( - throwable, - "Error counting data", - ) - }, - ): Long = - kotlin - .runCatching { withContext(dispatcherProvider.io()) { fhirEngine.count(this@count) } } - .onSuccess { count -> onSuccess(count) } - .onFailure { throwable -> onFailure(throwable) } - .getOrDefault(0) - private suspend fun computeCountForEachRelatedResource( resources: List, resourceConfig: ResourceConfig, @@ -612,6 +633,46 @@ constructor( relatedResourceWrapper.relatedResourceCountMap[key] = relatedResourceCountLinkedList } + private fun updateResourceWrapperAndQueue( + key: String, + defaultKey: String?, + resources: List, + relatedResourcesConfigsMap: Map?, + relatedResourceWrapper: RelatedResourceWrapper, + relatedResourcesQueue: ArrayDeque, List?>>, + ) { + val resourceConfigs = relatedResourcesConfigsMap?.get(key) + val id = resourceConfigs?.id ?: defaultKey + if (!id.isNullOrBlank()) { + relatedResourceWrapper.relatedResourceMap[id] = + relatedResourceWrapper.relatedResourceMap + .getOrPut(id) { mutableListOf() } + .apply { addAll(resources.distinctBy { it.logicalId }) } + resources.chunked(DEFAULT_BATCH_SIZE) { item -> + with(resourceConfigs?.relatedResources) { + if (!this.isNullOrEmpty()) { + relatedResourcesQueue.addLast(Pair(item, this)) + } + } + } + } + } + + protected suspend fun Search.count( + onSuccess: (Long) -> Unit = {}, + onFailure: (Throwable) -> Unit = { throwable -> + Timber.e( + throwable, + "Error counting data", + ) + }, + ): Long = + kotlin + .runCatching { fhirEngine.count(this@count) } + .onSuccess { count -> onSuccess(count) } + .onFailure { throwable -> onFailure(throwable) } + .getOrDefault(0) + /** * This function searches for reverse/forward included resources as per the configuration; * [RelatedResourceWrapper] data class is then used to wrap the maps used to store Search Query @@ -620,132 +681,57 @@ constructor( private suspend fun searchIncludedResources( relatedResourcesConfigs: List?, resources: List, - relatedResourceWrapper: RelatedResourceWrapper, configComputedRuleValues: Map, - ) { - val relatedResourcesConfigsMap = relatedResourcesConfigs?.groupBy { it.resource } - - if (!relatedResourcesConfigsMap.isNullOrEmpty()) { - if (resources.isEmpty()) return - - val firstResourceType = resources.first().resourceType - val search = - Search(firstResourceType).apply { - val filters = - resources.map { - val apply: TokenParamFilterCriterion.() -> Unit = { value = of(it.logicalId) } - apply - } - filter(Resource.RES_ID, *filters.toTypedArray()) - } - - // Forward include related resources e.g. Members (Patient) referenced in Group resource - val forwardIncludeResourceConfigs = - relatedResourcesConfigs.revIncludeRelatedResourceConfigs(false) - - // Reverse include related resources e.g. All CarePlans, Immunization for Patient resource - val reverseIncludeResourceConfigs = - relatedResourcesConfigs.revIncludeRelatedResourceConfigs(true) - - search.apply { - reverseIncludeResourceConfigs.forEach { resourceConfig -> - revInclude( - resourceConfig.resource, - ReferenceClientParam(resourceConfig.searchParameter), - ) { - (this as Search).applyConfiguredSortAndFilters( - resourceConfig = resourceConfig, - sortData = true, - configComputedRuleValues = configComputedRuleValues, - ) - } - } - - forwardIncludeResourceConfigs.forEach { resourceConfig -> - include( - resourceConfig.resource, - ReferenceClientParam(resourceConfig.searchParameter), - ) { - (this as Search).applyConfiguredSortAndFilters( - resourceConfig = resourceConfig, - sortData = true, - configComputedRuleValues = configComputedRuleValues, - ) + ): List> { + val search = + Search(resources.first().resourceType).apply { + val filters = + resources.map { + val apply: TokenParamFilterCriterion.() -> Unit = { value = of(it.logicalId) } + apply } - } + filter(Resource.RES_ID, *filters.toTypedArray()) } - searchRelatedResources( - search = search, - relatedResourcesConfigsMap = relatedResourcesConfigsMap, - relatedResourceWrapper = relatedResourceWrapper, - configComputedRuleValues = configComputedRuleValues, - ) - } - } + // Forward include related resources e.g. a member or managingEntity of a Group resource + val forwardIncludeResourceConfigs = + relatedResourcesConfigs?.revIncludeRelatedResourceConfigs(false) - private suspend fun searchRelatedResources( - search: Search, - relatedResourcesConfigsMap: Map>, - relatedResourceWrapper: RelatedResourceWrapper, - configComputedRuleValues: Map, - ) { - kotlin - .runCatching { fhirEngine.search(search) } - .onSuccess { searchResult -> - searchResult.forEach { currentSearchResult -> - val includedResources: Map>? = - currentSearchResult.included - ?.values - ?.flatten() - ?.distinctBy { it.id } - ?.groupBy { it.resourceType } - val reverseIncludedResources: Map>? = - currentSearchResult.revIncluded - ?.values - ?.flatten() - ?.distinctBy { it.id } - ?.groupBy { it.resourceType } - val theRelatedResourcesMap = - mutableMapOf>().apply { - includedResources?.let { putAll(it) } - reverseIncludedResources?.let { putAll(it) } - } - theRelatedResourcesMap.forEach { entry -> - val currentResourceConfigs = relatedResourcesConfigsMap[entry.key] - - val key = // Use configured id as key otherwise default to ResourceType - if (relatedResourcesConfigsMap.containsKey(entry.key)) { - currentResourceConfigs?.firstOrNull()?.id ?: entry.key.name - } else { - entry.key.name - } + // Reverse include related resources e.g. all CarePlans, Immunizations for Patient resource + val reverseIncludeResourceConfigs = + relatedResourcesConfigs?.revIncludeRelatedResourceConfigs(true) - // All nested resources flattened to one map by adding to existing list - relatedResourceWrapper.relatedResourceMap[key] = - relatedResourceWrapper.relatedResourceMap - .getOrPut(key) { LinkedList() } - .plus(entry.value) - - currentResourceConfigs?.forEach { resourceConfig -> - if (resourceConfig.relatedResources.isNotEmpty()) { - retrieveRelatedResources( - resources = entry.value, - relatedResourcesConfigs = resourceConfig.relatedResources, - relatedResourceWrapper = relatedResourceWrapper, - configComputedRuleValues = configComputedRuleValues, - ) - } - } - } + search.apply { + reverseIncludeResourceConfigs?.forEach { resourceConfig -> + revInclude( + resourceConfig.resource, + ReferenceClientParam(resourceConfig.searchParameter), + ) { + (this as Search).applyConfiguredSortAndFilters( + resourceConfig = resourceConfig, + sortData = true, + configComputedRuleValues = configComputedRuleValues, + ) } } - .onFailure { - Timber.e( - it, - "Error fetching configured related resources: $relatedResourcesConfigsMap", - ) + + forwardIncludeResourceConfigs?.forEach { resourceConfig -> + include( + resourceConfig.resource, + ReferenceClientParam(resourceConfig.searchParameter), + ) { + (this as Search).applyConfiguredSortAndFilters( + resourceConfig = resourceConfig, + sortData = true, + configComputedRuleValues = configComputedRuleValues, + ) + } } + } + return kotlin + .runCatching { fhirEngine.batchedSearch(search) } + .onFailure { Timber.e(it, "Error fetching related resources") } + .getOrDefault(emptyList()) } private fun List.revIncludeRelatedResourceConfigs(isRevInclude: Boolean) = @@ -764,66 +750,65 @@ constructor( */ suspend fun updateResourcesRecursively( resourceConfig: ResourceConfig, - subject: Resource, + subject: Resource? = null, eventWorkflow: EventWorkflow, ) { - val configRules = configRulesExecutor.generateRules(resourceConfig.configRules ?: listOf()) - val computedValuesMap = - configRulesExecutor.fireRules(rules = configRules, baseResource = subject).mapValues { entry, - -> - val initialValue = entry.value.toString() - if (initialValue.contains('/')) { - """${initialValue.substringBefore("/")}/${initialValue.extractLogicalIdUuid()}""" - } else { - initialValue + withContext(dispatcherProvider.io()) { + val configRules = configRulesExecutor.generateRules(resourceConfig.configRules ?: listOf()) + val computedValuesMap = + configRulesExecutor.fireRules(rules = configRules, baseResource = subject).mapValues { + entry, + -> + val initialValue = entry.value.toString() + if (initialValue.contains('/')) { + """${initialValue.substringBefore("/")}/${initialValue.extractLogicalIdUuid()}""" + } else { + initialValue + } } - } - - Timber.i("Computed values map = ${computedValuesMap.values}") - val search = - Search(resourceConfig.resource).apply { - applyConfiguredSortAndFilters( - resourceConfig = resourceConfig, - sortData = false, - filterActiveResources = null, - configComputedRuleValues = computedValuesMap, - ) - } - val resources = fhirEngine.search(search).map { it.resource } - val filteredResources = - filterResourcesByFhirPathExpression( - resourceFilterExpressions = eventWorkflow.resourceFilterExpressions, - resources = resources, - ) - filteredResources.forEach { - Timber.i("Closing Resource type ${it.resourceType.name} and id ${it.id}") - closeResource(resource = it, eventWorkflow = eventWorkflow) - } - // recursive related resources - val retrievedRelatedResources = - withContext(dispatcherProvider.io()) { - retrieveRelatedResources( + val search = + Search(resourceConfig.resource).apply { + applyConfiguredSortAndFilters( + resourceConfig = resourceConfig, + sortData = false, + filterActiveResources = null, + configComputedRuleValues = computedValuesMap, + ) + } + val resources = fhirEngine.batchedSearch(search).map { it.resource } + val filteredResources = + filterResourcesByFhirPathExpression( + resourceFilterExpressions = eventWorkflow.resourceFilterExpressions, resources = resources, - relatedResourcesConfigs = resourceConfig.relatedResources, - relatedResourceWrapper = RelatedResourceWrapper(), - configComputedRuleValues = emptyMap(), ) + filteredResources.forEach { + Timber.i("Closing Resource type ${it.resourceType.name} and id ${it.id}") + closeResource(resource = it, eventWorkflow = eventWorkflow) } - retrievedRelatedResources.relatedResourceMap.forEach { resourcesMap -> - val filteredRelatedResources = - filterResourcesByFhirPathExpression( - resourceFilterExpressions = eventWorkflow.resourceFilterExpressions, - resources = resourcesMap.value, - ) + resources.forEach { resource -> + val retrievedRelatedResources = + retrieveRelatedResources( + resource = resource, + relatedResourcesConfigs = resourceConfig.relatedResources, + configComputedRuleValues = computedValuesMap, + ) + retrievedRelatedResources.relatedResourceMap.forEach { resourcesMap -> + val filteredRelatedResources = + filterResourcesByFhirPathExpression( + resourceFilterExpressions = eventWorkflow.resourceFilterExpressions, + resources = resourcesMap.value, + ) - filteredRelatedResources.forEach { resource -> - Timber.i( - "Closing related Resource type ${resource.resourceType.name} and id ${resource.id}", - ) - if (filterRelatedResource(resource, resourceConfig)) { - closeResource(resource = resource, eventWorkflow = eventWorkflow) + filteredRelatedResources.forEach { resource -> + Timber.i( + "Closing related Resource type ${resource.resourceType.name} and id ${resource.id}", + ) + if (filterRelatedResource(resource, resourceConfig)) { + closeResource(resource = resource, eventWorkflow = eventWorkflow) + } + } } } } @@ -919,7 +904,7 @@ constructor( val updatedResource = parser.parseResource(resourceDefinition, updatedResourceDocument.jsonString()) updatedResource.setId(updatedResource.idElement.idPart) - withContext(dispatcherProvider.io()) { fhirEngine.update(updatedResource as Resource) } + fhirEngine.update(updatedResource as Resource) } private fun getJsonContent(jsonElement: JsonElement): Any? { @@ -950,9 +935,7 @@ constructor( suspend fun purge(resource: Resource, forcePurge: Boolean) { try { - withContext(dispatcherProvider.io()) { - fhirEngine.purge(resource.resourceType, resource.logicalId, forcePurge) - } + fhirEngine.purge(resource.resourceType, resource.logicalId, forcePurge) } catch (resourceNotFoundException: ResourceNotFoundException) { Timber.e( "Purge failed -> Resource with ID ${resource.logicalId} does not exist", @@ -961,6 +944,85 @@ constructor( } } + suspend fun countResources( + filterByRelatedEntityLocation: Boolean, + baseResourceConfig: ResourceConfig, + filterActiveResources: List, + configComputedRuleValues: Map, + ) = + if (filterByRelatedEntityLocation) { + val syncLocationIds = + context.retrieveRelatedEntitySyncLocationState(MultiSelectViewAction.FILTER_DATA).map { + it.locationId + } + + val locationIds = + syncLocationIds + .map { retrieveFlattenedSubLocations(it).map { subLocation -> subLocation.logicalId } } + .asSequence() + .flatten() + .toHashSet() + val countSearch = + Search(baseResourceConfig.resource).apply { + applyConfiguredSortAndFilters( + resourceConfig = baseResourceConfig, + sortData = false, + filterActiveResources = filterActiveResources, + configComputedRuleValues = configComputedRuleValues, + ) + } + val totalCount = fhirEngine.count(countSearch) + var searchResultsCount = 0L + var pageNumber = 0 + var count = 0 + while (count < totalCount) { + val baseResourceSearch = + createSearch( + baseResourceConfig = baseResourceConfig, + filterActiveResources = filterActiveResources, + configComputedRuleValues = configComputedRuleValues, + currentPage = pageNumber, + count = DEFAULT_BATCH_SIZE, + ) + searchResultsCount += + fhirEngine + .search(baseResourceSearch) + .asSequence() + .map { it.resource } + .filter { resource -> + when (resource.resourceType) { + ResourceType.Location -> locationIds.contains(resource.logicalId) + else -> + resource.meta.tag.any { + it.system == + context.getString(R.string.sync_strategy_related_entity_location_system) && + locationIds.contains(it.code) + } + } + } + .count() + .toLong() + count += DEFAULT_BATCH_SIZE + pageNumber++ + } + searchResultsCount + } else { + val search = + Search(baseResourceConfig.resource).apply { + applyConfiguredSortAndFilters( + resourceConfig = baseResourceConfig, + sortData = false, + filterActiveResources = filterActiveResources, + configComputedRuleValues = configComputedRuleValues, + ) + } + search.count( + onFailure = { + Timber.e(it, "Error counting resources ${baseResourceConfig.resource.name}") + }, + ) + } + suspend fun searchResourcesRecursively( filterByRelatedEntityLocationMetaTag: Boolean, filterActiveResources: List?, @@ -970,63 +1032,171 @@ constructor( pageSize: Int? = null, configRules: List?, ): List { - val baseResourceConfig = fhirResourceConfig.baseResource - val relatedResourcesConfig = fhirResourceConfig.relatedResources - val configComputedRuleValues = configRules.configRulesComputedValues() - val search = - Search(type = baseResourceConfig.resource).apply { - applyConfiguredSortAndFilters( - resourceConfig = baseResourceConfig, - filterActiveResources = filterActiveResources, - sortData = true, - configComputedRuleValues = configComputedRuleValues, - ) - applyFilterByRelatedEntityLocationMetaTag( - baseResourceConfig.resource, - filterByRelatedEntityLocationMetaTag, - ) - if (currentPage != null && pageSize != null) { - count = pageSize - from = currentPage * pageSize - } - } - - val baseFhirResources = - kotlin - .runCatching { - withContext(dispatcherProvider.io()) { fhirEngine.search(search) } - } - .onFailure { - Timber.e( - it, - "Error retrieving resources. Empty list returned by default", + return withContext(dispatcherProvider.io()) { + val baseResourceConfig = fhirResourceConfig.baseResource + val relatedResourcesConfig = fhirResourceConfig.relatedResources + val configComputedRuleValues = configRules.configRulesComputedValues() + + if (filterByRelatedEntityLocationMetaTag) { + val syncLocationIds = + context.retrieveRelatedEntitySyncLocationState(MultiSelectViewAction.FILTER_DATA).map { + it.locationId + } + val locationIds = + syncLocationIds + .map { retrieveFlattenedSubLocations(it).map { subLocation -> subLocation.logicalId } } + .flatten() + .toHashSet() + val countSearch = + Search(baseResourceConfig.resource).apply { + applyConfiguredSortAndFilters( + resourceConfig = baseResourceConfig, + sortData = false, + filterActiveResources = filterActiveResources, + configComputedRuleValues = configComputedRuleValues, + ) + } + val totalCount = fhirEngine.count(countSearch) + val searchResults = ArrayDeque>() + var pageNumber = 0 + var count = 0 + while (count < totalCount) { + val baseResourceSearch = + createSearch( + baseResourceConfig = baseResourceConfig, + filterActiveResources = filterActiveResources, + configComputedRuleValues = configComputedRuleValues, + currentPage = pageNumber, + count = DEFAULT_BATCH_SIZE, + ) + val result = fhirEngine.batchedSearch(baseResourceSearch) + searchResults.addAll( + result.filter { searchResult -> + when (baseResourceConfig.resource) { + ResourceType.Location -> locationIds.contains(searchResult.resource.logicalId) + else -> + searchResult.resource.meta.tag.any { + it.system == + context.getString(R.string.sync_strategy_related_entity_location_system) && + locationIds.contains(it.code) + } + } + }, ) + count += DEFAULT_BATCH_SIZE + pageNumber++ + if (currentPage != null && pageSize != null) { + val maxPageCount = (currentPage + 1) * pageSize + if (searchResults.size >= maxPageCount) break + } } - .getOrDefault(emptyList()) - return baseFhirResources.map { searchResult -> - val retrievedRelatedResources = - withContext(dispatcherProvider.io()) { - retrieveRelatedResources( - resources = listOf(searchResult.resource), - relatedResourcesConfigs = relatedResourcesConfig, - relatedResourceWrapper = RelatedResourceWrapper(), + if (currentPage != null && pageSize != null) { + val fromIndex = currentPage * pageSize + val toIndex = (currentPage + 1) * pageSize + val maxSublistIndex = min(toIndex, searchResults.size) + + if (fromIndex < maxSublistIndex) { + with(searchResults.subList(fromIndex, maxSublistIndex)) { + mapResourceToRepositoryResourceData( + relatedResourcesConfig = relatedResourcesConfig, + configComputedRuleValues = configComputedRuleValues, + secondaryResourceConfigs = secondaryResourceConfigs, + filterActiveResources = filterActiveResources, + baseResourceConfig = baseResourceConfig, + ) + } + } else { + emptyList() + } + } else { + searchResults.mapResourceToRepositoryResourceData( + relatedResourcesConfig = relatedResourcesConfig, configComputedRuleValues = configComputedRuleValues, + secondaryResourceConfigs = secondaryResourceConfigs, + filterActiveResources = filterActiveResources, + baseResourceConfig = baseResourceConfig, ) } + } else { + val baseFhirResources: List> = + kotlin + .runCatching { + val search = + createSearch( + baseResourceConfig = baseResourceConfig, + filterActiveResources = filterActiveResources, + configComputedRuleValues = configComputedRuleValues, + currentPage = currentPage, + count = pageSize, + ) + fhirEngine.batchedSearch(search) + } + .onFailure { + Timber.e( + t = it, + message = "Error retrieving resources. Empty list returned by default", + ) + } + .getOrDefault(emptyList()) + baseFhirResources.mapResourceToRepositoryResourceData( + relatedResourcesConfig = relatedResourcesConfig, + configComputedRuleValues = configComputedRuleValues, + secondaryResourceConfigs = secondaryResourceConfigs, + filterActiveResources = filterActiveResources, + baseResourceConfig = baseResourceConfig, + ) + } + as List + } + } + + private suspend fun List>.mapResourceToRepositoryResourceData( + relatedResourcesConfig: List, + configComputedRuleValues: Map, + secondaryResourceConfigs: List?, + filterActiveResources: List?, + baseResourceConfig: ResourceConfig, + ) = + this.pmap { searchResult -> + val retrievedRelatedResources = + retrieveRelatedResources( + resource = searchResult.resource, + relatedResourcesConfigs = relatedResourcesConfig, + configComputedRuleValues = configComputedRuleValues, + ) + val secondaryRepositoryResourceData = + secondaryResourceConfigs.retrieveSecondaryRepositoryResourceData(filterActiveResources) RepositoryResourceData( resourceRulesEngineFactId = baseResourceConfig.id ?: baseResourceConfig.resource.name, resource = searchResult.resource, relatedResourcesMap = retrievedRelatedResources.relatedResourceMap, relatedResourcesCountMap = retrievedRelatedResources.relatedResourceCountMap, - secondaryRepositoryResourceData = - withContext(dispatcherProvider.io()) { - secondaryResourceConfigs.retrieveSecondaryRepositoryResourceData( - filterActiveResources, - ) - }, + secondaryRepositoryResourceData = secondaryRepositoryResourceData, ) } + + protected fun createSearch( + baseResourceConfig: ResourceConfig, + filterActiveResources: List?, + configComputedRuleValues: Map, + currentPage: Int?, + count: Int?, + ): Search { + val search = + Search(type = baseResourceConfig.resource).apply { + applyConfiguredSortAndFilters( + resourceConfig = baseResourceConfig, + filterActiveResources = filterActiveResources, + sortData = true, + configComputedRuleValues = configComputedRuleValues, + ) + if (currentPage != null && count != null) { + this.count = count + from = currentPage * count + } + } + return search } protected fun List?.configRulesComputedValues(): Map { @@ -1038,10 +1208,10 @@ constructor( /** This function fetches other resources that are not linked to the base/primary resource. */ protected suspend fun List?.retrieveSecondaryRepositoryResourceData( filterActiveResources: List?, - ): LinkedList { - val secondaryRepositoryResourceDataLinkedList = LinkedList() + ): List { + val secondaryRepositoryResourceDataList = mutableListOf() this?.forEach { - secondaryRepositoryResourceDataLinkedList.addAll( + secondaryRepositoryResourceDataList.addAll( searchResourcesRecursively( fhirResourceConfig = it, filterActiveResources = filterActiveResources, @@ -1051,60 +1221,18 @@ constructor( ), ) } - return secondaryRepositoryResourceDataLinkedList - } - - suspend fun Search.applyFilterByRelatedEntityLocationMetaTag( - baseResourceType: ResourceType, - filterByRelatedEntityLocation: Boolean, - ) { - runBlocking { - if (filterByRelatedEntityLocation) { - val system = context.getString(R.string.sync_strategy_related_entity_location_system) - val display = context.getString(R.string.sync_strategy_related_entity_location_display) - val locationIds = - context.syncLocationIdsProtoStore.data - .firstOrNull() - ?.filter { it.toggleableState == ToggleableState.On } - ?.map { it.locationId } - .takeIf { !it.isNullOrEmpty() } - val filters = - if (baseResourceType == ResourceType.Location) { // E.g where _id=uuid1,uuid2 - locationIds?.map { - val apply: TokenParamFilterCriterion.() -> Unit = { value = of(it) } - apply - } - } else { - locationIds?.map { code -> // The RelatedEntityLocation is retrieved from meta tag - val apply: TokenParamFilterCriterion.() -> Unit = { - value = of(Coding(system, code, display)) - } - apply - } - } - - if (!filters.isNullOrEmpty()) { - this@applyFilterByRelatedEntityLocationMetaTag.filter( - if (baseResourceType == ResourceType.Location) { - Location.RES_ID - } else { - TokenClientParam(TAG) - }, - *filters.toTypedArray(), - ) - } - } - } + return secondaryRepositoryResourceDataList } suspend fun retrieveUniqueIdAssignmentResource( uniqueIdAssignmentConfig: UniqueIdAssignmentConfig?, + computedValuesMap: Map, ): Resource? { if (uniqueIdAssignmentConfig != null) { val search = Search(uniqueIdAssignmentConfig.resource).apply { uniqueIdAssignmentConfig.dataQueries.forEach { - filterBy(dataQuery = it, configComputedRuleValues = emptyMap()) + filterBy(dataQuery = it, configComputedRuleValues = computedValuesMap) } if (uniqueIdAssignmentConfig.sortConfigs != null) { sort(uniqueIdAssignmentConfig.sortConfigs) @@ -1140,16 +1268,42 @@ constructor( return null } + suspend fun retrieveFlattenedSubLocations(locationId: String): ArrayDeque { + val locations = ArrayDeque() + val resources: ArrayDeque = retrieveSubLocations(locationId) + while (resources.isNotEmpty()) { + val currentResource = resources.removeFirst() + locations.add(currentResource) + retrieveSubLocations(currentResource.logicalId).forEach(resources::addLast) + } + loadResource(locationId)?.let { parentLocation -> locations.addFirst(parentLocation) } + return locations + } + + private suspend fun retrieveSubLocations(locationId: String): ArrayDeque = + fhirEngine + .batchedSearch( + Search(type = ResourceType.Location).apply { + filter( + Location.PARTOF, + { value = locationId.asReference(ResourceType.Location).reference }, + ) + }, + ) + .mapTo(ArrayDeque()) { it.resource } + /** * A wrapper data class to hold search results. All related resources are flattened into one Map * including the nested related resources as required by the Rules Engine facts. */ data class RelatedResourceWrapper( - val relatedResourceMap: MutableMap> = mutableMapOf(), - val relatedResourceCountMap: MutableMap> = mutableMapOf(), + val relatedResourceMap: MutableMap> = mutableMapOf(), + val relatedResourceCountMap: MutableMap> = + mutableMapOf(), ) companion object { + const val DEFAULT_BATCH_SIZE = 250 const val SNOMED_SYSTEM = "http://hl7.org/fhir/R4B/valueset-condition-clinical.html" const val PATIENT_CONDITION_RESOLVED_CODE = "resolved" const val PATIENT_CONDITION_RESOLVED_DISPLAY = "Resolved" diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/data/local/DefaultRepositoryComponent.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/data/local/DefaultRepositoryComponent.kt new file mode 100644 index 00000000000..bf93f30cbdf --- /dev/null +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/data/local/DefaultRepositoryComponent.kt @@ -0,0 +1,27 @@ +/* + * Copyright 2021-2024 Ona Systems, Inc + * + * 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 org.smartregister.fhircore.engine.data.local + +import dagger.Component +import javax.inject.Singleton +import org.smartregister.fhircore.engine.di.DispatcherModule + +@Singleton +@Component(modules = [DispatcherModule::class]) +interface DefaultRepositoryComponent { + fun inject(defaultRepository: DefaultRepository) +} diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/data/local/register/RegisterRepository.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/data/local/register/RegisterRepository.kt index 22f6f2044ab..af77c12a3fe 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/data/local/register/RegisterRepository.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/data/local/register/RegisterRepository.kt @@ -19,7 +19,6 @@ package org.smartregister.fhircore.engine.data.local.register import android.content.Context import ca.uhn.fhir.parser.IParser import com.google.android.fhir.FhirEngine -import com.google.android.fhir.search.Search import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject import kotlinx.coroutines.withContext @@ -29,6 +28,7 @@ import org.smartregister.fhircore.engine.configuration.ConfigurationRegistry import org.smartregister.fhircore.engine.configuration.app.ConfigService import org.smartregister.fhircore.engine.configuration.profile.ProfileConfiguration import org.smartregister.fhircore.engine.configuration.register.RegisterConfiguration +import org.smartregister.fhircore.engine.data.local.ContentCache import org.smartregister.fhircore.engine.data.local.DefaultRepository import org.smartregister.fhircore.engine.domain.model.ActionParameter import org.smartregister.fhircore.engine.domain.model.ActionParameterType @@ -40,7 +40,6 @@ import org.smartregister.fhircore.engine.util.DispatcherProvider import org.smartregister.fhircore.engine.util.SharedPreferencesHelper import org.smartregister.fhircore.engine.util.extension.extractLogicalIdUuid import org.smartregister.fhircore.engine.util.fhirpath.FhirPathDataExtractor -import timber.log.Timber class RegisterRepository @Inject @@ -54,6 +53,7 @@ constructor( override val fhirPathDataExtractor: FhirPathDataExtractor, override val parser: IParser, @ApplicationContext override val context: Context, + override val contentCache: ContentCache, ) : Repository, DefaultRepository( @@ -66,6 +66,7 @@ constructor( fhirPathDataExtractor = fhirPathDataExtractor, parser = parser, context = context, + contentCache = contentCache, ) { override suspend fun loadRegisterData( @@ -93,29 +94,20 @@ constructor( fhirResourceConfig: FhirResourceConfig?, paramsMap: Map?, ): Long { - val registerConfiguration = retrieveRegisterConfiguration(registerId, paramsMap) - val fhirResource = fhirResourceConfig ?: registerConfiguration.fhirResource - val baseResourceConfig = fhirResource.baseResource - val configComputedRuleValues = registerConfiguration.configRules.configRulesComputedValues() - val filterByRelatedEntityLocation = registerConfiguration.filterDataByRelatedEntityLocation - val search = - Search(baseResourceConfig.resource).apply { - applyConfiguredSortAndFilters( - resourceConfig = baseResourceConfig, - sortData = false, - filterActiveResources = registerConfiguration.activeResourceFilters, - configComputedRuleValues = configComputedRuleValues, - ) - applyFilterByRelatedEntityLocationMetaTag( - baseResourceType = baseResourceConfig.resource, - filterByRelatedEntityLocation = filterByRelatedEntityLocation, - ) - } - return search.count( - onFailure = { - Timber.e(it, "Error counting register data for register id: ${registerConfiguration.id}") - }, - ) + return withContext(dispatcherProvider.io()) { + val registerConfiguration = retrieveRegisterConfiguration(registerId, paramsMap) + val fhirResource = fhirResourceConfig ?: registerConfiguration.fhirResource + val baseResourceConfig = fhirResource.baseResource + val configComputedRuleValues = registerConfiguration.configRules.configRulesComputedValues() + val filterByRelatedEntityLocation = registerConfiguration.filterDataByRelatedEntityLocation + val filterActiveResources = registerConfiguration.activeResourceFilters + countResources( + filterByRelatedEntityLocation = filterByRelatedEntityLocation, + baseResourceConfig = baseResourceConfig, + filterActiveResources = filterActiveResources, + configComputedRuleValues = configComputedRuleValues, + ) + } } override suspend fun loadProfileData( @@ -124,47 +116,43 @@ constructor( fhirResourceConfig: FhirResourceConfig?, paramsList: Array?, ): RepositoryResourceData { - val paramsMap: Map = - paramsList - ?.asSequence() - ?.filter { - (it.paramType == ActionParameterType.PARAMDATA || - it.paramType == ActionParameterType.UPDATE_DATE_ON_EDIT) && it.value.isNotEmpty() - } - ?.associate { it.key to it.value } ?: emptyMap() + return withContext(dispatcherProvider.io()) { + val paramsMap: Map = + paramsList + ?.asSequence() + ?.filter { + (it.paramType == ActionParameterType.PARAMDATA || + it.paramType == ActionParameterType.UPDATE_DATE_ON_EDIT) && it.value.isNotEmpty() + } + ?.associate { it.key to it.value } ?: emptyMap() - val profileConfiguration = retrieveProfileConfiguration(profileId, paramsMap) - val resourceConfig = fhirResourceConfig ?: profileConfiguration.fhirResource - val baseResourceConfig = resourceConfig.baseResource + val profileConfiguration = retrieveProfileConfiguration(profileId, paramsMap) + val resourceConfig = fhirResourceConfig ?: profileConfiguration.fhirResource + val baseResourceConfig = resourceConfig.baseResource - val baseResource: Resource = - withContext(dispatcherProvider.io()) { + val baseResource: Resource = fhirEngine.get(baseResourceConfig.resource, resourceId.extractLogicalIdUuid()) - } - val configComputedRuleValues = profileConfiguration.configRules.configRulesComputedValues() + val configComputedRuleValues = profileConfiguration.configRules.configRulesComputedValues() - val retrievedRelatedResources = - withContext(dispatcherProvider.io()) { + val retrievedRelatedResources = retrieveRelatedResources( - resources = listOf(baseResource), + resource = baseResource, relatedResourcesConfigs = resourceConfig.relatedResources, - relatedResourceWrapper = RelatedResourceWrapper(), configComputedRuleValues = configComputedRuleValues, ) - } - return RepositoryResourceData( - resourceRulesEngineFactId = baseResourceConfig.id ?: baseResourceConfig.resource.name, - resource = baseResource, - relatedResourcesMap = retrievedRelatedResources.relatedResourceMap, - relatedResourcesCountMap = retrievedRelatedResources.relatedResourceCountMap, - secondaryRepositoryResourceData = - withContext(dispatcherProvider.io()) { + + RepositoryResourceData( + resourceRulesEngineFactId = baseResourceConfig.id ?: baseResourceConfig.resource.name, + resource = baseResource, + relatedResourcesMap = retrievedRelatedResources.relatedResourceMap, + relatedResourcesCountMap = retrievedRelatedResources.relatedResourceCountMap, + secondaryRepositoryResourceData = profileConfiguration.secondaryResources.retrieveSecondaryRepositoryResourceData( profileConfiguration.filterActiveResources, - ) - }, - ) + ), + ) + } } fun retrieveProfileConfiguration(profileId: String, paramsMap: Map) = diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/data/remote/shared/TokenAuthenticator.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/data/remote/shared/TokenAuthenticator.kt index 0f82f478830..757bb0a3559 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/data/remote/shared/TokenAuthenticator.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/data/remote/shared/TokenAuthenticator.kt @@ -28,17 +28,20 @@ import android.os.Handler import android.os.Looper import android.os.Message import androidx.core.os.bundleOf +import com.auth0.jwt.JWT +import com.auth0.jwt.exceptions.JWTDecodeException +import com.auth0.jwt.interfaces.DecodedJWT import com.google.android.fhir.sync.HttpAuthenticationMethod import com.google.android.fhir.sync.HttpAuthenticator as FhirAuthenticator import dagger.hilt.android.qualifiers.ApplicationContext -import io.jsonwebtoken.JwtException -import io.jsonwebtoken.Jwts import java.io.IOException import java.net.UnknownHostException import java.util.Base64 import javax.inject.Inject import javax.inject.Singleton import javax.net.ssl.SSLHandshakeException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import org.smartregister.fhircore.engine.configuration.app.ConfigService import org.smartregister.fhircore.engine.data.remote.auth.OAuthService @@ -62,7 +65,6 @@ constructor( @ApplicationContext val context: Context, ) : FhirAuthenticator { - private val jwtParser = Jwts.parser() private val authConfiguration by lazy { configService.provideAuthConfiguration() } private var isLoginPageRendered = false @@ -131,12 +133,11 @@ constructor( /** This function checks if token is null or empty or expired */ fun isTokenActive(authToken: String?): Boolean { if (authToken.isNullOrEmpty()) return false - val tokenPart = authToken.substringBeforeLast('.').plus(".") return try { - val body = jwtParser.parseClaimsJwt(tokenPart).body - body.expiration.after(today()) - } catch (jwtException: JwtException) { - false + val jwt: DecodedJWT? = JWT.decode(authToken) + jwt?.expiresAt!!.after(today()) + } catch (e: JWTDecodeException) { + return false } } @@ -189,7 +190,9 @@ constructor( accountManager.peekAuthToken(account, AUTH_TOKEN_TYPE), ) Result.success(true) - } else Result.success(false) + } else { + Result.success(false) + } } catch (httpException: HttpException) { Result.failure(httpException) } catch (unknownHostException: UnknownHostException) { @@ -214,8 +217,11 @@ constructor( addAccountExplicitly(newAccount, oAuthResponse.refreshToken, null) setAuthToken(newAccount, AUTH_TOKEN_TYPE, oAuthResponse.accessToken) } + // Save credentials - secureSharedPreference.saveCredentials(username, password) + CoroutineScope(dispatcherProvider.io()).launch { + secureSharedPreference.saveCredentials(username, password) + } } } diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/datastore/ProtoDataStore.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/datastore/ProtoDataStore.kt index c18738afd32..bbea2098024 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/datastore/ProtoDataStore.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/datastore/ProtoDataStore.kt @@ -30,16 +30,15 @@ import org.smartregister.fhircore.engine.datastore.serializers.LocationCoordinat import org.smartregister.fhircore.engine.datastore.serializers.PractitionerDetailsDataStoreSerializer import org.smartregister.fhircore.engine.datastore.serializers.SyncLocationIdDataStoreSerializer import org.smartregister.fhircore.engine.datastore.serializers.UserInfoDataStoreSerializer -import org.smartregister.fhircore.engine.domain.model.SyncLocationToggleableState +import org.smartregister.fhircore.engine.domain.model.SyncLocationState import org.smartregister.fhircore.engine.rulesengine.services.LocationCoordinate import timber.log.Timber private const val PRACTITIONER_DETAILS_DATASTORE_JSON = "practitioner_details.json" private const val USER_INFO_DATASTORE_JSON = "user_info.json" - private const val LOCATION_COORDINATES_DATASTORE_JSON = "location_coordinates.json" - private const val SYNC_LOCATION_IDS = "sync_location_ids.json" +private const val DATA_FILTER_LOCATION_IDS = "data_filter_location_ids.json" val Context.practitionerProtoStore: DataStore by dataStore( @@ -59,11 +58,16 @@ val Context.locationCoordinatesDatastore: DataStore by serializer = LocationCoordinatesSerializer, ) -val Context.syncLocationIdsProtoStore: DataStore> by +val Context.syncLocationIdsProtoStore: DataStore> by dataStore( fileName = SYNC_LOCATION_IDS, serializer = SyncLocationIdDataStoreSerializer, ) +val Context.dataFilterLocationIdsProtoStore: DataStore> by + dataStore( + fileName = DATA_FILTER_LOCATION_IDS, + serializer = SyncLocationIdDataStoreSerializer, + ) @Singleton class ProtoDataStore @Inject constructor(@ApplicationContext val context: Context) { diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/datastore/serializers/SyncLocationIdDataStoreSerializer.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/datastore/serializers/SyncLocationIdDataStoreSerializer.kt index 9ef7bdbd02a..261c51dc408 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/datastore/serializers/SyncLocationIdDataStoreSerializer.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/datastore/serializers/SyncLocationIdDataStoreSerializer.kt @@ -22,26 +22,26 @@ import java.io.OutputStream import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.apache.commons.lang3.SerializationException -import org.smartregister.fhircore.engine.domain.model.SyncLocationToggleableState +import org.smartregister.fhircore.engine.domain.model.SyncLocationState import org.smartregister.fhircore.engine.util.extension.encodeJson import org.smartregister.fhircore.engine.util.extension.json import timber.log.Timber -object SyncLocationIdDataStoreSerializer : Serializer> { +object SyncLocationIdDataStoreSerializer : Serializer> { - override val defaultValue: List - get() = emptyList() + override val defaultValue: Map + get() = emptyMap() - override suspend fun readFrom(input: InputStream): List { + override suspend fun readFrom(input: InputStream): Map { return try { - json.decodeFromString>(input.readBytes().decodeToString()) + json.decodeFromString>(input.readBytes().decodeToString()) } catch (serializationException: SerializationException) { Timber.e(serializationException) defaultValue } } - override suspend fun writeTo(t: List, output: OutputStream) { + override suspend fun writeTo(t: Map, output: OutputStream) { withContext(Dispatchers.IO) { output.write(t.encodeJson().encodeToByteArray()) } } } diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/di/CoreModule.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/di/CoreModule.kt index a3a1eb6d015..74a925f75f6 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/di/CoreModule.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/di/CoreModule.kt @@ -27,22 +27,74 @@ import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent +import java.io.File +import java.io.FileInputStream import javax.inject.Singleton +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import org.hl7.fhir.r4.context.SimpleWorkerContext import org.hl7.fhir.r4.model.Parameters +import org.hl7.fhir.r4.model.Resource import org.hl7.fhir.r4.utils.FHIRPathEngine +import org.smartregister.fhircore.engine.util.KnowledgeManagerUtil import org.smartregister.fhircore.engine.util.helper.TransformSupportServices +import timber.log.Timber @InstallIn(SingletonComponent::class) @Module class CoreModule { + @OptIn(DelicateCoroutinesApi::class) @Singleton @Provides - fun provideWorkerContextProvider(): SimpleWorkerContext = + fun provideWorkerContextProvider(@ApplicationContext context: Context): SimpleWorkerContext = SimpleWorkerContext().apply { - setExpansionProfile(Parameters()) - isCanRunWithoutTerminology = true + GlobalScope.launch { + withContext(Dispatchers.IO) { + setExpansionProfile(Parameters()) + isCanRunWithoutTerminology = true + context.filesDir + .resolve(KnowledgeManagerUtil.KNOWLEDGE_MANAGER_ASSETS_SUBFOLDER) + .list() + ?.forEach { resourceFolder -> + context.filesDir + .resolve( + "${KnowledgeManagerUtil.KNOWLEDGE_MANAGER_ASSETS_SUBFOLDER}/$resourceFolder", + ) + .list() + ?.forEach { file -> + try { + cacheResource( + FhirContext.forR4Cached() + .newJsonParser() + .parseResource( + FileInputStream( + File( + context.filesDir + .resolve( + "${KnowledgeManagerUtil.KNOWLEDGE_MANAGER_ASSETS_SUBFOLDER}/$resourceFolder/$file", + ) + .toString(), + ), + ), + ) as Resource, + ) + } catch (e: Exception) { + Timber.e( + "EXCEPTION PROCESSING ${context.filesDir + .resolve( + "${KnowledgeManagerUtil.KNOWLEDGE_MANAGER_ASSETS_SUBFOLDER}/$resourceFolder/$file", + )}", + ) + Timber.e(e) + } + } + } + } + } } @Singleton diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/di/FhirEngineModule.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/di/FhirEngineModule.kt index f39ef440b66..2b6afdaab7a 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/di/FhirEngineModule.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/di/FhirEngineModule.kt @@ -55,7 +55,7 @@ class FhirEngineModule { FhirEngineProvider.init( FhirEngineConfiguration( enableEncryptionIfSupported = !BuildConfig.DEBUG, - databaseErrorStrategy = DatabaseErrorStrategy.UNSPECIFIED, + databaseErrorStrategy = DatabaseErrorStrategy.RECREATE_AT_OPEN, ServerConfiguration( baseUrl = configService.provideAuthConfiguration().fhirServerBaseUrl, authenticator = tokenAuthenticator, diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/di/FhirValidatorModule.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/di/FhirValidatorModule.kt new file mode 100644 index 00000000000..4e49ded6ba7 --- /dev/null +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/di/FhirValidatorModule.kt @@ -0,0 +1,67 @@ +/* + * Copyright 2021-2024 Ona Systems, Inc + * + * 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 org.smartregister.fhircore.engine.di + +import ca.uhn.fhir.context.FhirContext +import ca.uhn.fhir.context.support.DefaultProfileValidationSupport +import ca.uhn.fhir.context.support.IValidationSupport +import ca.uhn.fhir.validation.FhirValidator +import dagger.Lazy +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton +import org.hl7.fhir.common.hapi.validation.support.CommonCodeSystemsTerminologyService +import org.hl7.fhir.common.hapi.validation.support.InMemoryTerminologyServerValidationSupport +import org.hl7.fhir.common.hapi.validation.support.UnknownCodeSystemWarningValidationSupport +import org.hl7.fhir.common.hapi.validation.support.ValidationSupportChain +import org.hl7.fhir.common.hapi.validation.validator.FhirInstanceValidator +import org.smartregister.fhircore.engine.util.DispatcherProvider +import org.smartregister.fhircore.engine.util.validation.ResourceValidationRequestHandler + +@Module +@InstallIn(SingletonComponent::class) +class FhirValidatorModule { + + @Provides + @Singleton + fun provideFhirValidator(fhirContext: FhirContext): FhirValidator { + val validationSupportChain = + ValidationSupportChain( + DefaultProfileValidationSupport(fhirContext), + InMemoryTerminologyServerValidationSupport(fhirContext), + CommonCodeSystemsTerminologyService(fhirContext), + UnknownCodeSystemWarningValidationSupport(fhirContext).apply { + setNonExistentCodeSystemSeverity(IValidationSupport.IssueSeverity.WARNING) + }, + ) + val instanceValidator = FhirInstanceValidator(validationSupportChain) + instanceValidator.isAssumeValidRestReferences = true + instanceValidator.invalidateCaches() + return fhirContext.newValidator().apply { registerValidatorModule(instanceValidator) } + } + + @Provides + @Singleton + fun provideResourceValidationRequestHandler( + fhirValidatorProvider: Lazy, + dispatcherProvider: DispatcherProvider, + ): ResourceValidationRequestHandler { + return ResourceValidationRequestHandler(fhirValidatorProvider.get(), dispatcherProvider) + } +} diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/di/NetworkModule.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/di/NetworkModule.kt index e2c346fc936..1ce80b7bf76 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/di/NetworkModule.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/di/NetworkModule.kt @@ -66,7 +66,9 @@ class NetworkModule { level = if (BuildConfig.DEBUG) { HttpLoggingInterceptor.Level.BODY - } else HttpLoggingInterceptor.Level.BASIC + } else { + HttpLoggingInterceptor.Level.BASIC + } redactHeader(AUTHORIZATION) redactHeader(COOKIE) }, @@ -141,7 +143,9 @@ class NetworkModule { level = if (BuildConfig.DEBUG) { HttpLoggingInterceptor.Level.BODY - } else HttpLoggingInterceptor.Level.BASIC + } else { + HttpLoggingInterceptor.Level.BASIC + } redactHeader(AUTHORIZATION) redactHeader(COOKIE) }, diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/domain/model/ActionConfig.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/domain/model/ActionConfig.kt index 0bd988d40b0..3b9f5b3acda 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/domain/model/ActionConfig.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/domain/model/ActionConfig.kt @@ -22,6 +22,7 @@ import kotlinx.parcelize.Parcelize import kotlinx.serialization.Serializable import org.hl7.fhir.r4.model.Enumerations import org.hl7.fhir.r4.model.ResourceType +import org.smartregister.fhircore.engine.configuration.PdfConfig import org.smartregister.fhircore.engine.configuration.QuestionnaireConfig import org.smartregister.fhircore.engine.configuration.profile.ManagingEntityConfig import org.smartregister.fhircore.engine.configuration.workflow.ActionTrigger @@ -42,6 +43,7 @@ data class ActionConfig( val toolBarHomeNavigation: ToolBarHomeNavigation = ToolBarHomeNavigation.OPEN_DRAWER, val popNavigationBackStack: Boolean? = null, val multiSelectViewConfig: MultiSelectViewConfig? = null, + val pdfConfig: PdfConfig? = null, ) : Parcelable, java.io.Serializable { fun paramsBundle(computedValuesMap: Map = emptyMap()): Bundle = Bundle().apply { diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/domain/model/MultiSelectViewConfig.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/domain/model/MultiSelectViewConfig.kt index 3da938c5fd1..f878256987a 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/domain/model/MultiSelectViewConfig.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/domain/model/MultiSelectViewConfig.kt @@ -29,6 +29,9 @@ import kotlinx.serialization.Serializable * @property rootNodeFhirPathExpression A key value pair containing a FHIRPath expression for * extracting the value used to identify if the current resource is Root. The key is the FHIRPath * expression while value is the content to compare against. + * @property viewActions The actions to be performed when the multiselect action button is pressed + * @property mutuallyExclusive Setup the multi choice checkbox such that only a single (root level) + * selection can be performed at a time. */ @Serializable @Parcelize @@ -37,4 +40,11 @@ data class MultiSelectViewConfig( val parentIdFhirPathExpression: String, val contentFhirPathExpression: String, val rootNodeFhirPathExpression: KeyValueConfig, + val viewActions: List = listOf(MultiSelectViewAction.FILTER_DATA), + val mutuallyExclusive: Boolean = true, ) : java.io.Serializable, Parcelable + +enum class MultiSelectViewAction { + SYNC_DATA, + FILTER_DATA, +} diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/domain/model/QuestionnaireType.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/domain/model/QuestionnaireType.kt index bf3fce59b76..ba490a4ec7d 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/domain/model/QuestionnaireType.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/domain/model/QuestionnaireType.kt @@ -22,6 +22,7 @@ enum class QuestionnaireType { DEFAULT, EDIT, READ_ONLY, + SUMMARY, } fun QuestionnaireConfig.isDefault() = @@ -32,3 +33,6 @@ fun QuestionnaireConfig.isEditable() = fun QuestionnaireConfig.isReadOnly() = QuestionnaireType.valueOf(this.type) == QuestionnaireType.READ_ONLY + +fun QuestionnaireConfig.isSummary() = + QuestionnaireType.valueOf(this.type) == QuestionnaireType.SUMMARY diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/domain/model/SyncLocationToggleableState.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/domain/model/SyncLocationState.kt similarity index 93% rename from android/engine/src/main/java/org/smartregister/fhircore/engine/domain/model/SyncLocationToggleableState.kt rename to android/engine/src/main/java/org/smartregister/fhircore/engine/domain/model/SyncLocationState.kt index d4cda89cc43..743663cb0a5 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/domain/model/SyncLocationToggleableState.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/domain/model/SyncLocationState.kt @@ -20,7 +20,8 @@ import androidx.compose.ui.state.ToggleableState import kotlinx.serialization.Serializable @Serializable -data class SyncLocationToggleableState( +data class SyncLocationState( val locationId: String, + val parentLocationId: String?, val toggleableState: ToggleableState, ) diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/p2p/dao/BaseP2PTransferDao.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/p2p/dao/BaseP2PTransferDao.kt index 2893539540c..2cd52f88aba 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/p2p/dao/BaseP2PTransferDao.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/p2p/dao/BaseP2PTransferDao.kt @@ -16,8 +16,6 @@ package org.smartregister.fhircore.engine.p2p.dao -import ca.uhn.fhir.context.FhirContext -import ca.uhn.fhir.parser.IParser import ca.uhn.fhir.rest.gclient.DateClientParam import ca.uhn.fhir.rest.gclient.StringClientParam import ca.uhn.fhir.rest.param.ParamPrefixEnum @@ -36,6 +34,7 @@ import org.smartregister.fhircore.engine.configuration.ConfigType import org.smartregister.fhircore.engine.configuration.ConfigurationRegistry import org.smartregister.fhircore.engine.configuration.app.ApplicationConfiguration import org.smartregister.fhircore.engine.util.DispatcherProvider +import org.smartregister.fhircore.engine.util.extension.batchedSearch import org.smartregister.fhircore.engine.util.extension.isValidResourceType import org.smartregister.fhircore.engine.util.extension.resourceClassType import org.smartregister.p2p.model.RecordCount @@ -48,8 +47,6 @@ constructor( open val configurationRegistry: ConfigurationRegistry, ) { - protected val jsonParser: IParser = FhirContext.forR4Cached().newJsonParser() - open fun getDataTypes(): TreeSet { val appRegistry = configurationRegistry.retrieveConfiguration(ConfigType.Application) @@ -108,7 +105,7 @@ constructor( count = batchSize from = offset } - fhirEngine.search(search) + fhirEngine.batchedSearch(search) } } diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/p2p/dao/P2PReceiverTransferDao.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/p2p/dao/P2PReceiverTransferDao.kt index 24270c0215c..ddc1d1d7591 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/p2p/dao/P2PReceiverTransferDao.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/p2p/dao/P2PReceiverTransferDao.kt @@ -16,6 +16,7 @@ package org.smartregister.fhircore.engine.p2p.dao +import ca.uhn.fhir.context.FhirContext import com.google.android.fhir.FhirEngine import com.google.android.fhir.datacapture.extensions.logicalId import java.util.TreeSet @@ -47,7 +48,9 @@ constructor( (0 until jsonArray.length()).forEach { runBlocking { val resource = - jsonParser.parseResource(type.name.resourceClassType(), jsonArray.get(it).toString()) + FhirContext.forR4Cached() + .newJsonParser() + .parseResource(type.name.resourceClassType(), jsonArray.get(it).toString()) val recordLastUpdated = resource.meta.lastUpdated.time defaultRepository.addOrUpdate(resource = resource) maxLastUpdated = diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/p2p/dao/P2PSenderTransferDao.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/p2p/dao/P2PSenderTransferDao.kt index f6a0c181233..ce537fb3b7a 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/p2p/dao/P2PSenderTransferDao.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/p2p/dao/P2PSenderTransferDao.kt @@ -16,6 +16,7 @@ package org.smartregister.fhircore.engine.p2p.dao +import ca.uhn.fhir.context.FhirContext import com.google.android.fhir.FhirEngine import com.google.android.fhir.datacapture.extensions.logicalId import java.util.TreeSet @@ -76,7 +77,7 @@ constructor( val jsonArray = JSONArray() records.forEach { - jsonArray.put(jsonParser.encodeResourceToString(it.resource)) + jsonArray.put(FhirContext.forR4Cached().newJsonParser().encodeResourceToString(it.resource)) highestRecordId = if (it.resource.meta?.lastUpdated?.time!! > highestRecordId) { it.resource.meta?.lastUpdated?.time!! diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/pdf/HtmlPopulator.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/pdf/HtmlPopulator.kt index 28aaf581edc..7b49518eeaf 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/pdf/HtmlPopulator.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/pdf/HtmlPopulator.kt @@ -16,32 +16,55 @@ package org.smartregister.fhircore.engine.pdf +import java.util.Date import java.util.regex.Matcher import java.util.regex.Pattern import org.hl7.fhir.r4.model.BaseDateTimeType import org.hl7.fhir.r4.model.QuestionnaireResponse +import org.hl7.fhir.r4.model.QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent import org.smartregister.fhircore.engine.util.extension.allItems +import org.smartregister.fhircore.engine.util.extension.extractLogicalIdUuid import org.smartregister.fhircore.engine.util.extension.formatDate import org.smartregister.fhircore.engine.util.extension.makeItReadable import org.smartregister.fhircore.engine.util.extension.valueToString /** * HtmlPopulator class is responsible for processing an HTML template by replacing custom tags with - * data from a QuestionnaireResponse. The class uses various regex patterns to find and replace - * custom tags such as @is-not-empty, @answer-as-list, @answer, @submitted-date, and @contains. + * data from QuestionnaireResponses. The class uses various regex patterns to find and replace + * custom tags such as @is-not-empty, @answer-as-list, @answer, @submitted-date, @contains, + * and @is-questionnaire-submitted. * - * @property questionnaireResponse The QuestionnaireResponse object containing data for replacement. + * @property questionnaireResponses The QuestionnaireResponses object containing data for + * replacement. */ class HtmlPopulator( - private val questionnaireResponse: QuestionnaireResponse, + questionnaireResponses: List, ) { + private var answerMap: Map> + private var submittedDateMap: Map + private var questionnaireIds: List - // Map to store questionnaire response items keyed by their linkId - private val questionnaireResponseItemMap = - questionnaireResponse.allItems.associateBy( - keySelector = { it.linkId }, - valueTransform = { it.answer }, - ) + init { + val answerMap = mutableMapOf>() + val submittedDateMap = mutableMapOf() + val questionnaireIds = mutableListOf() + + questionnaireResponses.forEach { questionnaireResponse -> + val questionnaireId = questionnaireResponse.questionnaire.extractLogicalIdUuid() + questionnaireResponse.allItems + .associateBy( + keySelector = { "$questionnaireId/${it.linkId}" }, + valueTransform = { it.answer }, + ) + .let { answerMap.putAll(it) } + submittedDateMap[questionnaireId] = questionnaireResponse.meta.lastUpdated ?: Date() + questionnaireIds.add(questionnaireId) + } + + this.answerMap = answerMap + this.submittedDateMap = submittedDateMap + this.questionnaireIds = questionnaireIds + } /** * Populates the provided HTML template with data from the QuestionnaireResponse. @@ -77,6 +100,10 @@ class HtmlPopulator( val matcher = containsPattern.matcher(html.substring(i)) if (matcher.find()) processContains(i, html, matcher) else i++ } + html.startsWith("@is-questionnaire-submitted", i) -> { + val matcher = isQuestionnaireSubmittedPattern.matcher(html.substring(i)) + if (matcher.find()) processIsQuestionnaireSubmitted(i, html, matcher) else i++ + } else -> i++ } } @@ -94,7 +121,7 @@ class HtmlPopulator( private fun processIsNotEmpty(i: Int, html: StringBuilder, matcher: Matcher) { val linkId = matcher.group(1) val content = matcher.group(2) ?: "" - val doesAnswerExist = questionnaireResponseItemMap.getOrDefault(linkId, listOf()).isNotEmpty() + val doesAnswerExist = answerMap.getOrDefault(linkId, listOf()).isNotEmpty() if (doesAnswerExist) { html.replace(i, matcher.end() + i, content) // Start index is the index of '@' symbol, End index is the index after the ')' symbol. @@ -119,8 +146,7 @@ class HtmlPopulator( private fun processAnswerAsList(i: Int, html: StringBuilder, matcher: Matcher) { val linkId = matcher.group(1) val answerAsList = - questionnaireResponseItemMap.getOrDefault(linkId, listOf()).joinToString(separator = "") { - answer -> + answerMap.getOrDefault(linkId, listOf()).joinToString(separator = "") { answer -> "
  • ${answer.value.valueToString()}
  • " } html.replace(i, matcher.end() + i, answerAsList) @@ -137,10 +163,12 @@ class HtmlPopulator( val linkId = matcher.group(1) val dateFormat = matcher.group(2) val answer = - questionnaireResponseItemMap.getOrDefault(linkId, listOf()).joinToString { answer -> + answerMap.getOrDefault(linkId, listOf()).joinToString { answer -> if (dateFormat == null) { answer.value.valueToString() - } else answer.value.valueToString(dateFormat) + } else { + answer.value.valueToString(dateFormat) + } } html.replace(i, matcher.end() + i, answer) } @@ -153,12 +181,13 @@ class HtmlPopulator( * @param matcher The Matcher object for the regex pattern. */ private fun processSubmittedDate(i: Int, html: StringBuilder, matcher: Matcher) { - val dateFormat = matcher.group(1) + val questionnaireId = matcher.group(1) + val dateFormat = matcher.group(2) val date = if (dateFormat == null) { - questionnaireResponse.meta.lastUpdated.formatDate() + submittedDateMap.getOrDefault(questionnaireId, Date()).formatDate() } else { - questionnaireResponse.meta.lastUpdated.formatDate(dateFormat) + submittedDateMap.getOrDefault(questionnaireId, Date()).formatDate(dateFormat) } html.replace(i, matcher.end() + i, date) } @@ -176,7 +205,7 @@ class HtmlPopulator( val indicator = matcher.group(2) ?: "" val content = matcher.group(3) ?: "" val doesAnswerExist = - questionnaireResponseItemMap.getOrDefault(linkId, listOf()).any { + answerMap.getOrDefault(linkId, listOf()).any { when { it.hasValueCoding() -> it.valueCoding.code == indicator it.hasValueStringType() -> it.valueStringType.value.contains(indicator) @@ -197,14 +226,39 @@ class HtmlPopulator( } } + /** + * Processes the @is-questionnaire-submitted tag by checking if the corresponding + * [QuestionnaireResponse] exists. Replaces the tag with the content if the indicator is true, + * otherwise removes the tag. + * + * @param i The starting index of the tag in the HTML. + * @param html The StringBuilder containing the HTML. + * @param matcher The Matcher object for the regex pattern. + */ + private fun processIsQuestionnaireSubmitted(i: Int, html: StringBuilder, matcher: Matcher) { + val id = matcher.group(1) + val content = matcher.group(2) ?: "" + val doesQuestionnaireExists = questionnaireIds.contains(id) + if (doesQuestionnaireExists) { + html.replace(i, matcher.end() + i, content) + } else { + html.replace(i, matcher.end() + i, "") + } + } + companion object { // Compile regex patterns for different tags private val isNotEmptyPattern = Pattern.compile("@is-not-empty\\('([^']+)'\\)((?s).*?)@is-not-empty\\('\\1'\\)") private val answerAsListPattern = Pattern.compile("@answer-as-list\\('([^']+)'\\)") private val answerPattern = Pattern.compile("@answer\\('([^']+)'(?:,'([^']+)')?\\)") - private val submittedDatePattern = Pattern.compile("@submitted-date(?:\\('([^']+)'\\))?") + private val submittedDatePattern = + Pattern.compile("@submitted-date\\('([^']+)'(?:,'([^']+)')?\\)") private val containsPattern = Pattern.compile("@contains\\('([^']+)','([^']+)'\\)((?s).*?)@contains\\('\\1'\\)") + private val isQuestionnaireSubmittedPattern = + Pattern.compile( + "@is-questionnaire-submitted\\('([^']+)'\\)((?s).*?)@is-questionnaire-submitted\\('\\1'\\)", + ) } } diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/rulesengine/ConfigRulesExecutor.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/rulesengine/ConfigRulesExecutor.kt index 7c1821d6b49..c03ff0a4951 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/rulesengine/ConfigRulesExecutor.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/rulesengine/ConfigRulesExecutor.kt @@ -52,7 +52,9 @@ class ConfigRulesExecutor @Inject constructor(val fhirPathDataExtractor: FhirPat if (BuildConfig.DEBUG) { val timeToFireRules = measureTimeMillis { rulesEngine.fire(rules, facts) } Timber.d("Rule executed in $timeToFireRules millisecond(s)") - } else rulesEngine.fire(rules, facts) + } else { + rulesEngine.fire(rules, facts) + } return facts.get(DATA) as Map } diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/rulesengine/ResourceDataRulesExecutor.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/rulesengine/ResourceDataRulesExecutor.kt index fce7444a957..7ed10158d11 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/rulesengine/ResourceDataRulesExecutor.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/rulesengine/ResourceDataRulesExecutor.kt @@ -24,12 +24,13 @@ import javax.inject.Inject import org.hl7.fhir.r4.model.Resource import org.jeasy.rules.api.Facts import org.smartregister.fhircore.engine.configuration.view.ListProperties -import org.smartregister.fhircore.engine.configuration.view.ListResource +import org.smartregister.fhircore.engine.configuration.view.ListResourceConfig import org.smartregister.fhircore.engine.domain.model.RepositoryResourceData import org.smartregister.fhircore.engine.domain.model.ResourceData import org.smartregister.fhircore.engine.domain.model.RuleConfig import org.smartregister.fhircore.engine.domain.model.ViewType import org.smartregister.fhircore.engine.util.extension.extractLogicalIdUuid +import org.smartregister.fhircore.engine.util.extension.interpolate /** * This class is used to fire rules used to extract and manipulate data from FHIR resources. @@ -73,17 +74,18 @@ class ResourceDataRulesExecutor @Inject constructor(val rulesFactory: RulesFacto listResourceDataStateMap: SnapshotStateMap>, ) { listProperties.resources.forEach { listResource -> - // Initialize to be updated incrementally as resources are transformed into ResourceData + // A new list is required on each iteration val resourceDataSnapshotStateList = mutableStateListOf() listResourceDataStateMap[listProperties.id] = resourceDataSnapshotStateList - filteredListResources(relatedResourcesMap, listResource) + filteredListResources(relatedResourcesMap, listResource, computedValuesMap) .mapToResourceData( - listResource = listResource, + listResourceConfig = listResource, relatedResourcesMap = relatedResourcesMap, ruleConfigs = listProperties.registerCard.rules, computedValuesMap = computedValuesMap, resourceDataSnapshotStateList = resourceDataSnapshotStateList, + listResourceDataStateMap = listResourceDataStateMap, ) } } @@ -107,36 +109,64 @@ class ResourceDataRulesExecutor @Inject constructor(val rulesFactory: RulesFacto } private fun List.mapToResourceData( - listResource: ListResource, + listResourceConfig: ListResourceConfig, relatedResourcesMap: Map>, ruleConfigs: List, computedValuesMap: Map, resourceDataSnapshotStateList: SnapshotStateList, + listResourceDataStateMap: SnapshotStateMap>, ) { - this.forEach { resource -> + this.forEach { baseListResource -> + val relatedResourcesQueue = + ArrayDeque>>().apply { + addFirst(Pair(baseListResource, listResourceConfig.relatedResources)) + } + val listItemRelatedResources = mutableMapOf>() - listResource.relatedResources.forEach { relatedListResource -> - val retrieveRelatedResources: List? = - relatedListResource.fhirPathExpression.let { + while (relatedResourcesQueue.isNotEmpty()) { + val (currentResource, currentListResourceConfig) = relatedResourcesQueue.removeFirst() + currentListResourceConfig.forEach { relatedListResourceConfig -> + val retrievedRelatedResources: List = rulesFactory.rulesEngineService.retrieveRelatedResources( - resource = resource, + resource = currentResource, relatedResourceKey = - relatedListResource.relatedResourceId ?: relatedListResource.resourceType.name, - referenceFhirPathExpression = it, + relatedListResourceConfig.relatedResourceId + ?: relatedListResourceConfig.resourceType.name, + referenceFhirPathExpression = relatedListResourceConfig.fhirPathExpression, relatedResourcesMap = relatedResourcesMap, ) - } - if (!retrieveRelatedResources.isNullOrEmpty()) { - listItemRelatedResources[ - relatedListResource.id ?: relatedListResource.resourceType.name, - ] = - if (!relatedListResource.conditionalFhirPathExpression.isNullOrEmpty()) { - rulesFactory.rulesEngineService.filterResources( - retrieveRelatedResources, - relatedListResource.conditionalFhirPathExpression, - ) - } else { - retrieveRelatedResources + + val interpolatedConditionalFhirPathExpression = + relatedListResourceConfig.conditionalFhirPathExpression?.interpolate(computedValuesMap) + + rulesFactory.rulesEngineService + .filterResources( + resources = retrievedRelatedResources, + conditionalFhirPathExpression = interpolatedConditionalFhirPathExpression, + ) + .also { filteredResources -> + // Add to queue for processing + filteredResources.forEach { + relatedResourcesQueue.addLast(Pair(it, relatedListResourceConfig.relatedResources)) + } + + // Apply configurable sorting to related resources + val sortConfig = relatedListResourceConfig.sortConfig + if (sortConfig == null || sortConfig.fhirPathExpression.isBlank()) { + listItemRelatedResources[ + relatedListResourceConfig.id ?: relatedListResourceConfig.resourceType.name, + ] = filteredResources + } else { + listItemRelatedResources[ + relatedListResourceConfig.id ?: relatedListResourceConfig.resourceType.name, + ] = + rulesFactory.rulesEngineService.sortResources( + resources = filteredResources, + fhirPathExpression = sortConfig.fhirPathExpression, + dataType = sortConfig.dataType.name, + order = sortConfig.order.name, + ) ?: filteredResources + } } } } @@ -146,8 +176,8 @@ class ResourceDataRulesExecutor @Inject constructor(val rulesFactory: RulesFacto ruleConfigs = ruleConfigs, repositoryResourceData = RepositoryResourceData( - resourceRulesEngineFactId = null, - resource = resource, + resourceRulesEngineFactId = listResourceConfig.id, + resource = baseListResource, relatedResourcesMap = listItemRelatedResources, ), params = emptyMap(), @@ -155,10 +185,10 @@ class ResourceDataRulesExecutor @Inject constructor(val rulesFactory: RulesFacto resourceDataSnapshotStateList.add( ResourceData( - baseResourceId = resource.logicalId.extractLogicalIdUuid(), - baseResourceType = resource.resourceType, - computedValuesMap = - computedValuesMap.plus(listComputedValuesMap), // Reuse computed values + baseResourceId = baseListResource.logicalId, + baseResourceType = baseListResource.resourceType, + computedValuesMap = computedValuesMap.plus(listComputedValuesMap), + listResourceDataMap = listResourceDataStateMap, ), ) } @@ -167,39 +197,36 @@ class ResourceDataRulesExecutor @Inject constructor(val rulesFactory: RulesFacto /** * This function returns a list of filtered resources. The required list is obtained from * [relatedResourceMap], then a filter is applied based on the condition returned from the - * extraction of the [ListResource] conditional FHIR path expression + * extraction of the [ListResourceConfig] conditional FHIR path expression. The list is sorted if + * configurations for sorting are provided. */ private fun filteredListResources( relatedResourceMap: Map>, - listResource: ListResource, + listResource: ListResourceConfig, + computedValuesMap: Map, ): List { val relatedResourceKey = listResource.relatedResourceId ?: listResource.resourceType.name - val newListRelatedResources = relatedResourceMap[relatedResourceKey] + val interpolatedConditionalFhirPathExpression = + listResource.conditionalFhirPathExpression?.interpolate(computedValuesMap) - // conditionalFhirPath expression e.g. "Task.status == 'ready'" to filter tasks that are due + // Filter by condition derived from fhirPathExpression otherwise return original or empty list val resources = - if ( - newListRelatedResources != null && - !listResource.conditionalFhirPathExpression.isNullOrEmpty() - ) { - rulesFactory.rulesEngineService.filterResources( - resources = newListRelatedResources, - conditionalFhirPathExpression = listResource.conditionalFhirPathExpression, - ) - } else newListRelatedResources ?: listOf() + rulesFactory.rulesEngineService.filterResources( + resources = relatedResourceMap[relatedResourceKey], + conditionalFhirPathExpression = interpolatedConditionalFhirPathExpression, + ) + // Sort resources if valid sort configuration is provided val sortConfig = listResource.sortConfig - - // Sort resources if sort configuration is provided - return if (sortConfig != null && sortConfig.fhirPathExpression.isNotEmpty()) { + return if (sortConfig == null || sortConfig.fhirPathExpression.isEmpty()) { + resources + } else { rulesFactory.rulesEngineService.sortResources( resources = resources, fhirPathExpression = sortConfig.fhirPathExpression, dataType = sortConfig.dataType.name, order = sortConfig.order.name, ) ?: resources - } else { - resources } } } diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/rulesengine/RulesFactory.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/rulesengine/RulesFactory.kt index f9171eec35b..99af4f1a6af 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/rulesengine/RulesFactory.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/rulesengine/RulesFactory.kt @@ -17,8 +17,13 @@ package org.smartregister.fhircore.engine.rulesengine import android.content.Context +import ca.uhn.fhir.context.FhirContext import com.google.android.fhir.datacapture.extensions.logicalId import com.google.android.fhir.search.Order +import com.jayway.jsonpath.Configuration +import com.jayway.jsonpath.JsonPath +import com.jayway.jsonpath.Option +import com.jayway.jsonpath.PathNotFoundException import dagger.hilt.android.qualifiers.ApplicationContext import java.math.BigDecimal import java.text.SimpleDateFormat @@ -26,9 +31,10 @@ import java.util.Date import java.util.Locale import javax.inject.Inject import kotlin.system.measureTimeMillis +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch import org.hl7.fhir.r4.model.Base import org.hl7.fhir.r4.model.Enumerations.DataType -import org.hl7.fhir.r4.model.Patient import org.hl7.fhir.r4.model.Resource import org.hl7.fhir.r4.model.Task import org.jeasy.rules.api.Facts @@ -38,18 +44,22 @@ import org.joda.time.DateTime import org.ocpsoft.prettytime.PrettyTime import org.smartregister.fhircore.engine.BuildConfig import org.smartregister.fhircore.engine.configuration.ConfigurationRegistry +import org.smartregister.fhircore.engine.data.local.DefaultRepository import org.smartregister.fhircore.engine.domain.model.RelatedResourceCount import org.smartregister.fhircore.engine.domain.model.RepositoryResourceData import org.smartregister.fhircore.engine.domain.model.RuleConfig import org.smartregister.fhircore.engine.domain.model.ServiceMemberIcon import org.smartregister.fhircore.engine.domain.model.ServiceStatus +import org.smartregister.fhircore.engine.rulesengine.services.DateService import org.smartregister.fhircore.engine.rulesengine.services.LocationService import org.smartregister.fhircore.engine.util.DispatcherProvider import org.smartregister.fhircore.engine.util.SharedPreferenceKey import org.smartregister.fhircore.engine.util.extension.SDF_DD_MMM_YYYY import org.smartregister.fhircore.engine.util.extension.SDF_E_MMM_DD_YYYY import org.smartregister.fhircore.engine.util.extension.daysPassed +import org.smartregister.fhircore.engine.util.extension.encodeResourceToString import org.smartregister.fhircore.engine.util.extension.extractAge +import org.smartregister.fhircore.engine.util.extension.extractBirthDate import org.smartregister.fhircore.engine.util.extension.extractGender import org.smartregister.fhircore.engine.util.extension.extractLogicalIdUuid import org.smartregister.fhircore.engine.util.extension.formatDate @@ -69,6 +79,8 @@ constructor( val fhirPathDataExtractor: FhirPathDataExtractor, val dispatcherProvider: DispatcherProvider, val locationService: LocationService, + val fhirContext: FhirContext, + val defaultRepository: DefaultRepository, ) : RulesListener() { val rulesEngineService = RulesEngineService() private var facts: Facts = Facts() @@ -91,6 +103,7 @@ constructor( put(DATA, mutableMapOf().apply { putAll(params) }) put(LOCATION_SERVICE, locationService) put(SERVICE, rulesEngineService) + put(DATE_SERVICE, DateService) } if (repositoryResourceData != null) { with(repositoryResourceData) { @@ -129,13 +142,18 @@ constructor( if (BuildConfig.DEBUG) { val timeToFireRules = measureTimeMillis { rulesEngine.fire(rules, facts) } Timber.d("Rule executed in $timeToFireRules millisecond(s)") - } else rulesEngine.fire(rules, facts) + } else { + rulesEngine.fire(rules, facts) + } return facts.get(DATA) as Map } /** Provide access to utility functions accessible to the users defining rules in JSON format. */ inner class RulesEngineService { + private var conf: Configuration = + Configuration.defaultConfiguration().apply { addOptions(Option.DEFAULT_PATH_LEAF_TO_NULL) } + /** * This function creates a property key from the string [value] and uses the key to retrieve the * correct translation from the string.properties file. @@ -165,24 +183,35 @@ constructor( relatedResourceKey: String, referenceFhirPathExpression: String?, relatedResourcesMap: Map>? = null, + isRevInclude: Boolean = true, ): List { val value: List = relatedResourcesMap?.get(relatedResourceKey) ?: if (facts.getFact(relatedResourceKey) != null) { - facts.getFact(relatedResourceKey).value as List + facts.getFact(relatedResourceKey).value as List? ?: emptyList() } else { emptyList() } - return if (referenceFhirPathExpression.isNullOrEmpty()) { - value - } else - value.filter { - resource.logicalId == - fhirPathDataExtractor - .extractValue(it, referenceFhirPathExpression) - .extractLogicalIdUuid() + if (referenceFhirPathExpression.isNullOrEmpty()) { + return value + } + + // Reverse search; look for related resource that references the provided resource + return if (isRevInclude) { + value.filter { res -> + fhirPathDataExtractor.extractData(res, referenceFhirPathExpression).all { + resource.logicalId == it.primitiveValue().extractLogicalIdUuid() + } } + } else { + // Forward search; extract value provided resource, then search resources with matching id + value.filter { res -> + fhirPathDataExtractor.extractData(resource, referenceFhirPathExpression).all { + res.logicalId == it.primitiveValue().extractLogicalIdUuid() + } + } + } } /** @@ -288,17 +317,22 @@ constructor( label: String, ): String = mapResourcesToLabeledCSV(listOf(resource), fhirPathExpression, label) - /** This function extracts the patient's age from the patient resource */ - fun extractAge(patient: Patient): String = patient.extractAge(context) + /** Extracts a Patient/RelatedPerson's age */ + fun extractAge(resource: Resource): String { + return resource.extractAge(context) + } - /** - * This function extracts and returns a translated string for the gender in Patient resource. - */ - fun extractGender(patient: Patient): String = patient.extractGender(context) ?: "" + /** Extracts and returns a translated string for the gender in the resource */ + fun extractGender(resource: Resource): String { + return resource.extractGender(context) + } - /** This function extracts the patient's DOB from the FHIR resource */ - fun extractDOB(patient: Patient, dateFormat: String): String = - SimpleDateFormat(dateFormat, Locale.ENGLISH).run { format(patient.birthDate) } + /** This function extracts a Patient/RelatedPerson's DOB from the FHIR resource */ + fun extractDOB(resource: Resource, dateFormat: String): String { + return SimpleDateFormat(dateFormat, Locale.ENGLISH).run { + resource.extractBirthDate()?.let { format(it) } + } ?: "" + } /** * This function takes [inputDate] and returns a difference (for examples 7 hours, 2 day, 5 @@ -401,14 +435,15 @@ constructor( /** * This function filters resources provided the condition extracted from the - * [conditionalFhirPathExpression] is met + * [conditionalFhirPathExpression] is met. Returns the original source or empty resources list + * if FHIR path expression is null. */ fun filterResources( resources: List?, - conditionalFhirPathExpression: String, + conditionalFhirPathExpression: String?, ): List { - if (conditionalFhirPathExpression.isEmpty()) { - return emptyList() + if (conditionalFhirPathExpression.isNullOrBlank()) { + return resources ?: emptyList() } return resources?.filter { fhirPathDataExtractor.extractValue(it, conditionalFhirPathExpression).toBoolean() @@ -457,6 +492,48 @@ constructor( } .getOrNull() + fun filterResourcesByJsonPath( + resources: List?, + jsonPathExpression: String, + dataType: String, + value: Any, + vararg compareToResult: Any, + ): List? { + if (resources.isNullOrEmpty() || jsonPathExpression.isBlank()) return null + + val expression = + if (jsonPathExpression.startsWith("\$")) { + jsonPathExpression + } else { + jsonPathExpression.replace( + jsonPathExpression.substring(0, jsonPathExpression.indexOf(".")), + "\$", + ) + } + + return runCatching { + resources.filter { + val document = JsonPath.using(conf).parse(it.encodeResourceToString()) + val result: Any = document.read(expression) + + when (DataType.valueOf(dataType.uppercase())) { + DataType.BOOLEAN -> (result as Boolean).compareTo(value as Boolean) in compareToResult + DataType.DATE -> (result as Date).compareTo(value as Date) in compareToResult + DataType.DATETIME -> + (result as DateTime).compareTo(value as DateTime) in compareToResult + DataType.DECIMAL -> + (result as BigDecimal).compareTo(value as BigDecimal) in compareToResult + DataType.INTEGER -> (result as Int).compareTo(value as Int) in compareToResult + DataType.STRING -> (result as String).compareTo(value as String) in compareToResult + else -> { + false + } + } + } + } + .getOrNull() + } + /** * This function combines all string indexes to a list separated by the separator and regex * defined by the content author @@ -480,6 +557,7 @@ constructor( return source?.take(limit) ?: emptyList() } + @JvmOverloads fun mapResourcesToExtractedValues( resources: List?, fhirPathExpression: String, @@ -491,6 +569,31 @@ constructor( ?: emptyList() } + /** + * This function combines all the string values retrieved from the [resources] using the + * [fhirPathExpression] to a list separated by the [separator] + * + * e.g for a provided list of Patients we can extract a string containing the family names using + * the [Patient.name.family] as the [fhirpathExpression] and [ | ] as the [separator] the + * returned string would be [John | Jane | James] + */ + @JvmOverloads + fun mapResourcesToExtractedValues( + resources: List?, + fhirPathExpression: String, + separator: String = ",", + ): String { + if (fhirPathExpression.isEmpty()) { + return "" + } + val results: List = + mapResourcesToExtractedValues( + resources = resources, + fhirPathExpression = fhirPathExpression, + ) + return results.joinToString(separator) + } + fun computeTotalCount(relatedResourceCounts: List?): Long = relatedResourceCounts?.sumOf { it.count } ?: 0 @@ -546,12 +649,11 @@ constructor( } .getOrNull() - fun generateTaskServiceStatus(task: Task): String { - val serviceStatus: String - if (task.isOverDue()) { - serviceStatus = ServiceStatus.OVERDUE.name - } else { - serviceStatus = + fun generateTaskServiceStatus(task: Task?): String { + return when { + task == null -> "" + task.isOverDue() -> ServiceStatus.OVERDUE.name + else -> { when (task.status) { Task.TaskStatus.NULL, Task.TaskStatus.RECEIVED, @@ -569,15 +671,85 @@ constructor( Task.TaskStatus.CANCELLED -> ServiceStatus.EXPIRED.name Task.TaskStatus.INPROGRESS -> ServiceStatus.IN_PROGRESS.name Task.TaskStatus.COMPLETED -> ServiceStatus.COMPLETED.name + else -> "" + } + } + } + } + + @JvmOverloads + fun updateResource( + resource: Resource?, + path: String?, + value: Any?, + purgeAffectedResources: Boolean = false, + createLocalChangeEntitiesAfterPurge: Boolean = true, + ) { + if (resource == null || path.isNullOrEmpty()) return + + val jsonParse = JsonPath.using(conf).parse(resource.encodeResourceToString()) + + val updatedResourceDocument = + try { + jsonParse.apply { + // Expression stars with '$' (JSONPath) or ResourceType like in FHIRPath + if (path.startsWith("\$") && value != null) { + set(path, value) + } + if ( + path.startsWith( + resource.resourceType.name, + ignoreCase = true, + ) && value != null + ) { + set( + path.replace(resource.resourceType.name, "\$"), + value, + ) + } + + if (resource.id.startsWith("#")) { + val idPath = "\$.id" + set(idPath, resource.id.replace("#", "")) + } } + } catch (e: PathNotFoundException) { + Timber.e(e, "Path $path not found") + jsonParse + } + + val updatedResource = + fhirContext + .newJsonParser() + .parseResource(resource::class.java, updatedResourceDocument.jsonString()) + CoroutineScope(dispatcherProvider.io()).launch { + if (purgeAffectedResources) { + defaultRepository.purge(updatedResource as Resource, forcePurge = true) + } + if (createLocalChangeEntitiesAfterPurge) { + defaultRepository.addOrUpdate(resource = updatedResource as Resource) + } else { + defaultRepository.createRemote(resource = arrayOf(updatedResource as Resource)) + } + } + } + + fun taskServiceStatusExist(tasks: List, vararg serviceStatus: String): Boolean { + return tasks.any { + val status = generateTaskServiceStatus(it) + if (status.isNotBlank()) { + ServiceStatus.valueOf(status) in serviceStatus.map { item -> ServiceStatus.valueOf(item) } + } else { + false + } } - return serviceStatus } } companion object { private const val SERVICE = "service" private const val LOCATION_SERVICE = "locationService" + private const val DATE_SERVICE = "dateService" private const val INCLUSIVE_SIX_DIGIT_MINIMUM = 100000 private const val INCLUSIVE_SIX_DIGIT_MAXIMUM = 999999 private const val DEFAULT_REGEX = "(?<=^|,)[\\s,]*(\\w[\\w\\s]*)(?=[\\s,]*$|,)" diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/sync/AppSyncWorker.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/sync/AppSyncWorker.kt index 41a1d588334..e16812a7ac5 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/sync/AppSyncWorker.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/sync/AppSyncWorker.kt @@ -24,9 +24,12 @@ import com.google.android.fhir.sync.AcceptLocalConflictResolver import com.google.android.fhir.sync.ConflictResolver import com.google.android.fhir.sync.DownloadWorkManager import com.google.android.fhir.sync.FhirSyncWorker +import com.google.android.fhir.sync.upload.HttpCreateMethod +import com.google.android.fhir.sync.upload.HttpUpdateMethod import com.google.android.fhir.sync.upload.UploadStrategy import dagger.assisted.Assisted import dagger.assisted.AssistedInject +import kotlinx.coroutines.runBlocking @HiltWorker class AppSyncWorker @@ -43,11 +46,17 @@ constructor( override fun getDownloadWorkManager(): DownloadWorkManager = OpenSrpDownloadManager( - syncParams = syncListenerManager.loadSyncParams(), + resourceSearchParams = runBlocking { syncListenerManager.loadResourceSearchParams() }, context = appTimeStampContext, ) override fun getFhirEngine(): FhirEngine = openSrpFhirEngine - override fun getUploadStrategy(): UploadStrategy = UploadStrategy.AllChangesSquashedBundlePut + override fun getUploadStrategy(): UploadStrategy = + UploadStrategy.forBundleRequest( + methodForCreate = HttpCreateMethod.PUT, + methodForUpdate = HttpUpdateMethod.PATCH, + squash = true, + bundleSize = 500, + ) } diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/sync/CustomSyncWorker.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/sync/CustomSyncWorker.kt new file mode 100644 index 00000000000..00c66ddce49 --- /dev/null +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/sync/CustomSyncWorker.kt @@ -0,0 +1,83 @@ +/* + * Copyright 2021-2024 Ona Systems, Inc + * + * 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 org.smartregister.fhircore.engine.sync + +import android.content.Context +import androidx.hilt.work.HiltWorker +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import com.google.android.fhir.sync.concatParams +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import java.net.UnknownHostException +import kotlinx.coroutines.withContext +import org.smartregister.fhircore.engine.configuration.ConfigurationRegistry +import org.smartregister.fhircore.engine.data.remote.fhir.resource.FhirResourceDataSource +import org.smartregister.fhircore.engine.util.DispatcherProvider +import retrofit2.HttpException +import retrofit2.Response +import timber.log.Timber + +@HiltWorker +class CustomSyncWorker +@AssistedInject +constructor( + @Assisted appContext: Context, + @Assisted workerParams: WorkerParameters, + val configurationRegistry: ConfigurationRegistry, + val dispatcherProvider: DispatcherProvider, + val fhirResourceDataSource: FhirResourceDataSource, +) : CoroutineWorker(appContext, workerParams) { + override suspend fun doWork(): Result { + return withContext(dispatcherProvider.io()) { + try { + with(configurationRegistry) { + val (resourceSearchParams, _) = loadResourceSearchParams() + Timber.i("Custom resource sync parameters $resourceSearchParams") + resourceSearchParams + .asSequence() + .filter { it.value.isNotEmpty() } + .map { "${it.key}?${it.value.concatParams()}" } + .forEach { url -> + fetchResources( + gatewayModeHeaderValue = ConfigurationRegistry.FHIR_GATEWAY_MODE_HEADER_VALUE, + url = url, + ) + } + } + Result.success() + } catch (httpException: HttpException) { + Timber.e(httpException) + val response: Response<*>? = httpException.response() + if (response != null && (400..503).contains(response.code())) { + Timber.e("HTTP exception ${response.code()} -> ${response.errorBody()}") + } + Result.failure() + } catch (unknownHostException: UnknownHostException) { + Timber.e(unknownHostException) + Result.failure() + } catch (exception: Exception) { + Timber.e(exception) + Result.failure() + } + } + } + + companion object { + const val WORK_ID = "CustomResourceSyncWorker" + } +} diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/sync/OpenSrpDownloadManager.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/sync/OpenSrpDownloadManager.kt index 279fb046d0d..b94a78381b2 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/sync/OpenSrpDownloadManager.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/sync/OpenSrpDownloadManager.kt @@ -25,11 +25,12 @@ import org.hl7.fhir.r4.model.ResourceType import org.smartregister.fhircore.engine.util.extension.updateLastUpdated class OpenSrpDownloadManager( - syncParams: ResourceSearchParams, + resourceSearchParams: ResourceSearchParams, val context: ResourceParamsBasedDownloadWorkManager.TimestampContext, ) : DownloadWorkManager { - private val downloadWorkManager = ResourceParamsBasedDownloadWorkManager(syncParams, context) + private val downloadWorkManager = + ResourceParamsBasedDownloadWorkManager(resourceSearchParams, context) override suspend fun getNextRequest(): DownloadRequest? = downloadWorkManager.getNextRequest() diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/sync/SyncBroadcaster.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/sync/SyncBroadcaster.kt index b3171e177da..7eb2ab584f9 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/sync/SyncBroadcaster.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/sync/SyncBroadcaster.kt @@ -19,8 +19,11 @@ package org.smartregister.fhircore.engine.sync import android.content.Context import androidx.work.Constraints import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkManager import com.google.android.fhir.FhirEngine import com.google.android.fhir.sync.CurrentSyncJobStatus +import com.google.android.fhir.sync.LastSyncJobStatus import com.google.android.fhir.sync.PeriodicSyncConfiguration import com.google.android.fhir.sync.PeriodicSyncJobStatus import com.google.android.fhir.sync.RepeatInterval @@ -57,6 +60,7 @@ constructor( val fhirEngine: FhirEngine, val syncListenerManager: SyncListenerManager, val dispatcherProvider: DispatcherProvider, + val workManager: WorkManager, @ApplicationContext val context: Context, ) { @@ -64,9 +68,17 @@ constructor( * Run one time sync. The [SyncJobStatus] will be broadcast to all the registered [OnSyncListener] * 's */ - suspend fun runOneTimeSync() = coroutineScope { + suspend fun runOneTimeSync(): Unit = coroutineScope { Timber.i("Running one time sync...") Sync.oneTimeSync(context).handleOneTimeSyncJobStatus(this) + + workManager.enqueue( + OneTimeWorkRequestBuilder() + .setConstraints( + Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build(), + ) + .build(), + ) } /** @@ -93,7 +105,11 @@ constructor( ) { this.onEach { syncListenerManager.onSyncListeners.forEach { onSyncListener -> - onSyncListener.onSync(it.currentSyncJobStatus) + onSyncListener.onSync( + if (it.lastSyncJobStatus != null) { + CurrentSyncJobStatus.Succeeded((it.lastSyncJobStatus as LastSyncJobStatus).timestamp) + } else it.currentSyncJobStatus, + ) } } .catch { throwable -> Timber.e("Encountered an error during periodic sync:", throwable) } diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/sync/SyncListenerManager.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/sync/SyncListenerManager.kt index e2f5e6cdaa8..bc865b01c03 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/sync/SyncListenerManager.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/sync/SyncListenerManager.kt @@ -17,27 +17,19 @@ package org.smartregister.fhircore.engine.sync import android.content.Context -import androidx.compose.ui.state.ToggleableState import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner import com.google.android.fhir.sync.SyncJobStatus +import com.google.android.fhir.sync.download.ResourceSearchParams import dagger.hilt.android.qualifiers.ApplicationContext import java.lang.ref.WeakReference import javax.inject.Inject import javax.inject.Singleton -import kotlinx.coroutines.flow.firstOrNull -import kotlinx.coroutines.runBlocking -import org.hl7.fhir.r4.model.Parameters import org.hl7.fhir.r4.model.ResourceType -import org.hl7.fhir.r4.model.SearchParameter -import org.smartregister.fhircore.engine.configuration.ConfigType import org.smartregister.fhircore.engine.configuration.ConfigurationRegistry -import org.smartregister.fhircore.engine.configuration.app.ApplicationConfiguration import org.smartregister.fhircore.engine.configuration.app.ConfigService -import org.smartregister.fhircore.engine.datastore.syncLocationIdsProtoStore import org.smartregister.fhircore.engine.util.DefaultDispatcherProvider -import org.smartregister.fhircore.engine.util.SharedPreferenceKey import org.smartregister.fhircore.engine.util.SharedPreferencesHelper import timber.log.Timber @@ -55,14 +47,6 @@ constructor( @ApplicationContext val context: Context, val dispatcherProvider: DefaultDispatcherProvider, ) { - private val appConfig by lazy { - configurationRegistry.retrieveConfiguration( - ConfigType.Application, - ) - } - private val syncConfig by lazy { - configurationRegistry.retrieveResourceConfiguration(ConfigType.Sync) - } private val _onSyncListeners = mutableListOf>() val onSyncListeners: List @@ -75,16 +59,18 @@ constructor( * [Lifecycle.State.DESTROYED] */ fun registerSyncListener(onSyncListener: OnSyncListener, lifecycle: Lifecycle) { - _onSyncListeners.add(WeakReference(onSyncListener)) - Timber.w("${onSyncListener::class.simpleName} registered to receive sync state events") - lifecycle.addObserver( - object : DefaultLifecycleObserver { - override fun onStop(owner: LifecycleOwner) { - super.onStop(owner) - deregisterSyncListener(onSyncListener) - } - }, - ) + if (_onSyncListeners.find { it.get() == onSyncListener } == null) { + _onSyncListeners.add(WeakReference(onSyncListener)) + Timber.w("${onSyncListener::class.simpleName} registered to receive sync state events") + lifecycle.addObserver( + object : DefaultLifecycleObserver { + override fun onDestroy(owner: LifecycleOwner) { + super.onDestroy(owner) + deregisterSyncListener(onSyncListener) + } + }, + ) + } } /** @@ -98,96 +84,16 @@ constructor( } } - /** Retrieve registry sync params */ - fun loadSyncParams(): Map> { - val pairs = mutableListOf>>() - - val organizationResourceTag = - configService.defineResourceTags().find { it.type == ResourceType.Organization.name } - - val mandatoryTags = configService.provideResourceTags(sharedPreferencesHelper) - - val relatedResourceTypes: List? = - sharedPreferencesHelper.read(SharedPreferenceKey.REMOTE_SYNC_RESOURCES.name) - - // TODO Does not support nested parameters i.e. parameters.parameters... - // TODO: expressionValue supports for Organization and Publisher literals for now - syncConfig.parameter - .map { it.resource as SearchParameter } - .forEach { sp -> - val paramName = sp.name // e.g. organization - val paramLiteral = "#$paramName" // e.g. #organization in expression for replacement - val paramExpression = sp.expression - val expressionValue = - when (paramName) { - // TODO: Does not support multi organization yet, - // https://github.com/opensrp/fhircore/issues/1550 - ConfigurationRegistry.ORGANIZATION -> - mandatoryTags - .firstOrNull { - it.display.contentEquals(organizationResourceTag?.tag?.display, ignoreCase = true) - } - ?.code - ConfigurationRegistry.ID -> paramExpression - ConfigurationRegistry.COUNT -> appConfig.remoteSyncPageSize.toString() - else -> null - }?.let { - // replace the evaluated value into expression for complex expressions - // e.g. #organization -> 123 - // e.g. patient.organization eq #organization -> patient.organization eq 123 - paramExpression?.replace(paramLiteral, it) - } - - // for each entity in base create and add param map - // [Patient=[ name=Abc, organization=111 ], Encounter=[ type=MyType, location=MyHospital - // ],..] - if (relatedResourceTypes.isNullOrEmpty()) { - sp.base.mapNotNull { it.code } - } else { - relatedResourceTypes - } - .forEach { clinicalResource -> - val resourceType = ResourceType.fromCode(clinicalResource) - val pair = pairs.find { it.first == resourceType } - if (pair == null) { - pairs.add( - Pair( - resourceType, - expressionValue?.let { mutableMapOf(sp.code to expressionValue) } - ?: mutableMapOf(), - ), - ) - } else { - expressionValue?.let { - // add another parameter if there is a matching resource type - // e.g. [(Patient, {organization=105})] to [(Patient, {organization=105, - // _count=100})] - val updatedPair = pair.second.apply { put(sp.code, expressionValue) } - val index = pairs.indexOfFirst { it.first == resourceType } - pairs.set(index, Pair(resourceType, updatedPair)) - } - } - } - } - - // Set sync locations Location query params - runBlocking { - context.syncLocationIdsProtoStore.data - .firstOrNull() - ?.filter { it.toggleableState == ToggleableState.On } - ?.map { it.locationId } - .takeIf { !it.isNullOrEmpty() } - ?.let { locationIds -> - pairs.forEach { it.second[SYNC_LOCATION_IDS] = locationIds.joinToString(",") } - } - } - - Timber.i("SYNC CONFIG $pairs") - - return mapOf(*pairs.toTypedArray()) - } - - companion object { - private const val SYNC_LOCATION_IDS = "_syncLocations" + /** + * This function is used to retrieve search parameters for the various [ResourceType]'s synced by + * the application. The function returns a pair of maps, one contains the the custom Resource + * types and the other returns the supported FHIR [ResourceType]s. The [OpenSrpDownloadManager] + * does not support downloading of custom resource, a separate worker is implemented instead to + * download the custom resources. + */ + suspend fun loadResourceSearchParams(): ResourceSearchParams { + val (_, resourceSearchParams) = configurationRegistry.loadResourceSearchParams() + Timber.i("FHIR resource sync parameters $resourceSearchParams") + return resourceSearchParams } } diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/task/FhirCarePlanGenerator.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/task/FhirCarePlanGenerator.kt index f81fc7b332f..f00e2be3dec 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/task/FhirCarePlanGenerator.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/task/FhirCarePlanGenerator.kt @@ -23,7 +23,6 @@ import ca.uhn.fhir.util.TerserUtil import com.google.android.fhir.FhirEngine import com.google.android.fhir.datacapture.extensions.logicalId import com.google.android.fhir.get -import com.google.android.fhir.search.search import dagger.hilt.android.qualifiers.ApplicationContext import java.util.Date import javax.inject.Inject @@ -60,6 +59,7 @@ import org.smartregister.fhircore.engine.configuration.event.EventType import org.smartregister.fhircore.engine.data.local.DefaultRepository import org.smartregister.fhircore.engine.util.extension.addResourceParameter import org.smartregister.fhircore.engine.util.extension.asReference +import org.smartregister.fhircore.engine.util.extension.batchedSearch import org.smartregister.fhircore.engine.util.extension.encodeResourceToString import org.smartregister.fhircore.engine.util.extension.extractFhirpathDuration import org.smartregister.fhircore.engine.util.extension.extractFhirpathPeriod @@ -83,7 +83,10 @@ constructor( @ApplicationContext val context: Context, ) { private val structureMapUtilities by lazy { - StructureMapUtilities(transformSupportServices.simpleWorkerContext, transformSupportServices) + StructureMapUtilities( + transformSupportServices.simpleWorkerContext, + transformSupportServices, + ) } suspend fun generateOrUpdateCarePlan( @@ -117,8 +120,11 @@ constructor( // Only one CarePlan per plan, update or init a new one if not exists val output = fhirEngine - .search { - filter(CarePlan.INSTANTIATES_CANONICAL, { value = planDefinition.referenceValue() }) + .batchedSearch { + filter( + CarePlan.INSTANTIATES_CANONICAL, + { value = planDefinition.referenceValue() }, + ) filter(CarePlan.SUBJECT, { value = subject.referenceValue() }) filter( CarePlan.STATUS, @@ -131,8 +137,7 @@ constructor( .map { it.resource } .firstOrNull() ?: CarePlan().apply { - // TODO delete this section once all PlanDefinitions are using new - // recommended approach + // TODO delete this section once all PlanDefinitions are using new recommended approach this.title = planDefinition.title this.description = planDefinition.description this.instantiatesCanonical = listOf(CanonicalType(planDefinition.asReference().reference)) @@ -155,6 +160,8 @@ constructor( val carePlanTasks = output.contained.filterIsInstance() + output.cleanPlanDefinitionCanonical() + if (carePlanModified) saveCarePlan(output, relatedEntityLocationTags) if (carePlanTasks.isNotEmpty()) { @@ -167,6 +174,19 @@ constructor( return if (output.hasActivity()) output else null } + // TODO refactor this code to remove hardcoded appended "PlanDefinition/" on + // https://github.com/opensrp/fhircore/issues/3386 + private fun CarePlan.cleanPlanDefinitionCanonical() { + val canonicalValue = this.instantiatesCanonical.first().value + if (canonicalValue.contains('/').not()) { + this.instantiatesCanonical = listOf(CanonicalType("PlanDefinition/$canonicalValue")) + } + } + + @VisibleForTesting + fun invokeCleanPlanDefinitionCanonical(carePlan: CarePlan) = + carePlan.cleanPlanDefinitionCanonical() + /** Implements OpenSRP's $lite version of CarePlan & Tasks generation via StructureMap(s) */ private suspend fun liteApplyPlanDefinitionOnPatient( planDefinition: PlanDefinition, @@ -194,8 +214,9 @@ constructor( } source.setParameter(Task.SP_PERIOD, period) source.setParameter(ActivityDefinition.SP_VERSION, IntegerType(index)) + val structureMapId = IdType(action.transform).idPart + val structureMap = defaultRepository.loadResourceFromCache(structureMapId) - val structureMap = fhirEngine.get(IdType(action.transform).idPart) structureMapUtilities.transform( transformSupportServices.simpleWorkerContext, source, @@ -209,7 +230,15 @@ constructor( definition.dynamicValue.forEach { dynamicValue -> if (definition.kind == ActivityDefinition.ActivityDefinitionKind.CAREPLAN) { dynamicValue.expression.expression - .let { fhirPathEngine.evaluate(null, input, planDefinition, subject, it) } + .let { + fhirPathEngine.evaluate( + null, + input, + planDefinition, + subject, + it, + ) + } ?.takeIf { it.isNotEmpty() } ?.let { evaluatedValue -> // TODO handle cases where we explicitly need to set previous value as null, @@ -255,8 +284,17 @@ constructor( .filter { it.reference.startsWith(ResourceType.Task.name) } .mapNotNull { getTask(it.extractId()) } .forEach { - if (it.status.isIn(TaskStatus.REQUESTED, TaskStatus.READY, TaskStatus.INPROGRESS)) { - cancelTaskByTaskId(it.logicalId, "${carePlan.fhirType()} ${carePlan.status}") + if ( + it.status.isIn( + TaskStatus.REQUESTED, + TaskStatus.READY, + TaskStatus.INPROGRESS, + ) + ) { + cancelTaskByTaskId( + it.logicalId, + "${carePlan.fhirType()} ${carePlan.status}", + ) } } } @@ -359,7 +397,9 @@ constructor( end = if (durationExpression.isNotBlank() && offsetDate.hasValue()) { evaluateToDate(offsetDate, "\$this + $durationExpression")?.value - } else carePlan.period.end + } else { + carePlan.period.end + } } .also { taskPeriods.add(it) } } @@ -367,7 +407,10 @@ constructor( return taskPeriods } - private fun extractTaskPeriodsFromDosage(dosage: List, carePlan: CarePlan): List { + private fun extractTaskPeriodsFromDosage( + dosage: List, + carePlan: CarePlan, + ): List { val taskPeriods = mutableListOf() dosage .flatMap { extractTaskPeriodsFromTiming(it.timing, carePlan) } @@ -406,7 +449,11 @@ constructor( ) if (resourceClosureConditionsMet) { - defaultRepository.updateResourcesRecursively(eventResource, subject, eventWorkFlow) + defaultRepository.updateResourcesRecursively( + eventResource, + subject, + eventWorkFlow, + ) } } } diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/task/FhirCompleteCarePlanWorker.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/task/FhirCompleteCarePlanWorker.kt index 0ff00e5ced1..050173774b7 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/task/FhirCompleteCarePlanWorker.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/task/FhirCompleteCarePlanWorker.kt @@ -20,7 +20,6 @@ import android.content.Context import androidx.hilt.work.HiltWorker import androidx.work.CoroutineWorker import androidx.work.WorkerParameters -import com.google.android.fhir.search.search import dagger.assisted.Assisted import dagger.assisted.AssistedInject import kotlinx.coroutines.withContext @@ -33,6 +32,7 @@ import org.smartregister.fhircore.engine.configuration.app.ApplicationConfigurat import org.smartregister.fhircore.engine.data.local.DefaultRepository import org.smartregister.fhircore.engine.util.DispatcherProvider import org.smartregister.fhircore.engine.util.SharedPreferencesHelper +import org.smartregister.fhircore.engine.util.extension.batchedSearch import org.smartregister.fhircore.engine.util.extension.extractId import org.smartregister.fhircore.engine.util.extension.lastOffset import org.smartregister.fhircore.engine.util.getLastOffset @@ -94,7 +94,7 @@ constructor( suspend fun getCarePlans(batchSize: Int, lastOffset: Int) = defaultRepository.fhirEngine - .search { + .batchedSearch { filter( CarePlan.STATUS, { value = of(CarePlan.CarePlanStatus.DRAFT.toCode()) }, diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/task/FhirResourceExpireWorker.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/task/FhirResourceExpireWorker.kt index 43cebe57022..50c24a55825 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/task/FhirResourceExpireWorker.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/task/FhirResourceExpireWorker.kt @@ -40,8 +40,11 @@ constructor( override suspend fun doWork(): Result { return withContext(dispatcherProvider.io()) { - fhirResourceUtil.expireOverdueTasks() - fhirResourceUtil.closeResourcesRelatedToCompletedServiceRequests() + fhirResourceUtil.run { + expireOverdueTasks() + closeResourcesRelatedToCompletedServiceRequests() + closeFhirResources() + } Result.success() } } diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/task/FhirResourceUtil.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/task/FhirResourceUtil.kt index 57228e3153a..fafba124987 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/task/FhirResourceUtil.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/task/FhirResourceUtil.kt @@ -21,7 +21,6 @@ import ca.uhn.fhir.rest.param.ParamPrefixEnum import com.google.android.fhir.datacapture.extensions.logicalId import com.google.android.fhir.get import com.google.android.fhir.search.filter.TokenParamFilterCriterion -import com.google.android.fhir.search.search import dagger.hilt.android.qualifiers.ApplicationContext import java.util.Date import javax.inject.Inject @@ -39,6 +38,7 @@ import org.smartregister.fhircore.engine.configuration.ConfigurationRegistry import org.smartregister.fhircore.engine.configuration.app.ApplicationConfiguration import org.smartregister.fhircore.engine.configuration.event.EventType import org.smartregister.fhircore.engine.data.local.DefaultRepository +import org.smartregister.fhircore.engine.util.extension.batchedSearch import org.smartregister.fhircore.engine.util.extension.executionStartIsBeforeOrToday import org.smartregister.fhircore.engine.util.extension.expiredConcept import org.smartregister.fhircore.engine.util.extension.extractId @@ -66,7 +66,7 @@ constructor( Timber.i("Fetch and expire overdue tasks") val tasksResult = fhirEngine - .search { + .batchedSearch { filter( Task.STATUS, { value = of(TaskStatus.REQUESTED.toCoding()) }, @@ -148,7 +148,7 @@ constructor( val tasks = defaultRepository.fhirEngine - .search { + .batchedSearch { filter( Task.STATUS, { value = of(TaskStatus.REQUESTED.toCoding()) }, @@ -235,7 +235,7 @@ constructor( suspend fun closeResourcesRelatedToCompletedServiceRequests() { Timber.i("Fetch completed service requests and close related resources") defaultRepository.fhirEngine - .search { + .batchedSearch { filter( ServiceRequest.STATUS, { value = of(ServiceRequest.ServiceRequestStatus.COMPLETED.toCode()) }, @@ -248,4 +248,22 @@ constructor( closeRelatedResources(serviceRequest) } } + + suspend fun closeFhirResources() { + val appRegistry = + configurationRegistry.retrieveConfiguration( + ConfigType.Application, + ) + + appRegistry.eventWorkflows + .filter { it.eventType == EventType.RESOURCE_CLOSURE } + .forEach { eventWorkFlow -> + eventWorkFlow.eventResources.forEach { eventResource -> + defaultRepository.updateResourcesRecursively( + resourceConfig = eventResource, + eventWorkflow = eventWorkFlow, + ) + } + } + } } diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/base/AlertDialogue.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/base/AlertDialogue.kt index 677cf7b786d..bee9d7febde 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/base/AlertDialogue.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/base/AlertDialogue.kt @@ -55,6 +55,8 @@ object AlertDialogue { @StringRes confirmButtonText: Int = R.string.questionnaire_alert_confirm_button_title, neutralButtonListener: ((d: DialogInterface) -> Unit)? = null, @StringRes neutralButtonText: Int = R.string.questionnaire_alert_neutral_button_title, + negativeButtonListener: ((d: DialogInterface) -> Unit)? = null, + @StringRes negativeButtonText: Int = R.string.questionnaire_alert_negative_button_title, cancellable: Boolean = false, options: Array? = null, ): AlertDialog { @@ -71,6 +73,9 @@ object AlertDialogue { confirmButtonListener?.let { setPositiveButton(confirmButtonText) { d, _ -> confirmButtonListener.invoke(d) } } + negativeButtonListener?.let { + setNegativeButton(negativeButtonText) { d, _ -> negativeButtonListener.invoke(d) } + } options?.run { setSingleChoiceItems(options.map { it.value }.toTypedArray(), -1, null) } } .show() @@ -81,7 +86,9 @@ object AlertDialogue { dialog.findViewById(R.id.pr_circular)?.apply { if (alertIntent == AlertIntent.PROGRESS) { this.show() - } else this.hide() + } else { + this.hide() + } } dialog.findViewById(R.id.tv_alert_message)?.apply { this.text = message } @@ -170,6 +177,8 @@ object AlertDialogue { @StringRes confirmButtonText: Int, neutralButtonListener: ((d: DialogInterface) -> Unit), @StringRes neutralButtonText: Int, + negativeButtonListener: ((d: DialogInterface) -> Unit), + @StringRes negativeButtonText: Int, cancellable: Boolean = true, options: List? = null, ): AlertDialog { @@ -182,6 +191,8 @@ object AlertDialogue { confirmButtonText = confirmButtonText, neutralButtonListener = neutralButtonListener, neutralButtonText = neutralButtonText, + negativeButtonListener = negativeButtonListener, + negativeButtonText = negativeButtonText, cancellable = cancellable, options = options?.toTypedArray(), ) diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/components/Pin.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/components/Pin.kt index 5487b9d92ef..7b6eb5619c5 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/components/Pin.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/components/Pin.kt @@ -114,7 +114,9 @@ fun PinInput( enteredPin = if (it.length < enteredPin.size) { enteredPin.safeRemoveLast() - } else enteredPin.safePlus(it.last()) + } else { + enteredPin.safePlus(it.last()) + } nextCellIndex = enteredPin.size onPinSet(enteredPin) onShowPinError(false) diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/components/register/RegisterFooter.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/components/register/RegisterFooter.kt index e6dc80a54a2..fdddeae1ea6 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/components/register/RegisterFooter.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/components/register/RegisterFooter.kt @@ -34,7 +34,6 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import org.smartregister.fhircore.engine.R -import org.smartregister.fhircore.engine.configuration.navigation.NavigationMenuConfig import org.smartregister.fhircore.engine.ui.theme.GreyTextColor import org.smartregister.fhircore.engine.util.annotation.PreviewWithBackgroundExcludeGenerated @@ -43,8 +42,6 @@ const val SEARCH_FOOTER_TAG = "searchFooterTag" const val SEARCH_FOOTER_PREVIOUS_BUTTON_TAG = "searchFooterPreviousButtonTag" const val SEARCH_FOOTER_NEXT_BUTTON_TAG = "searchFooterNextButtonTag" const val SEARCH_FOOTER_PAGINATION_TAG = "searchFooterPaginationTag" -const val PADDING_BOTTOM_WITH_FAB = 80 -const val PADDING_BOTTOM_WITHOUT_FAB = 32 @Composable fun RegisterFooter( @@ -54,21 +51,9 @@ fun RegisterFooter( previousButtonClickListener: () -> Unit, nextButtonClickListener: () -> Unit, modifier: Modifier = Modifier, - fabActions: List? = null, ) { if (resultCount > 0) { - Row( - modifier = - modifier - .fillMaxWidth() - .testTag(SEARCH_FOOTER_TAG) - .padding( - bottom = - if (!fabActions.isNullOrEmpty() && fabActions.first().visible) { - PADDING_BOTTOM_WITH_FAB.dp - } else PADDING_BOTTOM_WITHOUT_FAB.dp, - ), - ) { + Row(modifier = modifier.fillMaxWidth().testTag(SEARCH_FOOTER_TAG)) { Box( modifier = modifier.weight(1f).padding(4.dp).wrapContentWidth(Alignment.Start), ) { diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/multiselect/MultiSelectView.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/multiselect/MultiSelectView.kt index 81e99ee8c75..0f5bc9a66f2 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/multiselect/MultiSelectView.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/multiselect/MultiSelectView.kt @@ -39,42 +39,46 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.state.ToggleableState import androidx.compose.ui.unit.dp -import java.util.LinkedList +import org.smartregister.fhircore.engine.domain.model.SyncLocationState @Composable fun ColumnScope.MultiSelectView( rootTreeNode: TreeNode, - selectedNodes: MutableMap, + syncLocationStateMap: MutableMap, depth: Int = 0, + onChecked: () -> Unit, content: @Composable (TreeNode) -> Unit, ) { val collapsedState = remember { mutableStateOf(false) } MultiSelectCheckbox( - selectedNodes = selectedNodes, + syncLocationStateMap = syncLocationStateMap, currentTreeNode = rootTreeNode, depth = depth, content = content, collapsedState = collapsedState, + onChecked = onChecked, ) if (collapsedState.value) { rootTreeNode.children.forEach { MultiSelectView( rootTreeNode = it, - selectedNodes = selectedNodes, + syncLocationStateMap = syncLocationStateMap, depth = depth + 16, content = content, + onChecked = onChecked, ) } } } @Composable -fun MultiSelectCheckbox( - selectedNodes: MutableMap, +private fun MultiSelectCheckbox( + syncLocationStateMap: MutableMap, currentTreeNode: TreeNode, depth: Int, content: @Composable (TreeNode) -> Unit, collapsedState: MutableState, + onChecked: () -> Unit, ) { val checked = remember { mutableStateOf(false) } Column { @@ -87,7 +91,9 @@ fun MultiSelectCheckbox( imageVector = if (collapsedState.value) { Icons.Default.ArrowDropDown - } else Icons.AutoMirrored.Filled.ArrowRight, + } else { + Icons.AutoMirrored.Filled.ArrowRight + }, contentDescription = null, tint = Color.Gray, modifier = Modifier.clickable { collapsedState.value = !collapsedState.value }, @@ -95,11 +101,18 @@ fun MultiSelectCheckbox( } TriStateCheckbox( - state = selectedNodes[currentTreeNode.id] ?: ToggleableState.Off, + state = syncLocationStateMap[currentTreeNode.id]?.toggleableState ?: ToggleableState.Off, onClick = { - selectedNodes[currentTreeNode.id] = - ToggleableState(selectedNodes[currentTreeNode.id] != ToggleableState.On) - checked.value = selectedNodes[currentTreeNode.id] == ToggleableState.On + syncLocationStateMap[currentTreeNode.id] = + SyncLocationState( + currentTreeNode.id, + currentTreeNode.parent?.id, + ToggleableState( + syncLocationStateMap[currentTreeNode.id]?.toggleableState != ToggleableState.On, + ), + ) + checked.value = + syncLocationStateMap[currentTreeNode.id]?.toggleableState == ToggleableState.On var toggleableState: ToggleableState var parent = currentTreeNode.parent @@ -107,26 +120,30 @@ fun MultiSelectCheckbox( toggleableState = ToggleableState.Indeterminate if ( parent.children.all { - selectedNodes[it.id] == ToggleableState.Off || selectedNodes[it.id] == null + syncLocationStateMap[it.id]?.toggleableState == ToggleableState.Off || + syncLocationStateMap[it.id] == null } ) { toggleableState = ToggleableState.Off } - if (parent.children.all { selectedNodes[it.id] == ToggleableState.On }) { + if ( + parent.children.all { + syncLocationStateMap[it.id]?.toggleableState == ToggleableState.On + } + ) { toggleableState = ToggleableState.On } - selectedNodes[parent.id] = toggleableState + syncLocationStateMap[parent.id] = + SyncLocationState(parent.id, parent.parent?.id, toggleableState) parent = parent.parent } - // Select all the nested checkboxes - val linkedList = LinkedList(currentTreeNode.children) - - while (linkedList.isNotEmpty()) { - val currentNode = linkedList.removeFirst() - selectedNodes[currentNode.id] = ToggleableState(checked.value) - currentNode.children.forEach { linkedList.add(it) } - } + updateNestedCheckboxState( + currentTreeNode = currentTreeNode, + syncLocationStateMap = syncLocationStateMap, + checked = checked.value, + ) + onChecked() }, modifier = Modifier.padding(0.dp), colors = CheckboxDefaults.colors(checkedColor = MaterialTheme.colors.primary), @@ -136,3 +153,26 @@ fun MultiSelectCheckbox( } } } + +/** + * This function selects/deselects all the children for the [currentTreeNode] based on the value for + * the [checked] parameter. The states for the [MultiSelectCheckbox] is updated in the + * [syncLocationStateMap]. + */ +fun updateNestedCheckboxState( + currentTreeNode: TreeNode, + syncLocationStateMap: MutableMap, + checked: Boolean, +) { + val treeNodeArrayDeque = ArrayDeque(currentTreeNode.children) + while (treeNodeArrayDeque.isNotEmpty()) { + val currentNode = treeNodeArrayDeque.removeFirst() + syncLocationStateMap[currentNode.id] = + SyncLocationState( + locationId = currentNode.id, + parentLocationId = currentNode.parent?.id, + toggleableState = ToggleableState(checked), + ) + currentNode.children.forEach { treeNodeArrayDeque.addLast(it) } + } +} diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/theme/Colors.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/theme/Colors.kt index 48d88a21550..b28f78391f2 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/theme/Colors.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/theme/Colors.kt @@ -35,16 +35,16 @@ val LighterBlue = Color(0xFFE0F0FF) val ProgressBarBlueColor = Color(0xFF0075EB) val SideMenuDarkColor = Color(0xFF2C2C2C) val SideMenuTopItemDarkColor = Color(0xFF242424) -val SideMenuBottomItemDarkColor = Color(0xFF404040) +val SideMenuBottomItemDarkColor = Color(0xFF424242) +val SyncBarBackgroundColor = Color(0xFF002B4A) val AppTitleColor = Color(0xFF929496) val StatusTextColor = Color(0xFF6F7274) val PersonalDataBackgroundColor = Color(0xFFF5F5F5) val MenuActionButtonTextColor = Color(0xFF28B8F9) -val MenuItemColor = Color(0xFFBFBFBF) +val MenuItemColor = Color(0xFF929496) val SearchHeaderColor = Color(0xFFF2F4F7) private val PrimaryColor = Color(0xFF0077CC) private val SecondaryColor = Color(0xFFF8DF4B) -private val SurfaceColor = Color(0xFFFFFFFF) private val PrimaryVariantColor = Color(0xFF006BBA) val LightColors = diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/util/FhirDataTypesUtil.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/FhirDataTypesUtil.kt new file mode 100644 index 00000000000..a0a8e29be8b --- /dev/null +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/FhirDataTypesUtil.kt @@ -0,0 +1,63 @@ +/* + * Copyright 2021-2024 Ona Systems, Inc + * + * 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 org.smartregister.fhircore.engine.util + +import org.hl7.fhir.r4.formats.JsonParser +import org.hl7.fhir.r4.model.BooleanType +import org.hl7.fhir.r4.model.DateTimeType +import org.hl7.fhir.r4.model.DateType +import org.hl7.fhir.r4.model.DecimalType +import org.hl7.fhir.r4.model.Enumerations.DataType +import org.hl7.fhir.r4.model.IntegerType +import org.hl7.fhir.r4.model.StringType +import org.hl7.fhir.r4.model.TimeType +import org.hl7.fhir.r4.model.Type +import org.hl7.fhir.r4.model.UriType +import org.hl7.fhir.r4.utils.TypesUtilities +import timber.log.Timber + +private val fhirTypesJsonParser: JsonParser = JsonParser() + +private fun String.castToFhirPrimitiveType(type: DataType): Type? = + when (type) { + DataType.BOOLEAN -> BooleanType(this) + DataType.DECIMAL -> DecimalType(this) + DataType.INTEGER -> IntegerType(this) + DataType.DATE -> DateType(this) + DataType.DATETIME -> DateTimeType(this) + DataType.TIME -> TimeType(this) + DataType.STRING -> StringType(this) + DataType.URI -> UriType(this) + else -> null + } + +private fun String.castToFhirDataType(type: DataType): Type? = + try { + fhirTypesJsonParser.parseType(this, type.toCode()) + } catch (ex: Exception) { + Timber.e("Error casting \"$this\" to FHIR type \"${type.toCode()}\"") + throw ex + } + +/** Cast string value (including json string) to the FHIR {@link org.hl7.fhir.r4.model.Type} */ +fun String.castToType(type: DataType): Type? { + return if (TypesUtilities.isPrimitive(type.toCode())) { + castToFhirPrimitiveType(type) + } else { + castToFhirDataType(type) + } +} diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/util/KnowledgeManagerUtil.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/KnowledgeManagerUtil.kt new file mode 100644 index 00000000000..95697941d33 --- /dev/null +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/KnowledgeManagerUtil.kt @@ -0,0 +1,70 @@ +/* + * Copyright 2021-2024 Ona Systems, Inc + * + * 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 org.smartregister.fhircore.engine.util + +import android.content.Context +import ca.uhn.fhir.context.FhirContext +import java.io.File +import org.hl7.fhir.r4.model.MetadataResource +import org.smartregister.fhircore.engine.configuration.app.AuthConfiguration +import org.smartregister.fhircore.engine.configuration.app.ConfigService +import org.smartregister.fhircore.engine.util.extension.referenceValue + +object KnowledgeManagerUtil { + const val KNOWLEDGE_MANAGER_ASSETS_SUBFOLDER = "km" + private val fhirContext = FhirContext.forR4Cached() + + /** + * Util method that creates a physical file and writes the Metadata FHIR resource content to it. + * Note the filepath provided is appended to the apps private directory as returned by + * Context.filesDir + * + * @param subFilePath the path of the file but within the apps private directory + * {Context.filesDir} + * @param metadataResource the actual FHIR Resource of type MetadataResource + * @param configService the configuration service + * @param context the application context + * @return File the file object after creating and writing + */ + fun writeToFile( + subFilePath: String, + metadataResource: MetadataResource, + configService: ConfigService, + context: Context, + ): File = + context + .createFileInPrivateDirectory(subFilePath) + .also { it.parentFile?.mkdirs() } + .apply { + writeText( + fhirContext + .newJsonParser() + .encodeResourceToString( + metadataResource.overwriteCanonicalURL(configService.provideAuthConfiguration()), + ), + ) + } + + private fun Context.createFileInPrivateDirectory(filePath: String) = File(this.filesDir, filePath) + + private fun MetadataResource.overwriteCanonicalURL(authConfiguration: AuthConfiguration) = + this.apply { + url = + url + ?: """${authConfiguration.fhirServerBaseUrl.trimEnd { it == '/' }}/${this.referenceValue()}""" + } +} diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/util/SecureSharedPreference.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/SecureSharedPreference.kt index 73658b4fdf9..9fcdc526e8e 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/util/SecureSharedPreference.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/SecureSharedPreference.kt @@ -17,6 +17,7 @@ package org.smartregister.fhircore.engine.util import android.content.Context +import android.content.SharedPreferences import androidx.core.content.edit import androidx.security.crypto.EncryptedSharedPreferences import androidx.security.crypto.MasterKey @@ -24,6 +25,9 @@ import dagger.hilt.android.qualifiers.ApplicationContext import java.util.Base64 import javax.inject.Inject import javax.inject.Singleton +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope import org.jetbrains.annotations.VisibleForTesting import org.smartregister.fhircore.engine.auth.AuthCredentials import org.smartregister.fhircore.engine.util.extension.decodeJson @@ -31,8 +35,20 @@ import org.smartregister.fhircore.engine.util.extension.encodeJson @Singleton class SecureSharedPreference @Inject constructor(@ApplicationContext val context: Context) { + private val secureSharedPreferences: SharedPreferences by lazy { + initEncryptedSharedPreferences() + } + + @VisibleForTesting + fun initEncryptedSharedPreferences() = + runCatching { createEncryptedSharedPreferences() } + .getOrElse { + resetSharedPrefs() + createEncryptedSharedPreferences() + } - private val secureSharedPreferences = + @VisibleForTesting + fun createEncryptedSharedPreferences() = EncryptedSharedPreferences.create( context, SECURE_STORAGE_FILE_NAME, @@ -71,15 +87,21 @@ class SecureSharedPreference @Inject constructor(@ApplicationContext val context .getString(SharedPreferenceKey.LOGIN_CREDENTIAL_KEY.name, null) ?.decodeJson() - fun saveSessionPin(pin: CharArray) { + suspend fun saveSessionPin(pin: CharArray, onSavedPin: () -> Unit) { val randomSaltBytes = get256RandomBytes() secureSharedPreferences.edit { putString( SharedPreferenceKey.LOGIN_PIN_SALT.name, Base64.getEncoder().encodeToString(randomSaltBytes), ) - putString(SharedPreferenceKey.LOGIN_PIN_KEY.name, pin.toPasswordHash(randomSaltBytes)) + putString( + SharedPreferenceKey.LOGIN_PIN_KEY.name, + coroutineScope { + async(Dispatchers.Default) { pin.toPasswordHash(randomSaltBytes) }.await() + }, + ) } + onSavedPin() } @VisibleForTesting fun get256RandomBytes() = 256.getRandomBytesOfSize() diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/util/SecurityUtil.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/SecurityUtil.kt index 1e29b2f254e..a183046eb61 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/util/SecurityUtil.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/SecurityUtil.kt @@ -16,7 +16,6 @@ package org.smartregister.fhircore.engine.util -import android.os.Build import java.nio.charset.StandardCharsets import java.security.SecureRandom import java.util.Arrays @@ -28,22 +27,14 @@ fun CharArray.toPasswordHash(salt: ByteArray) = passwordHashString(this, salt) @VisibleForTesting fun passwordHashString(password: CharArray, salt: ByteArray): String { - val pbKeySpec = PBEKeySpec(password, salt, 800000, 256) - val secretKeyFactory = - SecretKeyFactory.getInstance( - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - "PBKDF2withHmacSHA256" - } else { - "PBKDF2WithHmacSHA1" - }, - ) + val pbKeySpec = PBEKeySpec(password, salt, 210000, 512) + val secretKeyFactory = SecretKeyFactory.getInstance("PBKDF2withHmacSHA512") return secretKeyFactory.generateSecret(pbKeySpec).encoded.toString(StandardCharsets.UTF_8) } fun Int.getRandomBytesOfSize(): ByteArray { - val random = SecureRandom() val randomSaltBytes = ByteArray(this) - random.nextBytes(randomSaltBytes) + SecureRandom().nextBytes(randomSaltBytes) return randomSaltBytes } diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/util/SharedPreferenceKey.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/SharedPreferenceKey.kt index e9277ec5b83..9b5b5e04323 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/util/SharedPreferenceKey.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/SharedPreferenceKey.kt @@ -25,7 +25,6 @@ enum class SharedPreferenceKey { PRACTITIONER_LOCATION_HIERARCHIES, PRACTITIONER_LOCATION, PRACTITIONER_LOCATION_ID, - REMOTE_SYNC_RESOURCES, LOGIN_CREDENTIAL_KEY, LOGIN_PIN_KEY, LOGIN_PIN_SALT, diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/AndroidExtensions.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/AndroidExtensions.kt index c25acc4f0d7..6b7156ae967 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/AndroidExtensions.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/AndroidExtensions.kt @@ -32,12 +32,20 @@ import android.os.Parcelable import android.widget.Toast import androidx.appcompat.app.AppCompatActivity import androidx.compose.ui.graphics.Color as ComposeColor +import androidx.compose.ui.state.ToggleableState import androidx.core.os.bundleOf import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.updatePadding import java.io.Serializable import java.util.Locale +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.withContext +import org.smartregister.fhircore.engine.datastore.dataFilterLocationIdsProtoStore +import org.smartregister.fhircore.engine.datastore.syncLocationIdsProtoStore +import org.smartregister.fhircore.engine.domain.model.MultiSelectViewAction +import org.smartregister.fhircore.engine.domain.model.SyncLocationState import org.smartregister.fhircore.engine.ui.theme.DangerColor import org.smartregister.fhircore.engine.ui.theme.DefaultColor import org.smartregister.fhircore.engine.ui.theme.InfoColor @@ -204,13 +212,6 @@ inline fun Bundle.parcelable(key: String): T? = else -> @Suppress("DEPRECATION") getParcelable(key) as? T } -@ExcludeFromJacocoGeneratedReport -inline fun Bundle.parcelableArrayList(key: String): ArrayList? = - when { - SDK_INT >= 33 -> getParcelableArrayList(key, T::class.java) - else -> @Suppress("DEPRECATION") getParcelableArrayList(key) - } - @ExcludeFromJacocoGeneratedReport inline fun Intent.serializable(key: String): T? = when { @@ -224,3 +225,26 @@ inline fun Intent.parcelableArrayList(key: String): Arr SDK_INT >= 33 -> getParcelableArrayListExtra(key, T::class.java) else -> @Suppress("DEPRECATION") getParcelableArrayListExtra(key) } + +suspend fun Context.retrieveRelatedEntitySyncLocationState( + multiSelectViewAction: MultiSelectViewAction, + filterToggleableStateOn: Boolean = true, +): List { + val selectedLocationStateMap = + withContext(Dispatchers.IO) { + val context = this@retrieveRelatedEntitySyncLocationState + when (multiSelectViewAction) { + MultiSelectViewAction.SYNC_DATA -> context.syncLocationIdsProtoStore.data.firstOrNull() + MultiSelectViewAction.FILTER_DATA -> + context.dataFilterLocationIdsProtoStore.data.firstOrNull() + } + } + return if (filterToggleableStateOn) { + selectedLocationStateMap?.values?.filter { + it.toggleableState == ToggleableState.On && + selectedLocationStateMap[it.parentLocationId]?.toggleableState != ToggleableState.On + } + } else { + selectedLocationStateMap?.values?.toList() + } ?: emptyList() +} diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/BitmapExtension.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/BitmapExtension.kt index af488ee20ae..579f49c9019 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/BitmapExtension.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/BitmapExtension.kt @@ -27,6 +27,6 @@ fun Bitmap.encodeToByteArray(): ByteArray { } } -fun ByteArray.decodeToBitmap(offset: Int = 0): Bitmap { - return BitmapFactory.decodeByteArray(this, offset, this.size) +fun ByteArray.decodeToBitmap(offset: Int = 0): Bitmap? { + return kotlin.runCatching { BitmapFactory.decodeByteArray(this, offset, this.size) }.getOrNull() } diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/DateTimeExtension.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/DateTimeExtension.kt index 9b05fd749fd..567c848c798 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/DateTimeExtension.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/DateTimeExtension.kt @@ -29,6 +29,7 @@ import org.hl7.fhir.r4.model.DateTimeType import org.hl7.fhir.r4.model.DateType import org.ocpsoft.prettytime.PrettyTime import org.smartregister.fhircore.engine.R +import timber.log.Timber const val SDF_DD_MMM_YYYY = "dd-MMM-yyyy" const val SDF_YYYY_MM_DD = "yyyy-MM-dd" @@ -39,6 +40,9 @@ const val SDF_YYYY = "yyyy" const val SDF_D_MMM_YYYY_WITH_COMA = "d MMM, yyyy" const val SDFHH_MM = "HH:mm" const val SDF_E_MMM_DD_YYYY = "E, MMM dd yyyy" +const val DEFAULT_FORMAT_SDF_DD_MM_YYYY = "EEE, MMM dd - hh:mm a" +const val SDF_YYYY_MMM_DD_HH_MM_SS = "yyyy-MM-dd HH:mm:ss" +const val MMM_D_HH_MM_AA = "MMM d, hh:mm aa" fun yesterday(): Date = DateTimeType.now().apply { add(Calendar.DATE, -1) }.value @@ -149,3 +153,60 @@ fun calculateAge(date: Date, context: Context, localDateNow: LocalDate = LocalDa private fun Context.abbreviateString(resourceId: Int, content: Int) = if (content > 0) "$content${this.getString(resourceId).lowercase().abbreviate()} " else "" + +fun formatDate(timeMillis: Long, desireFormat: String): String { + return try { + // Try formatting with the provided format + val format = SimpleDateFormat(desireFormat, Locale.getDefault()) + val date = Date(timeMillis) + format.format(date) + } catch (e: Exception) { + // If formatting fails, fall back to the default format + val defaultFormat = SimpleDateFormat(DEFAULT_FORMAT_SDF_DD_MM_YYYY, Locale.getDefault()) + val date = Date(timeMillis) + defaultFormat.format(date) + } +} + +/** + * Reformats a given date string from its current format to a specified desired format. If the date + * string cannot be parsed in the provided current format, the method will return the original date + * string as a fallback. + * + * @param inputDateString The date string that needs to be reformatted. + * @param currentFormat The format in which the input date string is provided (e.g., "dd-MM-yyyy"). + * @param desiredFormat The format in which the output date string should be returned (default: + * "yyyy-MM-dd HH:mm:ss"). If no desired format is specified, it defaults to "yyyy-MM-dd + * HH:mm:ss". + * @return A string representing the date in the desired format if parsing succeeds, or the original + * input date string if parsing fails. + * + * Example usage: + * ``` + * val reformattedDate = reformatDate("08-10-2024 15:30", "dd-MM-yyyy HH:mm", "yyyy-MM-dd HH:mm:ss") + * println(reformattedDate) // Output: "2024-10-08 15:30:00" + * + * val invalidDate = reformatDate("InvalidDate", "dd-MM-yyyy", "yyyy-MM-dd") + * println(invalidDate) // Output: "InvalidDate" + * ``` + */ +fun reformatDate( + inputDateString: String, + currentFormat: String, + desiredFormat: String, +): String { + return try { + // Create a SimpleDateFormat with the current format of the input date + val inputDateFormat = SimpleDateFormat(currentFormat, Locale.getDefault()) + // Parse the input date string into a Date object + val date = inputDateFormat.parse(inputDateString) + // Create a SimpleDateFormat for the desired format + val outputDateFormat = SimpleDateFormat(desiredFormat, Locale.getDefault()) + // Format the date into the desired format and return the result + outputDateFormat.format(date ?: Date()) + } catch (e: Exception) { + // In case of any exception, return the original input date string + Timber.e(e) + inputDateString + } +} diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/FhirEngineExtension.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/FhirEngineExtension.kt index 5dead070c2d..ff4b014e10a 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/FhirEngineExtension.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/FhirEngineExtension.kt @@ -18,9 +18,10 @@ package org.smartregister.fhircore.engine.util.extension import ca.uhn.fhir.util.UrlUtil import com.google.android.fhir.FhirEngine +import com.google.android.fhir.SearchResult import com.google.android.fhir.db.ResourceNotFoundException import com.google.android.fhir.get -import com.google.android.fhir.search.search +import com.google.android.fhir.search.Search import com.google.android.fhir.workflow.FhirOperator import org.hl7.fhir.r4.model.Composition import org.hl7.fhir.r4.model.IdType @@ -40,7 +41,7 @@ suspend inline fun FhirEngine.loadResource(resourceId: St } suspend fun FhirEngine.searchCompositionByIdentifier(identifier: String): Composition? = - this.search { + this.batchedSearch { filter(Composition.IDENTIFIER, { value = of(Identifier().apply { value = identifier }) }) } .map { it.resource } @@ -50,7 +51,9 @@ suspend fun FhirEngine.loadLibraryAtPath(fhirOperator: FhirOperator, path: Strin // resource path could be Library/123 OR something like http://fhir.labs.common/Library/123 val library = runCatching { get(IdType(path).idPart) }.getOrNull() - ?: search { filter(Library.URL, { value = path }) }.map { it.resource }.firstOrNull() + ?: batchedSearch { filter(Library.URL, { value = path }) } + .map { it.resource } + .firstOrNull() } suspend fun FhirEngine.loadLibraryAtPath( @@ -72,7 +75,7 @@ suspend fun FhirEngine.loadCqlLibraryBundle(fhirOperator: FhirOperator, measureP // resource path could be Measure/123 OR something like http://fhir.labs.common/Measure/123 val measure: Measure? = if (UrlUtil.isValid(measurePath)) { - search { filter(Measure.URL, { value = measurePath }) } + batchedSearch { filter(Measure.URL, { value = measurePath }) } .map { it.resource } .firstOrNull() } else { @@ -86,3 +89,36 @@ suspend fun FhirEngine.loadCqlLibraryBundle(fhirOperator: FhirOperator, measureP } catch (exception: Exception) { Timber.e(exception) } + +suspend fun FhirEngine.countUnSyncedResources() = + this.getUnsyncedLocalChanges() + .distinctBy { it.resourceId } + .groupingBy { it.resourceType.spaceByUppercase() } + .eachCount() + .map { it.key to it.value } + +suspend fun FhirEngine.batchedSearch(search: Search) = + if (search.count != null) { + this.search(search) + } else { + val result = mutableListOf>() + var offset = search.from ?: 0 + val pageCount = 100 + do { + search.from = offset + search.count = pageCount + val searchResults = this.search(search) + result += searchResults + offset += searchResults.size + } while (searchResults.size == pageCount) + + result + } + +suspend inline fun FhirEngine.batchedSearch( + init: Search.() -> Unit, +): List> { + val search = Search(type = R::class.java.newInstance().resourceType) + search.init() + return this.batchedSearch(search) +} diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/MeasureExtensions.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/MeasureExtensions.kt index dd733712c4e..32662559aae 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/MeasureExtensions.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/MeasureExtensions.kt @@ -92,7 +92,7 @@ fun MeasureReport.StratifierGroupComponent.findPercentage( ): String { return if (denominator == 0) { "0" - } else + } else { findPopulation(MeasurePopulationType.NUMERATOR) ?.count ?.toBigDecimal() @@ -103,6 +103,7 @@ fun MeasureReport.StratifierGroupComponent.findPercentage( reportConfiguration?.roundingStrategy?.value ?: DEFAULT_ROUNDING_STRATEGY.value, ) .toString() + } } val MeasureReport.StratifierGroupComponent.displayText @@ -165,5 +166,5 @@ suspend inline fun FhirEngine.retrievePreviouslyGeneratedMeasureReports( search.filter(MeasureReport.MEASURE, { value = measureUrl }) subjects.forEach { search.filter(MeasureReport.SUBJECT, { value = it }) } - return this.search(search).map { it.resource } + return this.batchedSearch(search).map { it.resource } } diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/PatientExtension.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/PatientExtension.kt index 0cbbbe2ad86..3d6df9e2f32 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/PatientExtension.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/PatientExtension.kt @@ -16,29 +16,7 @@ package org.smartregister.fhircore.engine.util.extension -import android.content.Context -import org.hl7.fhir.r4.model.Enumerations import org.hl7.fhir.r4.model.Patient -import org.hl7.fhir.r4.model.codesystems.AdministrativeGender -import org.smartregister.fhircore.engine.R - -fun Patient.extractGender(context: Context): String? = - if (hasGender()) { - when (AdministrativeGender.valueOf(this.gender.name)) { - AdministrativeGender.MALE -> context.getString(R.string.male) - AdministrativeGender.FEMALE -> context.getString(R.string.female) - AdministrativeGender.OTHER -> context.getString(R.string.other) - AdministrativeGender.UNKNOWN -> context.getString(R.string.unknown) - AdministrativeGender.NULL -> "" - } - } else { - null - } - -fun Patient.extractAge(context: Context): String { - if (!hasBirthDate()) return "" - return calculateAge(birthDate, context) -} fun String?.join(other: String?, separator: String) = this.orEmpty().plus(other?.plus(separator).orEmpty()) @@ -47,10 +25,3 @@ fun Patient.extractFamilyTag() = this.meta.tag.firstOrNull { it.display.contentEquals("family", true) || it.display.contains("head", true) } - -fun Enumerations.AdministrativeGender.translateGender(context: Context) = - when (this) { - Enumerations.AdministrativeGender.MALE -> context.getString(R.string.male) - Enumerations.AdministrativeGender.FEMALE -> context.getString(R.string.female) - else -> context.getString(R.string.unknown) - } diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/QuestionnaireExtension.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/QuestionnaireExtension.kt index 8530d6dc6f2..bd252f6b0ca 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/QuestionnaireExtension.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/QuestionnaireExtension.kt @@ -20,24 +20,23 @@ import com.google.android.fhir.datacapture.extensions.asStringValue import com.google.android.fhir.datacapture.extensions.logicalId import com.google.android.fhir.datacapture.extensions.targetStructureMap import java.util.Locale -import org.hl7.fhir.r4.model.BooleanType import org.hl7.fhir.r4.model.Bundle import org.hl7.fhir.r4.model.Coding -import org.hl7.fhir.r4.model.DateTimeType -import org.hl7.fhir.r4.model.DateType -import org.hl7.fhir.r4.model.DecimalType -import org.hl7.fhir.r4.model.Enumerations.DataType import org.hl7.fhir.r4.model.Expression import org.hl7.fhir.r4.model.IdType -import org.hl7.fhir.r4.model.IntegerType -import org.hl7.fhir.r4.model.Quantity import org.hl7.fhir.r4.model.Questionnaire import org.hl7.fhir.r4.model.QuestionnaireResponse +import org.hl7.fhir.r4.model.QuestionnaireResponse.QuestionnaireResponseStatus import org.hl7.fhir.r4.model.StringType -import org.hl7.fhir.r4.model.TimeType -import org.hl7.fhir.r4.model.Type -import org.hl7.fhir.r4.model.UriType +import org.smartregister.fhircore.engine.configuration.LinkIdType +import org.smartregister.fhircore.engine.configuration.QuestionnaireConfig +import org.smartregister.fhircore.engine.configuration.UniqueIdAssignmentConfig import org.smartregister.fhircore.engine.domain.model.ActionParameter +import org.smartregister.fhircore.engine.domain.model.ActionParameterType +import org.smartregister.fhircore.engine.domain.model.RuleConfig +import org.smartregister.fhircore.engine.domain.model.isEditable +import org.smartregister.fhircore.engine.domain.model.isReadOnly +import org.smartregister.fhircore.engine.util.castToType fun QuestionnaireResponse.QuestionnaireResponseItemComponent.asLabel() = if (this.linkId != null) { @@ -176,7 +175,9 @@ fun List.prePopulateInitialValues( (it.value is Coding) && if (actionParam.value.contains(",")) { actionParam.value.split(",").contains((it.value as Coding).code) - } else actionParam.value == (it.value as Coding).code + } else { + actionParam.value == (it.value as Coding).code + } } .forEach { it.initialSelected = true } } else { @@ -194,19 +195,117 @@ fun List.prePopulateInitialValues( } } -/** Cast string value (including json string) to the FHIR {@link org.hl7.fhir.r4.model.Type} */ -fun String.castToType(type: DataType): Type? { - return when (type) { - DataType.BOOLEAN -> BooleanType(this) - DataType.DECIMAL -> DecimalType(this) - DataType.INTEGER -> IntegerType(this) - DataType.DATE -> DateType(this) - DataType.DATETIME -> DateTimeType(this) - DataType.TIME -> TimeType(this) - DataType.STRING -> StringType(this) - DataType.URI -> UriType(this) - DataType.CODING -> this.tryDecodeJson() - DataType.QUANTITY -> this.tryDecodeJson() - else -> null // TODO cast the (several) remaining Enumeration.DataTypes +/** + * Pre-populates questionnaire with computed values from the Rules engine as well as include initial + * values set on configured [QuestionnaireConfig.barcodeLinkId] or + * [QuestionnaireConfig.uniqueIdAssignment] properties. + */ +suspend fun Questionnaire.prepopulateWithComputedConfigValues( + questionnaireConfig: QuestionnaireConfig, + actionParameters: List?, + questionnaireConfigRulesComputeFunc: (List) -> Map, + extractUniqueIdAssignmentFunc: suspend (UniqueIdAssignmentConfig, Map) -> String, +): Map { + require(questionnaireConfig.id.isNotBlank()) + + // Compute questionnaire config rules and add extra questionnaire params to action parameters + + val questionnaireComputedValues = + questionnaireConfig.configRules?.let { questionnaireConfigRulesComputeFunc.invoke(it) } + ?: emptyMap() + + val allActionParameters = + actionParameters?.plus( + questionnaireConfig.extraParams?.map { it.interpolate(questionnaireComputedValues) } + ?: emptyList(), + ) + + if (questionnaireConfig.isReadOnly() || questionnaireConfig.isEditable()) { + item.prepareQuestionsForReadingOrEditing( + readOnly = questionnaireConfig.isReadOnly(), + readOnlyLinkIds = + questionnaireConfig.readOnlyLinkIds + ?: questionnaireConfig.linkIds + ?.filter { it.type == LinkIdType.READ_ONLY } + ?.map { it.linkId }, + ) + } + + if (questionnaireConfig.isEditable()) { + item.prepareQuestionsForEditing( + readOnlyLinkIds = questionnaireConfig.readOnlyLinkIds, + ) + } + + // Pre-populate questionnaire items with configured values + allActionParameters + ?.filter { (it.paramType == ActionParameterType.PREPOPULATE && it.value.isNotEmpty()) } + ?.let { actionParam -> item.prePopulateInitialValues(DEFAULT_PLACEHOLDER_PREFIX, actionParam) } + + // Set barcode to the configured linkId default: "patient-barcode" + if (!questionnaireConfig.resourceIdentifier.isNullOrEmpty()) { + (questionnaireConfig.barcodeLinkId + ?: questionnaireConfig.linkIds?.firstOrNull { it.type == LinkIdType.BARCODE }?.linkId) + ?.let { barcodeLinkId -> + find(barcodeLinkId)?.apply { + initial = + mutableListOf( + Questionnaire.QuestionnaireItemInitialComponent() + .setValue(StringType(questionnaireConfig.resourceIdentifier)), + ) // TODO should this be resource identifier or OpenSrp unique ID? + readOnly = true + } + } + } + + prepopulateUniqueIdAssignment( + questionnaireConfig, + questionnaireComputedValues, + extractUniqueIdAssignmentFunc, + ) + + return questionnaireComputedValues +} + +/** Pre-populates questionnaire with computed [QuestionnaireConfig.uniqueIdAssignment]. */ +suspend fun Questionnaire.prepopulateUniqueIdAssignment( + questionnaireConfig: QuestionnaireConfig, + questionnaireComputedValues: Map, + extractUniqueIdAssignmentFunc: suspend (UniqueIdAssignmentConfig, Map) -> String, +) { + // Set configured OpenSRPId on Questionnaire + questionnaireConfig.uniqueIdAssignment?.let { uniqueIdAssignmentConfig -> + find(uniqueIdAssignmentConfig.linkId)?.apply { + if (initial.isNotEmpty() && !initial.first().isEmpty) return@apply + + val extractedId = + extractUniqueIdAssignmentFunc( + questionnaireConfig.uniqueIdAssignment, + questionnaireComputedValues, + ) + if (extractedId.isNotEmpty()) { + initial = + mutableListOf( + Questionnaire.QuestionnaireItemInitialComponent().setValue(StringType(extractedId)), + ) + } + readOnly = extractedId.isNotEmpty() && uniqueIdAssignmentConfig.readOnly + } + } +} + +/** + * Determines the [QuestionnaireResponse.Status] depending on the [saveDraft] and [isEditable] + * values contained in the [QuestionnaireConfig] + * + * returns [COMPLETED] when [isEditable] is [true] returns [INPROGRESS] when [saveDraft] is [true] + */ +fun QuestionnaireConfig.questionnaireResponseStatus(): String? { + return if (this.isEditable()) { + QuestionnaireResponseStatus.COMPLETED.toCode() + } else if (this.saveDraft) { + QuestionnaireResponseStatus.INPROGRESS.toCode() + } else { + null } } diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/QuestionnaireResponseExtension.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/QuestionnaireResponseExtension.kt index 7e4acaceec7..d09ac918a64 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/QuestionnaireResponseExtension.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/QuestionnaireResponseExtension.kt @@ -34,6 +34,34 @@ private fun List.clearText() { } } +/** Borrows from: https://github.com/google/android-fhir/pull/1936 */ +fun QuestionnaireResponse.packRepeatedGroups() { + item = item.packRepeatedGroups() +} + +private fun List.packRepeatedGroups(): + List { + forEach { it -> + it.item = it.item.packRepeatedGroups() + it.answer.forEach { it.item = it.item.packRepeatedGroups() } + } + val linkIdToPackedResponseItems = + groupBy { it.linkId } + .mapValues { (linkId, questionnaireResponseItems) -> + questionnaireResponseItems.singleOrNull() + ?: QuestionnaireResponse.QuestionnaireResponseItemComponent().apply { + this.linkId = linkId + answer = + questionnaireResponseItems.map { + QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { + item = it.item + } + } + } + } + return map { it.linkId }.distinct().map { linkIdToPackedResponseItems[it]!! } +} + /** Pre-order list of all questionnaire response items in the questionnaire. */ val QuestionnaireResponse.allItems: List get() = item.flatMap { it.descendant } diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/ReferenceExtension.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/ReferenceExtension.kt index d833d7b9b0a..c31a2551282 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/ReferenceExtension.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/ReferenceExtension.kt @@ -25,10 +25,11 @@ fun Reference.extractId(): String = fun Reference.extractType(): ResourceType? = if (this.reference.isNullOrEmpty()) { null - } else + } else { this.reference.substringBefore("/" + this.extractId()).substringAfterLast("/").let { ResourceType.fromCode(it) } + } fun String.asReference(resourceType: ResourceType): Reference { val resourceId = this diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/ResourceExtension.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/ResourceExtension.kt index 7b195477f0d..5d2db45505d 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/ResourceExtension.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/ResourceExtension.kt @@ -20,14 +20,11 @@ import android.content.Context import ca.uhn.fhir.context.FhirContext import ca.uhn.fhir.parser.IParser import ca.uhn.fhir.rest.gclient.ReferenceClientParam -import com.google.android.fhir.datacapture.extensions.createQuestionnaireResponseItem import com.google.android.fhir.datacapture.extensions.logicalId import com.google.android.fhir.get -import com.google.android.fhir.search.search import java.time.Duration import java.time.temporal.ChronoUnit import java.util.Date -import java.util.LinkedList import java.util.Locale import java.util.UUID import kotlin.math.abs @@ -38,7 +35,9 @@ import org.hl7.fhir.r4.model.CodeableConcept import org.hl7.fhir.r4.model.Coding import org.hl7.fhir.r4.model.Composition import org.hl7.fhir.r4.model.Condition +import org.hl7.fhir.r4.model.Consent import org.hl7.fhir.r4.model.Encounter +import org.hl7.fhir.r4.model.Enumerations import org.hl7.fhir.r4.model.Extension import org.hl7.fhir.r4.model.Flag import org.hl7.fhir.r4.model.Group @@ -54,6 +53,7 @@ import org.hl7.fhir.r4.model.Quantity import org.hl7.fhir.r4.model.Questionnaire import org.hl7.fhir.r4.model.QuestionnaireResponse import org.hl7.fhir.r4.model.Reference +import org.hl7.fhir.r4.model.RelatedPerson import org.hl7.fhir.r4.model.Resource import org.hl7.fhir.r4.model.ResourceType import org.hl7.fhir.r4.model.StringType @@ -64,6 +64,7 @@ import org.hl7.fhir.r4.model.Type import org.joda.time.Instant import org.json.JSONException import org.json.JSONObject +import org.smartregister.fhircore.engine.R import org.smartregister.fhircore.engine.configuration.LinkIdType import org.smartregister.fhircore.engine.configuration.QuestionnaireConfig import org.smartregister.fhircore.engine.data.local.DefaultRepository @@ -73,7 +74,6 @@ import timber.log.Timber const val REFERENCE = "reference" const val PARTOF = "part-of" -private val fhirR4JsonParser = FhirContext.forR4Cached().getCustomJsonParser() fun Base?.valueToString(datePattern: String = "dd-MMM-yyyy"): String { return when { @@ -114,10 +114,13 @@ fun Base?.valueToString(datePattern: String = "dd-MMM-yyyy"): String { fun CodeableConcept.stringValue(): String = this.text ?: this.codingFirstRep.display ?: this.codingFirstRep.code -fun Resource.encodeResourceToString(parser: IParser = fhirR4JsonParser): String = - parser.encodeResourceToString(this.copy()) +fun Resource.encodeResourceToString( + parser: IParser = FhirContext.forR4Cached().getCustomJsonParser(), +): String = parser.encodeResourceToString(this.copy()) -fun StructureMap.encodeResourceToString(parser: IParser = fhirR4JsonParser): String = +fun StructureMap.encodeResourceToString( + parser: IParser = FhirContext.forR4Cached().getCustomJsonParser(), +): String = parser .encodeResourceToString(this) .replace("'months'", "\\\\'months\\\\'") @@ -125,8 +128,9 @@ fun StructureMap.encodeResourceToString(parser: IParser = fhirR4JsonParser): Str .replace("'years'", "\\\\'years\\\\'") .replace("'weeks'", "\\\\'weeks\\\\'") -fun String.decodeResourceFromString(parser: IParser = fhirR4JsonParser): T = - parser.parseResource(this) as T +fun String.decodeResourceFromString( + parser: IParser = FhirContext.forR4Cached().getCustomJsonParser(), +): T = parser.parseResource(this) as T fun T.updateFrom(updatedResource: Resource): T { var extensionUpdateFrom = listOf() @@ -137,7 +141,7 @@ fun T.updateFrom(updatedResource: Resource): T { if (this is Patient) { extension = this.extension } - val jsonParser = fhirR4JsonParser + val jsonParser = FhirContext.forR4Cached().getCustomJsonParser() val stringJson = encodeResourceToString(jsonParser) val originalResourceJson = JSONObject(stringJson) @@ -182,22 +186,6 @@ fun JSONObject.updateFrom(updated: JSONObject) { keys.forEach { key -> updated.opt(key)?.run { put(key, this) } } } -fun QuestionnaireResponse.generateMissingItems(questionnaire: Questionnaire) = - questionnaire.item.generateMissingItems(this.item) - -fun List.generateMissingItems( - qrItems: MutableList, -) { - this.forEachIndexed { index, qItem -> - // generate complete hierarchy if response item missing otherwise check for nested items - if (qrItems.isEmpty() || (index < qrItems.size && qItem.linkId != qrItems[index].linkId)) { - qrItems.add(index, qItem.createQuestionnaireResponseItem()) - } else if (index < qrItems.size) { - qItem.item.generateMissingItems(qrItems[index].item) - } - } -} - /** * Set all questions that are not of type [Questionnaire.QuestionnaireItemType.GROUP] to readOnly if * [readOnly] is true. This also generates the correct FHIRPath population expression for each @@ -294,6 +282,7 @@ fun Resource.appendOrganizationInfo(authenticatedOrganizationIds: List?) is Group -> managingEntity = updateReference(managingEntity, organizationRef) is Encounter -> serviceProvider = updateReference(serviceProvider, organizationRef) is Location -> managingOrganization = updateReference(managingOrganization, organizationRef) + is Consent -> organization = updateReferenceList(organization, organizationRef) else -> {} } } @@ -326,6 +315,7 @@ fun Resource.appendPractitionerInfo(practitionerId: String?) { } else { participant } + is Consent -> performer = updateReferenceList(performer, practitionerRef) else -> {} } } @@ -364,6 +354,14 @@ fun Resource.appendRelatedEntityLocation( } } +private fun updateReferenceList( + oldReferenceList: List?, + newReference: Reference, +): List { + val list = oldReferenceList?.filter { !it.reference.isNullOrEmpty() } + return if (!list.isNullOrEmpty()) list else listOf(newReference) +} + private fun updateReference(oldReference: Reference?, newReference: Reference): Reference = if (oldReference == null || oldReference.reference.isNullOrEmpty()) { newReference @@ -423,7 +421,7 @@ fun ImplementationGuide.retrieveImplementationGuideDefinitionResources(): */ fun Composition.retrieveCompositionSections(): List { val sections = mutableListOf() - val sectionsQueue = LinkedList() + val sectionsQueue = ArrayDeque() this.section.forEach { if (!it.section.isNullOrEmpty()) { it.section.forEach { sectionComponent -> sectionsQueue.addLast(sectionComponent) } @@ -471,7 +469,7 @@ suspend fun Task.updateDependentTaskDueDate( return apply { val dependentTasks = defaultRepository.fhirEngine - .search { + .batchedSearch { filter( referenceParameter = ReferenceClientParam(PARTOF), { value = id }, @@ -567,3 +565,47 @@ fun List.filterByFhirPathExpression( } } } + +/** Extracts and returns a translated string for the gender in the resource */ +fun Resource.extractGender(context: Context): String { + return when (this) { + is Patient -> getGenderString(this.gender, context) + is RelatedPerson -> getGenderString(this.gender, context) + else -> "" + } +} + +private fun getGenderString(gender: Enumerations.AdministrativeGender?, context: Context): String { + return when (gender) { + Enumerations.AdministrativeGender.MALE -> context.getString(R.string.male) + Enumerations.AdministrativeGender.FEMALE -> context.getString(R.string.female) + Enumerations.AdministrativeGender.OTHER -> context.getString(R.string.other) + Enumerations.AdministrativeGender.UNKNOWN -> context.getString(R.string.unknown) + else -> "" + } +} + +fun Enumerations.AdministrativeGender.translateGender(context: Context) = + when (this) { + Enumerations.AdministrativeGender.MALE -> context.getString(R.string.male) + Enumerations.AdministrativeGender.FEMALE -> context.getString(R.string.female) + else -> context.getString(R.string.unknown) + } + +/** Extract a Resource's age if birthDate is an available field */ +fun Resource.extractAge(context: Context): String { + return when (this) { + is Patient -> this.birthDate?.let { calculateAge(it, context) } ?: "" + is RelatedPerson -> this.birthDate?.let { calculateAge(it, context) } ?: "" + else -> "" + } +} + +/** Extract a Resource's birthDate if it's an available field */ +fun Resource.extractBirthDate(): Date? { + return when (this) { + is Patient -> this.birthDate + is RelatedPerson -> this.birthDate + else -> null + } +} diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/StringExtensions.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/StringExtensions.kt index a724877053d..293b0d767d6 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/StringExtensions.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/StringExtensions.kt @@ -16,6 +16,8 @@ package org.smartregister.fhircore.engine.util.extension +import android.content.Context +import android.telephony.PhoneNumberUtils import java.text.MessageFormat import java.text.SimpleDateFormat import java.util.Date @@ -123,3 +125,14 @@ fun String.lastOffset() = this.uppercase() + "_" + SharedPreferenceKey.LAST_OFFS fun String.spaceByUppercase() = this.split(Regex("(?=\\p{Upper})")).joinToString(separator = " ").trim() + +fun String?.formatPhoneNumber(context: Context): String? { + if (this == null) return null + return try { + PhoneNumberUtils.formatNumber(this, context.resources.configuration.locales.get(0).country) + ?: this + } catch (formatException: NumberFormatException) { + Timber.e(formatException, "Error formatting phone number: $this") + this + } +} diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/util/helper/TransformSupportServices.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/helper/TransformSupportServices.kt index 2fed60e0475..1ff86402533 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/util/helper/TransformSupportServices.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/helper/TransformSupportServices.kt @@ -24,6 +24,7 @@ import org.hl7.fhir.r4.model.AdverseEvent import org.hl7.fhir.r4.model.Base import org.hl7.fhir.r4.model.CarePlan import org.hl7.fhir.r4.model.Coding +import org.hl7.fhir.r4.model.Consent import org.hl7.fhir.r4.model.Encounter import org.hl7.fhir.r4.model.EpisodeOfCare import org.hl7.fhir.r4.model.Group @@ -81,8 +82,15 @@ class TransformSupportServices @Inject constructor(val simpleWorkerContext: Simp "Task_Output" -> Task.TaskOutputComponent() "Task_Restriction" -> Task.TaskRestrictionComponent() "AdverseEvent_SuspectEntity" -> AdverseEvent.AdverseEventSuspectEntityComponent() + "AdverseEvent_SuspectEntityCausality" -> + AdverseEvent.AdverseEventSuspectEntityCausalityComponent() "Location_Position" -> Location.LocationPositionComponent() "List_Entry" -> ListResource.ListEntryComponent() + "Consent_Policy" -> Consent.ConsentPolicyComponent() + "Consent_Verification" -> Consent.ConsentVerificationComponent() + "Consent_Provision" -> Consent.provisionComponent() + "Consent_ProvisionActor" -> Consent.provisionActorComponent() + "Consent_ProvisionData" -> Consent.provisionDataComponent() else -> ResourceFactory.createResourceOrType(name) } } diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/util/location/LocationUtils.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/location/LocationUtils.kt index e24f28708db..f7d96acd576 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/util/location/LocationUtils.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/location/LocationUtils.kt @@ -34,7 +34,6 @@ object LocationUtils { fun isLocationEnabled(context: Context): Boolean { val locationManager = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager - return locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER) || locationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER) } diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/util/location/PermissionUtils.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/location/PermissionUtils.kt index ecbde46aa7e..800720b05af 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/util/location/PermissionUtils.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/location/PermissionUtils.kt @@ -18,63 +18,48 @@ package org.smartregister.fhircore.engine.util.location import android.Manifest import android.content.Context -import android.content.Intent import android.content.pm.PackageManager -import androidx.activity.result.ActivityResultLauncher -import androidx.activity.result.contract.ActivityResultContracts -import androidx.appcompat.app.AppCompatActivity import androidx.core.content.ContextCompat object PermissionUtils { - fun checkPermissions(context: Context, permissions: List): Boolean { - for (permission in permissions) { - if ( - ContextCompat.checkSelfPermission(context, permission) != PackageManager.PERMISSION_GRANTED - ) { - return false - } + fun checkPermissions(context: Context, permissions: List): Boolean = + permissions.none { + ContextCompat.checkSelfPermission( + context, + it, + ) != PackageManager.PERMISSION_GRANTED } - return true - } fun getLocationPermissionLauncher( - activity: AppCompatActivity, + permissions: Map, onFineLocationPermissionGranted: () -> Unit, onCoarseLocationPermissionGranted: () -> Unit, onLocationPermissionDenied: () -> Unit, - ): ActivityResultLauncher> { - return activity.registerForActivityResult( - ActivityResultContracts.RequestMultiplePermissions(), - ) { permissions -> - if (permissions[Manifest.permission.ACCESS_FINE_LOCATION] == true) { + ) { + when { + permissions[Manifest.permission.ACCESS_FINE_LOCATION] == true -> onFineLocationPermissionGranted() - } else if (permissions[Manifest.permission.ACCESS_COARSE_LOCATION] == true) { + permissions[Manifest.permission.ACCESS_COARSE_LOCATION] == true -> onCoarseLocationPermissionGranted() - } else { - onLocationPermissionDenied() - } - } - } - - fun getStartActivityForResultLauncher( - activity: AppCompatActivity, - onResult: (resultCode: Int, data: Intent?) -> Unit, - ): ActivityResultLauncher { - return activity.registerForActivityResult( - ActivityResultContracts.StartActivityForResult(), - ) { result -> - onResult(result.resultCode, result.data) + else -> onLocationPermissionDenied() } } - fun hasFineLocationPermissions(context: Context): Boolean { - return ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == + fun hasFineLocationPermissions(context: Context): Boolean = + ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED - } - fun hasCoarseLocationPermissions(context: Context): Boolean { - return ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) == + fun hasCoarseLocationPermissions(context: Context): Boolean = + ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED - } + + fun hasLocationPermissions(context: Context) = + checkPermissions( + context, + listOf( + Manifest.permission.ACCESS_COARSE_LOCATION, + Manifest.permission.ACCESS_FINE_LOCATION, + ), + ) } diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/util/test/HiltActivityForTest.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/test/HiltActivityForTest.kt index 8badb66af2f..5dd0f6ec014 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/util/test/HiltActivityForTest.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/test/HiltActivityForTest.kt @@ -16,16 +16,31 @@ package org.smartregister.fhircore.engine.util.test +import android.os.Bundle import androidx.appcompat.app.AppCompatActivity import com.google.android.fhir.sync.CurrentSyncJobStatus import dagger.hilt.android.AndroidEntryPoint +import org.smartregister.fhircore.engine.R import org.smartregister.fhircore.engine.sync.OnSyncListener import org.smartregister.fhircore.engine.util.annotation.ExcludeFromJacocoGeneratedReport @ExcludeFromJacocoGeneratedReport @AndroidEntryPoint class HiltActivityForTest : AppCompatActivity(), OnSyncListener { + override fun onCreate(savedInstanceState: Bundle?) { + if (intent.hasExtra(THEME_EXTRAS_BUNDLE_KEY)) { + setTheme(intent.getIntExtra(THEME_EXTRAS_BUNDLE_KEY, R.style.AppTheme)) + } + + super.onCreate(savedInstanceState) + } + override fun onSync(syncJobStatus: CurrentSyncJobStatus) { // DO nothing. This activity implements OnSyncListener for testing purposes } + + companion object { + const val THEME_EXTRAS_BUNDLE_KEY = + "org.smartregister.fhircore.engine.util.test.HiltActivityForTest.THEME_EXTRAS_BUNDLE_KEY" + } } diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/util/validation/ResourceValidationRequest.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/validation/ResourceValidationRequest.kt new file mode 100644 index 00000000000..ba398a5bf87 --- /dev/null +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/validation/ResourceValidationRequest.kt @@ -0,0 +1,94 @@ +/* + * Copyright 2021-2024 Ona Systems, Inc + * + * 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 org.smartregister.fhircore.engine.util.validation + +import ca.uhn.fhir.validation.FhirValidator +import ca.uhn.fhir.validation.ResultSeverityEnum +import ca.uhn.fhir.validation.ValidationResult +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import org.hl7.fhir.r4.model.Resource +import org.smartregister.fhircore.engine.util.DispatcherProvider +import org.smartregister.fhircore.engine.util.extension.referenceValue +import timber.log.Timber + +data class ResourceValidationRequest(val resources: List) { + constructor(vararg resource: Resource) : this(resource.toList()) +} + +class ResourceValidationRequestHandler( + private val fhirValidator: FhirValidator, + private val dispatcherProvider: DispatcherProvider, +) { + fun handleResourceValidationRequest(request: ResourceValidationRequest) { + CoroutineScope(dispatcherProvider.io()).launch { + val resources = request.resources + fhirValidator.checkResources(resources).logErrorMessages() + } + } +} + +internal data class ResourceValidationResult( + val resource: Resource, + val validationResult: ValidationResult, +) { + val errorMessages + get() = buildString { + val messages = + validationResult.messages.filter { + it.severity.ordinal >= ResultSeverityEnum.WARNING.ordinal + } + if (messages.isNotEmpty()) { + appendLine(resource.referenceValue()) + } + for (validationMsg in messages) { + appendLine( + "${validationMsg.locationString} - ${validationMsg.message} -- (${validationMsg.severity})", + ) + } + } +} + +internal class FhirValidatorResultsWrapper( + val results: List = emptyList(), +) { + val errorMessages = results.map { it.errorMessages } + + fun logErrorMessages() { + results.forEach { + if (it.errorMessages.isNotBlank()) { + Timber.tag(TAG).e(it.errorMessages) + } + } + } + + companion object { + private const val TAG = "FhirValidatorResult" + } +} + +internal fun FhirValidator.checkResources( + resources: List, +): FhirValidatorResultsWrapper { + return FhirValidatorResultsWrapper( + results = + resources.map { + val result = this@checkResources.validateWithResult(it) + ResourceValidationResult(it, result) + }, + ) +} diff --git a/android/engine/src/main/res/drawable/base_icon.png b/android/engine/src/main/res/drawable/base_icon.png new file mode 100644 index 00000000000..0571cec4d82 Binary files /dev/null and b/android/engine/src/main/res/drawable/base_icon.png differ diff --git a/android/engine/src/main/res/drawable/ic_filter.xml b/android/engine/src/main/res/drawable/ic_filter.xml new file mode 100644 index 00000000000..435835305ef --- /dev/null +++ b/android/engine/src/main/res/drawable/ic_filter.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/engine/src/main/res/drawable/ic_variant_loader.xml b/android/engine/src/main/res/drawable/ic_variant_loader.xml new file mode 100644 index 00000000000..2dd66f7a367 --- /dev/null +++ b/android/engine/src/main/res/drawable/ic_variant_loader.xml @@ -0,0 +1,163 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/engine/src/main/res/values-es/strings.xml b/android/engine/src/main/res/values-es/strings.xml new file mode 100644 index 00000000000..757d0475bb7 --- /dev/null +++ b/android/engine/src/main/res/values-es/strings.xml @@ -0,0 +1,191 @@ + + Sincronización manual + Idioma + Cerrar sesión como + Mostrar vencido + Buscar nombre o ID + Buscar nombre + Sincronizar datos + ESCANEAR CÓDIGO DE BARRAS + Sin resultados + Lo sentimos, no pudimos encontrar el cliente con su nombre o ID + Registrar nuevo cliente + Aplicación Fhir + Logotipo de la aplicación + Desarrollado por + Versión de la aplicación %1$d(%2$s) + Versión de migración de datos %1$s + Última sincronización %1$s + INICIAR + No se pudieron verificar las credenciales del servidor. Comprueba tu conexión a Internet + No se pudo obtener información del usuario del servidor. Comprueba tu conexión a Internet + Seleccionar idioma + Cerrar sesión como %1$s + Sincronización completa + Sincronización + Sincronizando + Sincronizando + Sincronización iniciada… + Error de sincronización. Verifique la conexión a Internet o vuelva a intentarlo más tarde + Sincronización completada con errores. Reintentando… + La sincronización falló porque las credenciales de autenticación no son válidas. + No se pudieron recuperar las credenciales del servidor. Por favor verifique su conexión a Internet. + Inténtalo de nuevo + Vacunado + Atrasado + Vacuna %1$d \n%2$s + "Completamente vacunado" + "No puedo recibir otra dosis. Ya estoy completamente vacunado" + Registro\nVacuna + Visto por última vez %1$s + Última visita %1$s + Guardar + Error de inicio de sesión: %1$s + SIGUIENTE + ANTERIOR + Página %1$d de %2$d + %1$d RESULTADO(S) + Sin resultados + Lo sentimos, no podemos encontrar a la persona con el nombre o ID proporcionado + Hombre + Mujer + Otro + Desconocido + Sincronización fallida + Reintentar sincronización + Sincronización en curso + Cargando + Mensaje de error al cerrar sesión: %1$s + No se puede cerrar sesión: ya se ha cerrado sesión o el dispositivo está desconectado. + No se pudo cargar la configuración. Inténtalo de nuevo más tarde + No se pudo cargar la configuración. Por favor revisa tu conexión a Internet + Error al conectarse al servidor. Por favor contacte al administrador del sistema + Error al cargar el formulario + Error encontrado al no poder guardar el formulario + Procesando datos. Por favor espera + ¿Estás seguro de que quieres volver? + ¿Estás seguro de que deseas descartar las respuestas? + Descartar cambios + Descartar + Guardar borrador parcial + Cancelar + + Los detalles proporcionados tienen errores de validación. Resolver errores y enviar de nuevo + Validación fallida + Aceptar + Nombre de usuario + Contraseña + Olvidé mi contraseña + ¡Olvidé mi contraseña! + Más + Llame a su supervisor al %1$s + CANCEL + MARCAR NÚMERO + Registrarse + Visitas + Informes + Perfil + Configuración + Seleccionar registro + Marca + Clientes + Cerrar sesión + ID de aplicación + por ejemplo, ecbis, quest, cha + CARGAR CONFIGURACIONES + APLICACIÓN FHIRCORE + Recordar aplicación + Listo + Reemplazar foto + Tomar foto + Editar + Error al cargar las configuraciones de la aplicación %1$s + eCBIS + el nombre de usuario o la contraseña no son válidos + ID de formulario adjunto no válido + No + + No hay respuesta al campo requerido. + Establecer PIN + CHA utilizará este PIN para iniciar sesión + Ingrese el PIN para %1$s + PIN incorrecto, inténtelo de nuevo + Iniciar sesión + ¿Olvidaste tu PIN? + Por favor, póngase en contacto con su supervisor. + % + Cerrar sesión. Por favor espera… + La sesión ha caducado y debes iniciar sesión nuevamente + Sexo + Edad + fecha de nacimiento + ID: %1$s + VER TODO + FORMULARIOS + ANTECEDENTES MÉDICOS + PRÓXIMOS SERVICIOS + TARJETA DE SERVICIO + Otros pacientes + Seleccionar ubicación + RESPUESTAS (%1$s) + Intenté iniciar sesión con un proveedor diferente + Por favor espera… + Restablecer datos + Transferir datos + ¡Restablecer base de datos! + Restablecer la base de datos borrará todos los registros de su dispositivo. Esta acción no se puede deshacer. + Restableciendo aplicación… + https://smartregister.org/care-team-tag-id + https://smartregister.org/location-tag-id + https://smartregister.org/organization-tag-id + https://smartregister.org/practitioner-tag-id + https://smartregister.org/ related-entity-location-tag-id + Ubicación de entidad relacionada + Equipo de atención de profesionales + Ubicación del practicante + Organización profesional + Practicante + Año + Mes + Semana(s) + Días(s) + Inicializando configuración … + por ejemplo, JohnDoe + %1$d%% + Algo salió mal… + Perspectivas + "Contactar con ayuda" + "Mapas sin conexión" + Descartar + Información del usuario + Información de la tarea + Información de la aplicación + Información del dispositivo + Actualizar + Recursos no sincronizados + Todos los recursos sincronizados + Estadísticas sincronizadas + Todos los datos sincronizados + Se requiere configuración de usuario. Habilite su conexión a Internet + Seleccionar mes + Usuario + Equipo + Localidad + Equipo(Organización) + Equipo de atención + Ubicación + Código de versión de la aplicación + Fecha de compilación + Fabricante + Versión de la aplicación + Versión del sistema operativo + Fecha + Dispositivo + OK + AÑADIR + Migración de datos iniciada desde la versión %1$d + Datos de la aplicación migrados a la versión %1$d + Ayuda + Sin conjunto de datos + archivo + diff --git a/android/engine/src/main/res/values-fr/strings.xml b/android/engine/src/main/res/values-fr/strings.xml index 3e9a00e173f..5332ca15cda 100644 --- a/android/engine/src/main/res/values-fr/strings.xml +++ b/android/engine/src/main/res/values-fr/strings.xml @@ -1,5 +1,6 @@ - Synchronisation manuelle + Synchronisation manuelle + Synchronisation Langue Se déconnecter en tant que Afficher les retards @@ -13,6 +14,7 @@ Logo de l\'application Powered By Version de l\'application%1$d(%2$s) + Version de la migration des données %1$s Dernière synchronisation%1$s SE CONNECTER Échec de la vérification des informations d\'identification du serveur. Vérifiez votre connexion internet @@ -23,7 +25,7 @@ Synchronisation Synchronisation Synchronisation en cours - Synchronisation initiée … + Synchronisation initiée… La synchronisation a échoué. Vérifier la connexion internet ou réessayer plus tard La synchronisation s\'est terminée avec des erreurs. Réessayer... La synchronisation a échoué car les informations d\'authentification ne sont pas valides. @@ -62,11 +64,12 @@ Erreur rencontrée, formulaire non enregistré Traitement des données. Veuillez patienter Êtes-vous sûr de vouloir revenir en arrière ? - Êtes-vous sûr de vouloir annuler les réponses ? - Annuler les modifications - Annuler - Enregistrer un brouillon partiel - Annuler + Si vous partez sans sauvegarder, toutes vos modifications ne seront pas enregistrées. + Vous avez des modifications non enregistrées. + Annuler les modifications + Enregistrer comme brouillon + Ne pas annuler cette action + Abandonner les modifications Oui Des erreurs de validation ont été détectées. Corrigez les erreurs et soumettez à nouveau. Échec de la validation @@ -76,7 +79,7 @@ Mot de passe oublié Mot de passe oublié! Plus de détails - Veuillez appeler votre superviseur au %1$s + Merci de contacter votre administrateur. Annuler Composer le numéro Enregistrer @@ -107,7 +110,6 @@ Code PIN incorrect, veuillez réessayer Connexion Code PIN oublié ? - Veuillez contacter votre superviseur. Déconnexion. Veuillez patienter... Session expirée, veuillez vous connecter à nouveau Sexe @@ -133,6 +135,7 @@ Semaine(s) Jour(s) Initialisation des paramètres … + Initialisation de l\'application … Quelque chose a mal tourné... Insights Contacter l\'aide @@ -166,5 +169,18 @@ AJOUTER Début de la migration des données à partir de la version %1$d Migration des données de l\'application vers la version %1$d + Aide Pas de données + Synchronisation terminée + Erreur de synchronisation + Calcul des minutes restantes... + %1$d%% Synchronisation... + %1$d%%Syncing down… + fichier + REESSAYER + Il y a des données non synchronisées + Le contact du superviseur est manquant ou le numéro de téléphone fourni n\'est pas valide + APPLIQUER LE FILLTRE + Enregistrer les modifications du brouillon + Voulez-vous enregistrer les modifications du brouillon ? diff --git a/android/engine/src/main/res/values-in/strings.xml b/android/engine/src/main/res/values-in/strings.xml index f55d32390af..5933ffa823c 100644 --- a/android/engine/src/main/res/values-in/strings.xml +++ b/android/engine/src/main/res/values-in/strings.xml @@ -1,5 +1,5 @@ - Sinkronisasi manual + Sinkronisasi manual Bahasa Keluar sebagai Tampilkan yang terlambat @@ -109,7 +109,6 @@ PIN salah, mohon coba lagi Masuk Lupa PIN? - Silakan hubungi supervisor Anda. % Sedang keluar (log out). Mohon tunggu… Sesi telah kedaluwarsa dan harus login lagi @@ -174,4 +173,5 @@ Versi OS Tanggal Perangkat + Versi migrasi data diff --git a/android/engine/src/main/res/values-sw/strings.xml b/android/engine/src/main/res/values-sw/strings.xml index 9db5abf150d..0b80b4bf86c 100644 --- a/android/engine/src/main/res/values-sw/strings.xml +++ b/android/engine/src/main/res/values-sw/strings.xml @@ -1,5 +1,6 @@ Toleo %1$d(%2$s) + Toleo la uhamiaji wa data %1$s Usawazishaji wa mwisho %1$s Lugha Hakuna Matokeo @@ -14,7 +15,7 @@ "Chanjo kamili" Chagua Lugha Sajilisha mteja - Sawazisha + Sawazisha Onyesha waliochelewa Rekodi\nChanjo Hifadhi @@ -97,7 +98,6 @@ PIN isiyo sahihi, jaribu tena Ingia Umesahau PIN? - Tafadhali wasiliana na msimamizi wako. Tafadhali subiri... Inatoka kwenye mfumo Muda wa kipindi chako umeisha. Tafadhali ingia tena. Jinsia diff --git a/android/engine/src/main/res/values/colors.xml b/android/engine/src/main/res/values/colors.xml index 6649f4ed09b..3045101fd64 100644 --- a/android/engine/src/main/res/values/colors.xml +++ b/android/engine/src/main/res/values/colors.xml @@ -25,4 +25,13 @@ #f2f5f6 #9e9b9b #FFECD6 + + #FFFFFF + #1F1F1F + + #FFFFFF + #1F1F1F + + #E1E3E1 + #444746 diff --git a/android/engine/src/main/res/values/dimens.xml b/android/engine/src/main/res/values/dimens.xml new file mode 100644 index 00000000000..84b1ae71937 --- /dev/null +++ b/android/engine/src/main/res/values/dimens.xml @@ -0,0 +1,19 @@ + + + + 0dp + diff --git a/android/engine/src/main/res/values/strings.xml b/android/engine/src/main/res/values/strings.xml index 4571522bdf4..7bc1d98de17 100644 --- a/android/engine/src/main/res/values/strings.xml +++ b/android/engine/src/main/res/values/strings.xml @@ -1,5 +1,6 @@ - Manual Sync + Manual Sync + Sync Language Log out as Show overdue @@ -14,6 +15,7 @@ Application logo Powered By App version %1$d(%2$s) + Data migration version %1$s Last sync %1$s LOGIN Failed to verify credentials from the server. Check your internet connection @@ -63,11 +65,12 @@ Error encountered cannot save form Processing data. Please wait Are you sure you want to go back? - Are you sure you want to discard the answers? - Discard changes - Discard - Save partial draft + If you leave without saving, all your changes will not be saved + You have unsaved changes + Discard changes + Save as draft Cancel + Discard Changes Yes Given details have validation errors. Resolve errors and submit again Validation Failed @@ -77,7 +80,7 @@ Forgot Password Forgot Password! More - Please call your supervisor at %1$s + Please contact your supervisor. CANCEL DIAL NUMBER Register @@ -111,7 +114,6 @@ Incorrect PIN, please try again Login Forgot PIN? - Please contact your supervisor. % Logging out. Please wait… Session has been expired and must login again @@ -149,6 +151,7 @@ Week(s) Days(s) Initializing settings … + Initializing application … e.g JohnDoe %1$d%% Something went wrong… @@ -184,5 +187,18 @@ ADD Started data migration from version %1$d Application data migrated to version %1$d + Help No data set + Sync complete + Sync error + Calculating minutes remaining… + %1$d%% Syncing up… + %1$d%% Syncing down… + file + RETRY + There\'s some un-synced data + Supervisor contact missing or the provided phone number is invalid + APPLY FILTER + Save draft changes + Do you want to save draft changes? diff --git a/android/engine/src/main/res/values/styles.xml b/android/engine/src/main/res/values/styles.xml index 28c561f1556..eb95cda512f 100644 --- a/android/engine/src/main/res/values/styles.xml +++ b/android/engine/src/main/res/values/styles.xml @@ -32,7 +32,6 @@ 8dp - + + + + + + diff --git a/android/engine/src/test/java/org/smartregister/fhircore/engine/FhirExtractionTest.kt b/android/engine/src/test/java/org/smartregister/fhircore/engine/FhirExtractionTest.kt index 79cc9d78429..0b8d49b085a 100644 --- a/android/engine/src/test/java/org/smartregister/fhircore/engine/FhirExtractionTest.kt +++ b/android/engine/src/test/java/org/smartregister/fhircore/engine/FhirExtractionTest.kt @@ -34,7 +34,6 @@ import javax.inject.Inject import junit.framework.Assert.assertEquals import junit.framework.Assert.assertNotNull import junit.framework.Assert.assertTrue -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest import org.hl7.fhir.r4.model.Encounter @@ -75,7 +74,6 @@ class FhirExtractionTest : RobolectricTest() { hiltRule.inject() structureMapUtilities = StructureMapUtilities(transformSupportServices.simpleWorkerContext) val workManager = mockk() - every { defaultRepository.dispatcherProvider.io() } returns Dispatchers.IO every { defaultRepository.fhirEngine } returns fhirEngine every { workManager.enqueue(any()) } returns mockk() } diff --git a/android/engine/src/test/java/org/smartregister/fhircore/engine/auth/TokenAuthenticatorTest.kt b/android/engine/src/test/java/org/smartregister/fhircore/engine/auth/TokenAuthenticatorTest.kt index 1aee73852bb..35a3bcc4039 100644 --- a/android/engine/src/test/java/org/smartregister/fhircore/engine/auth/TokenAuthenticatorTest.kt +++ b/android/engine/src/test/java/org/smartregister/fhircore/engine/auth/TokenAuthenticatorTest.kt @@ -24,10 +24,10 @@ import android.accounts.OperationCanceledException import android.os.Bundle import androidx.core.os.bundleOf import androidx.test.core.app.ApplicationProvider +import com.auth0.jwt.exceptions.JWTDecodeException import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidTest import dagger.hilt.android.testing.HiltTestApplication -import io.jsonwebtoken.JwtException import io.mockk.coEvery import io.mockk.every import io.mockk.just @@ -108,7 +108,7 @@ class TokenAuthenticatorTest : RobolectricTest() { } @Test - @Throws(JwtException::class) + @Throws(JWTDecodeException::class) fun testIsTokenActiveWithExpiredJwtToken() { Assert.assertFalse(tokenAuthenticator.isTokenActive("expired-token")) } diff --git a/android/engine/src/test/java/org/smartregister/fhircore/engine/configuration/ConfigurationRegistryTest.kt b/android/engine/src/test/java/org/smartregister/fhircore/engine/configuration/ConfigurationRegistryTest.kt index 8e55966a5c6..7ea15619ccb 100644 --- a/android/engine/src/test/java/org/smartregister/fhircore/engine/configuration/ConfigurationRegistryTest.kt +++ b/android/engine/src/test/java/org/smartregister/fhircore/engine/configuration/ConfigurationRegistryTest.kt @@ -25,7 +25,6 @@ import com.google.android.fhir.SearchResult import com.google.android.fhir.datacapture.extensions.logicalId import com.google.android.fhir.db.ResourceNotFoundException import com.google.android.fhir.search.Search -import com.google.common.reflect.TypeToken import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidTest import dagger.hilt.android.testing.HiltTestApplication @@ -46,10 +45,10 @@ import org.hl7.fhir.r4.model.Composition.SectionComponent import org.hl7.fhir.r4.model.Enumerations import org.hl7.fhir.r4.model.Identifier import org.hl7.fhir.r4.model.ListResource -import org.hl7.fhir.r4.model.Patient import org.hl7.fhir.r4.model.Reference import org.hl7.fhir.r4.model.Resource import org.hl7.fhir.r4.model.ResourceType +import org.hl7.fhir.r4.model.StructureMap import org.junit.Assert import org.junit.Assert.assertEquals import org.junit.Assert.assertNotNull @@ -71,6 +70,7 @@ import org.smartregister.fhircore.engine.domain.model.ActionParameterType import org.smartregister.fhircore.engine.robolectric.RobolectricTest import org.smartregister.fhircore.engine.rule.CoroutineTestRule import org.smartregister.fhircore.engine.util.DispatcherProvider +import org.smartregister.fhircore.engine.util.KnowledgeManagerUtil import org.smartregister.fhircore.engine.util.SharedPreferenceKey import org.smartregister.fhircore.engine.util.SharedPreferencesHelper import org.smartregister.fhircore.engine.util.extension.getPayload @@ -773,7 +773,7 @@ class ConfigurationRegistryTest : RobolectricTest() { } @Test - fun testThatNextIsInvokedWhenItExistsInABundleLink() = runTest { + fun testThatNextIsInvokedWhenItExistsInABundleLink() { val appId = "theAppId" val compositionSections = mutableListOf() compositionSections.add( @@ -804,14 +804,14 @@ class ConfigurationRegistryTest : RobolectricTest() { }, ) } + val nextPageUrlLink = bundle.getLink(PAGINATION_NEXT).url val finalBundle = Bundle().apply { entry = listOf(BundleEntryComponent().setResource(listResource)) } configRegistry.sharedPreferencesHelper.write(SharedPreferenceKey.APP_ID.name, appId) - fhirEngine.create(composition) - + runBlocking { fhirEngine.create(composition) } coEvery { fhirResourceDataSource.getResource("Composition?identifier=theAppId&_count=200") } returns Bundle().apply { addEntry().resource = composition } @@ -823,8 +823,6 @@ class ConfigurationRegistryTest : RobolectricTest() { ) } returns bundle - val nextPageUrlLink = bundle.getLink(PAGINATION_NEXT).url - coEvery { fhirResourceDataSource.getResourceWithGatewayModeHeader( "list-entries", @@ -832,7 +830,7 @@ class ConfigurationRegistryTest : RobolectricTest() { ) } returns finalBundle - configRegistry.fetchNonWorkflowConfigResources() + runBlocking { configRegistry.fetchNonWorkflowConfigResources() } coVerify { fhirResourceDataSource.getResourceWithGatewayModeHeader( @@ -1021,39 +1019,22 @@ class ConfigurationRegistryTest : RobolectricTest() { assertEquals(ResourceType.Composition, requestPathArgumentSlot.last().resourceType) } - @Test - fun testSaveSyncSharedPreferencesShouldVerifyDataSave() { - val resourceType = - listOf(ResourceType.Task, ResourceType.Patient, ResourceType.Task, ResourceType.Patient) - - configRegistry.saveSyncSharedPreferences(resourceType) - - val savedSyncResourcesResult = - configRegistry.sharedPreferencesHelper.read( - SharedPreferenceKey.REMOTE_SYNC_RESOURCES.name, - null, - )!! - val listResourceTypeToken = object : TypeToken>() {}.type - val savedSyncResourceTypes: List = - configRegistry.sharedPreferencesHelper.gson.fromJson( - savedSyncResourcesResult, - listResourceTypeToken, - ) - - assertEquals(2, savedSyncResourceTypes.size) - assertEquals(ResourceType.Task, savedSyncResourceTypes.first()) - assertEquals(ResourceType.Patient, savedSyncResourceTypes.last()) - } - @Test fun writeToFileWithMetadataResourceWithNameShouldCreateFileWithResourceName() { val parser = fhirContext.newJsonParser() - val resource = Faker.buildPatient() - val resultFile = configRegistry.writeToFile(resource) + val resource = StructureMap().apply { id = "structuremap-id-1" } + val resultFile = + KnowledgeManagerUtil.writeToFile( + configService = configService, + metadataResource = resource, + context = context, + subFilePath = + "${KnowledgeManagerUtil.KNOWLEDGE_MANAGER_ASSETS_SUBFOLDER}/${resource.resourceType}/${resource.idElement.idPart}.json", + ) assertNotNull(resultFile) assertEquals( resource.logicalId, - (parser.parseResource(resultFile.readText()) as Patient).logicalId, + (parser.parseResource(resultFile.readText()) as StructureMap).logicalId, ) } diff --git a/android/engine/src/test/java/org/smartregister/fhircore/engine/data/local/ContentCacheTest.kt b/android/engine/src/test/java/org/smartregister/fhircore/engine/data/local/ContentCacheTest.kt new file mode 100644 index 00000000000..713d0125b75 --- /dev/null +++ b/android/engine/src/test/java/org/smartregister/fhircore/engine/data/local/ContentCacheTest.kt @@ -0,0 +1,84 @@ +/* + * Copyright 2021-2024 Ona Systems, Inc + * + * 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 org.smartregister.fhircore.engine.data.local + +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import javax.inject.Inject +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.hl7.fhir.r4.model.Questionnaire +import org.hl7.fhir.r4.model.Resource +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.smartregister.fhircore.engine.robolectric.RobolectricTest +import org.smartregister.fhircore.engine.rule.CoroutineTestRule + +@OptIn(ExperimentalCoroutinesApi::class) +@HiltAndroidTest +class ContentCacheTest : RobolectricTest() { + + @get:Rule(order = 0) val hiltRule = HiltAndroidRule(this) + + @get:Rule(order = 1) val coroutineTestRule = CoroutineTestRule() + + @Inject lateinit var contentCache: ContentCache + + private val resourceId = "123" + private val mockResource: Resource = Questionnaire().apply { id = resourceId } + + @Before + fun setUp() { + hiltRule.inject() + } + + @Test + fun `saveResource should store resource in cache`() = runTest { + contentCache.saveResource(mockResource) + + val cachedResource = contentCache.getResource(mockResource.resourceType, mockResource.idPart) + assertNotNull(cachedResource) + assertEquals(mockResource.idPart, cachedResource?.idPart) + } + + @Test + fun `getResource should return the correct resource from cache`() = runTest { + contentCache.saveResource(mockResource) + + val result = contentCache.getResource(mockResource.resourceType, mockResource.idPart) + assertEquals(mockResource.idPart, result?.idPart) + } + + @Test + fun `getResource should return null if resource does not exist`() = runTest { + val result = contentCache.getResource(mockResource.resourceType, "non_existing_id") + assertNull(result) + } + + @Test + fun `invalidate should clear all resources from cache`() = runTest { + contentCache.saveResource(mockResource) + contentCache.invalidate() + + val result = contentCache.getResource(mockResource.resourceType, mockResource.idPart) + assertNull(result) + } +} diff --git a/android/engine/src/test/java/org/smartregister/fhircore/engine/data/local/DefaultRepositoryTest.kt b/android/engine/src/test/java/org/smartregister/fhircore/engine/data/local/DefaultRepositoryTest.kt index 6b5b57a2424..4dcbb39ec1e 100644 --- a/android/engine/src/test/java/org/smartregister/fhircore/engine/data/local/DefaultRepositoryTest.kt +++ b/android/engine/src/test/java/org/smartregister/fhircore/engine/data/local/DefaultRepositoryTest.kt @@ -56,11 +56,14 @@ import org.hl7.fhir.r4.model.Coding import org.hl7.fhir.r4.model.Condition import org.hl7.fhir.r4.model.ContactPoint import org.hl7.fhir.r4.model.DateTimeType +import org.hl7.fhir.r4.model.Encounter import org.hl7.fhir.r4.model.Enumerations import org.hl7.fhir.r4.model.Group import org.hl7.fhir.r4.model.HumanName +import org.hl7.fhir.r4.model.Location import org.hl7.fhir.r4.model.Organization import org.hl7.fhir.r4.model.Patient +import org.hl7.fhir.r4.model.Period import org.hl7.fhir.r4.model.Procedure import org.hl7.fhir.r4.model.Reference import org.hl7.fhir.r4.model.RelatedPerson @@ -79,6 +82,7 @@ import org.smartregister.fhircore.engine.app.fakes.Faker import org.smartregister.fhircore.engine.configuration.ConfigurationRegistry import org.smartregister.fhircore.engine.configuration.UniqueIdAssignmentConfig import org.smartregister.fhircore.engine.configuration.app.ConfigService +import org.smartregister.fhircore.engine.configuration.event.EventTriggerCondition import org.smartregister.fhircore.engine.configuration.event.EventWorkflow import org.smartregister.fhircore.engine.configuration.event.UpdateWorkflowValueConfig import org.smartregister.fhircore.engine.configuration.profile.ManagingEntityConfig @@ -119,6 +123,8 @@ class DefaultRepositoryTest : RobolectricTest() { @Inject lateinit var parser: IParser + @Inject lateinit var contentCache: ContentCache + @BindValue val configService: ConfigService = spyk(AppConfigService(ApplicationProvider.getApplicationContext())) @@ -132,8 +138,8 @@ class DefaultRepositoryTest : RobolectricTest() { @Before fun setUp() { hiltRule.inject() - dispatcherProvider = DefaultDispatcherProvider() sharedPreferenceHelper = SharedPreferencesHelper(application, gson) + dispatcherProvider = DefaultDispatcherProvider() defaultRepository = DefaultRepository( fhirEngine = fhirEngine, @@ -145,6 +151,7 @@ class DefaultRepositoryTest : RobolectricTest() { fhirPathDataExtractor = fhirPathDataExtractor, parser = parser, context = context, + contentCache = contentCache, ) } @@ -558,6 +565,7 @@ class DefaultRepositoryTest : RobolectricTest() { fhirPathDataExtractor = fhirPathDataExtractor, parser = parser, context = context, + contentCache = contentCache, ), ) coEvery { fhirEngine.search(any()) } returns @@ -636,6 +644,7 @@ class DefaultRepositoryTest : RobolectricTest() { fhirPathDataExtractor = fhirPathDataExtractor, parser = parser, context = context, + contentCache = contentCache, ), ) @@ -886,6 +895,90 @@ class DefaultRepositoryTest : RobolectricTest() { coVerify(exactly = 0) { fhirEngine.update(any()) } } + @Test + fun testNonCareplanRelatedResourcesUpdatedCorrectlyAndWithNoSpecifiedBaseResource() = runTest { + val encounter = + Encounter().apply { + id = "test-Encounter" + period = Period().apply { start = Date().plusDays(-2) } + status = Encounter.EncounterStatus.INPROGRESS + type = + listOf( + CodeableConcept().apply { + coding = + listOf( + Coding().apply { + system = "http://smartregister.org/" + code = "SVISIT" + display = "Service Point Visit" + }, + ) + text = "Service Point Visit" + }, + ) + } + val eventWorkflow = + EventWorkflow( + triggerConditions = + listOf( + EventTriggerCondition( + eventResourceId = "encounterToBeClosed", + matchAll = false, + conditionalFhirPathExpressions = + listOf( + "true", + ), + ), + ), + eventResources = + listOf( + ResourceConfig( + id = "encounterToBeClosed", + resource = ResourceType.Encounter, + configRules = listOf(), + dataQueries = + listOf( + DataQuery( + paramName = "reason-code", + filterCriteria = + listOf( + FilterCriterionConfig.TokenFilterCriterionConfig( + dataType = Enumerations.DataType.CODEABLECONCEPT, + value = Code(system = "http://smartregister.org/", code = "SVISIT"), + ), + ), + ), + ), + ), + ), + updateValues = + listOf( + UpdateWorkflowValueConfig( + jsonPathExpression = "Encounter.status", + value = JsonPrimitive("finished"), + resourceType = ResourceType.Encounter, + ), + ), + resourceFilterExpressions = + listOf( + ResourceFilterExpression( + conditionalFhirPathExpressions = listOf("Encounter.period.end < now()"), + matchAll = true, + ), + ), + ) + fhirEngine.create(encounter) + defaultRepository.updateResourcesRecursively( + resourceConfig = eventWorkflow.eventResources[0], + eventWorkflow = eventWorkflow, + ) + val resourceSlot = slot() + val captured = resourceSlot.captured + coVerify { fhirEngine.update(capture(resourceSlot)) } + Assert.assertEquals("test-Encounter", captured.id) + Assert.assertEquals(Encounter.EncounterStatus.FINISHED, captured.status) + } + @Test fun testUpdateResourcesRecursivelyClosesResource() = runTest { val patient = @@ -1479,10 +1572,52 @@ class DefaultRepositoryTest : RobolectricTest() { ) fhirEngine.create(group1, group2) - val resource = defaultRepository.retrieveUniqueIdAssignmentResource(uniqueIdAssignmentConfig) + val resource = + defaultRepository.retrieveUniqueIdAssignmentResource(uniqueIdAssignmentConfig, emptyMap()) Assert.assertNotNull(resource) Assert.assertTrue(resource is Group) Assert.assertEquals("1234", (resource as Group).characteristic[0].valueCodeableConcept.text) Assert.assertFalse(resource.characteristic[1].exclude) } + + @Test + fun testRetrieveFlattenedSubLocationsShouldReturnCorrectLocations() = + runTest(timeout = 120.seconds) { + val location1 = Location().apply { id = "loc1" } + val location2 = + Location().apply { + id = "loc2" + partOf = location1.asReference() + } + val location3 = + Location().apply { + id = "loc3" + partOf = location1.asReference() + } + val location4 = + Location().apply { + id = "loc4" + partOf = location3.asReference() + } + val location5 = + Location().apply { + id = "loc5" + partOf = location4.asReference() + } + + fhirEngine.create(location1, location2, location3, location4, location5, isLocalOnly = true) + + val location1SubLocations = + defaultRepository.retrieveFlattenedSubLocations(location1.logicalId) + Assert.assertEquals(5, location1SubLocations.size) + Assert.assertEquals(location2.logicalId, location1SubLocations[1].logicalId) + Assert.assertEquals(location3.logicalId, location1SubLocations[2].logicalId) + Assert.assertEquals(location4.logicalId, location1SubLocations[3].logicalId) + Assert.assertEquals(location5.logicalId, location1SubLocations[4].logicalId) + + val location4SubLocations = + defaultRepository.retrieveFlattenedSubLocations(location4.logicalId) + Assert.assertEquals(2, location4SubLocations.size) + Assert.assertEquals(location5.logicalId, location4SubLocations.last().logicalId) + } } diff --git a/android/engine/src/test/java/org/smartregister/fhircore/engine/data/local/register/RegisterRepositoryTest.kt b/android/engine/src/test/java/org/smartregister/fhircore/engine/data/local/register/RegisterRepositoryTest.kt index f9e14adde67..9cfc3af1929 100644 --- a/android/engine/src/test/java/org/smartregister/fhircore/engine/data/local/register/RegisterRepositoryTest.kt +++ b/android/engine/src/test/java/org/smartregister/fhircore/engine/data/local/register/RegisterRepositoryTest.kt @@ -56,6 +56,7 @@ import org.smartregister.fhircore.engine.configuration.ConfigType import org.smartregister.fhircore.engine.configuration.ConfigurationRegistry import org.smartregister.fhircore.engine.configuration.profile.ProfileConfiguration import org.smartregister.fhircore.engine.configuration.register.RegisterConfiguration +import org.smartregister.fhircore.engine.data.local.ContentCache import org.smartregister.fhircore.engine.datastore.syncLocationIdsProtoStore import org.smartregister.fhircore.engine.domain.model.ActionParameter import org.smartregister.fhircore.engine.domain.model.ActionParameterType @@ -63,7 +64,7 @@ import org.smartregister.fhircore.engine.domain.model.CountResultConfig import org.smartregister.fhircore.engine.domain.model.FhirResourceConfig import org.smartregister.fhircore.engine.domain.model.RepositoryResourceData import org.smartregister.fhircore.engine.domain.model.ResourceConfig -import org.smartregister.fhircore.engine.domain.model.SyncLocationToggleableState +import org.smartregister.fhircore.engine.domain.model.SyncLocationState import org.smartregister.fhircore.engine.robolectric.RobolectricTest import org.smartregister.fhircore.engine.rule.CoroutineTestRule import org.smartregister.fhircore.engine.rulesengine.RulesFactory @@ -104,6 +105,9 @@ class RegisterRepositoryTest : RobolectricTest() { @Inject lateinit var fhirEngine: FhirEngine @Inject lateinit var parser: IParser + + @Inject lateinit var contentCache: ContentCache + private val configurationRegistry: ConfigurationRegistry = Faker.buildTestConfigurationRegistry() private val patient = Faker.buildPatient(PATIENT_ID) private lateinit var registerRepository: RegisterRepository @@ -123,6 +127,7 @@ class RegisterRepositoryTest : RobolectricTest() { fhirPathDataExtractor = fhirPathDataExtractor, parser = parser, context = ApplicationProvider.getApplicationContext(), + contentCache = contentCache, ), ) } @@ -183,6 +188,50 @@ class RegisterRepositoryTest : RobolectricTest() { } } + @Ignore("Refactor this test") + @Test + fun countRegisterDataWithParamsAndRelatedEntityLocationFilter() { + runTest { + val paramsList = + arrayListOf( + ActionParameter( + key = "paramsName", + paramType = ActionParameterType.PARAMDATA, + value = "testing1", + dataType = DataType.STRING, + linkId = null, + ), + ActionParameter( + key = "paramName2", + paramType = ActionParameterType.PARAMDATA, + value = "testing2", + dataType = DataType.STRING, + linkId = null, + ), + ) + paramsList + .asSequence() + .filter { it.paramType == ActionParameterType.PARAMDATA && it.value.isNotEmpty() } + .associate { it.key to it.value } + val paramsMap = emptyMap() + val searchSlot = slot() + every { + registerRepository.retrieveRegisterConfiguration(PATIENT_REGISTER, emptyMap()) + } returns + RegisterConfiguration( + appId = "app", + id = PATIENT_REGISTER, + fhirResource = fhirResourceConfig(), + filterDataByRelatedEntityLocation = true, + ) + coEvery { fhirEngine.count(capture(searchSlot)) } returns 20 + val recordsCount = + registerRepository.countRegisterData(registerId = PATIENT_REGISTER, paramsMap = paramsMap) + Assert.assertEquals(ResourceType.Group, searchSlot.captured.type) + Assert.assertEquals(20, recordsCount) + } + } + @Test fun testLoadRegisterDataWithForwardAndReverseIncludedResources() = runTest(timeout = 90.seconds) { @@ -496,7 +545,7 @@ class RegisterRepositoryTest : RobolectricTest() { // Set locations ApplicationProvider.getApplicationContext() .syncLocationIdsProtoStore - .updateData { listOf(SyncLocationToggleableState(locationId, ToggleableState.On)) } + .updateData { mapOf(locationId to SyncLocationState(locationId, null, ToggleableState.On)) } // Prepare resources fhirEngine.run { diff --git a/android/engine/src/test/java/org/smartregister/fhircore/engine/datastore/ProtoDataStoreTest.kt b/android/engine/src/test/java/org/smartregister/fhircore/engine/datastore/ProtoDataStoreTest.kt index 9119f8225a7..ba04075c029 100644 --- a/android/engine/src/test/java/org/smartregister/fhircore/engine/datastore/ProtoDataStoreTest.kt +++ b/android/engine/src/test/java/org/smartregister/fhircore/engine/datastore/ProtoDataStoreTest.kt @@ -28,7 +28,6 @@ import org.junit.Before import org.junit.Rule import org.junit.Test import org.smartregister.fhircore.engine.datastore.mockdata.PractitionerDetails -import org.smartregister.fhircore.engine.datastore.mockdata.UserInfo import org.smartregister.fhircore.engine.robolectric.RobolectricTest import org.smartregister.fhircore.engine.rulesengine.services.LocationCoordinate @@ -77,15 +76,6 @@ internal class ProtoDataStoreTest : RobolectricTest() { } } - @Test - fun testWriteUserInfo() { - val valueToWrite = UserInfo(name = "Kelvin") - runTest { - protoDataStore.writeUserInfo(valueToWrite) - protoDataStore.userInfo.map { assert(it == valueToWrite) } - } - } - @Test fun testReadLocationCoordinates() { val expectedPreferencesValue = LocationCoordinate() diff --git a/android/engine/src/test/java/org/smartregister/fhircore/engine/pdf/HtmlPopulatorTest.kt b/android/engine/src/test/java/org/smartregister/fhircore/engine/pdf/HtmlPopulatorTest.kt index 2a348b43763..df51ac3af6f 100644 --- a/android/engine/src/test/java/org/smartregister/fhircore/engine/pdf/HtmlPopulatorTest.kt +++ b/android/engine/src/test/java/org/smartregister/fhircore/engine/pdf/HtmlPopulatorTest.kt @@ -35,223 +35,311 @@ class HtmlPopulatorTest { @Test fun testIsNotEmptyShouldShowContentWhenAnswerExistInQR() { - val html = "@is-not-empty('link-a')

    Text

    @is-not-empty('link-a')" - val questionnaireResponse = - QuestionnaireResponse().apply { - addItem().apply { - linkId = "link-a" - answer = buildList { - add( - QuestionnaireResponseItemAnswerComponent().apply { - value = Coding("system 1", "code 1", "display 1") - }, - ) + val html = "@is-not-empty('1234/link-a')

    Text

    @is-not-empty('1234/link-a')" + val questionnaireResponses = + listOf( + QuestionnaireResponse().apply { + id = "1234" + questionnaire = "Questionnaire/1234" + addItem().apply { + linkId = "link-a" + answer = buildList { + add( + QuestionnaireResponseItemAnswerComponent().apply { + value = Coding("system 1", "code 1", "display 1") + }, + ) + } } - } - } - val htmlPopulator = HtmlPopulator(questionnaireResponse) + }, + ) + val htmlPopulator = HtmlPopulator(questionnaireResponses) val populatedHtml = htmlPopulator.populateHtml(html) Assert.assertEquals("

    Text

    ", populatedHtml) } @Test fun testIsNotEmptyShouldHideContentWhenAnswerIsEmptyInQR() { - val html = "@is-not-empty('link-a')

    Text

    @is-not-empty('link-a')" - val questionnaireResponse = - QuestionnaireResponse().apply { - addItem().apply { - linkId = "link-a" - answer = emptyList() - } - } - val htmlPopulator = HtmlPopulator(questionnaireResponse) + val html = "@is-not-empty('1234/link-a')

    Text

    @is-not-empty('1234/link-a')" + val questionnaireResponses = + listOf( + QuestionnaireResponse().apply { + id = "1234" + questionnaire = "Questionnaire/1234" + addItem().apply { + linkId = "link-a" + answer = emptyList() + } + }, + ) + val htmlPopulator = HtmlPopulator(questionnaireResponses) val populatedHtml = htmlPopulator.populateHtml(html) Assert.assertEquals("", populatedHtml) } @Test fun testIsNotEmptyShouldHideContentWhenAnswerNotExistInQR() { - val html = "@is-not-empty('link-a')

    Text

    @is-not-empty('link-a')" - val questionnaireResponse = - QuestionnaireResponse().apply { addItem().apply { linkId = "link-a" } } - val htmlPopulator = HtmlPopulator(questionnaireResponse) + val html = "@is-not-empty('1234/link-a')

    Text

    @is-not-empty('1234/link-a')" + val questionnaireResponses = + listOf( + QuestionnaireResponse().apply { + id = "1234" + questionnaire = "Questionnaire/1234" + addItem().apply { linkId = "link-a" } + }, + ) + val htmlPopulator = HtmlPopulator(questionnaireResponses) val populatedHtml = htmlPopulator.populateHtml(html) Assert.assertEquals("", populatedHtml) } @Test fun testIsNotEmptyShouldHideContentWhenLinkIdNotExistInQR() { - val html = "@is-not-empty('link-a')

    Text

    @is-not-empty('link-a')" - val questionnaireResponse = QuestionnaireResponse() - val htmlPopulator = HtmlPopulator(questionnaireResponse) + val html = "@is-not-empty('1234/link-a')

    Text

    @is-not-empty('1234/link-a')" + val questionnaireResponses = + listOf( + QuestionnaireResponse().apply { + id = "1234" + questionnaire = "Questionnaire/1234" + }, + ) + val htmlPopulator = HtmlPopulator(questionnaireResponses) val populatedHtml = htmlPopulator.populateHtml(html) Assert.assertEquals("", populatedHtml) } @Test fun testIsNotEmptyShouldShowMalformedTagAndContentIfLinkIdOfBothTagDoesNotMatch() { - val html = "@is-not-empty('link-a')

    Text

    @is-not-empty('link-b')" - val questionnaireResponse = QuestionnaireResponse() - val htmlPopulator = HtmlPopulator(questionnaireResponse) + val html = "@is-not-empty('1234/link-a')

    Text

    @is-not-empty('1234/link-b')" + val questionnaireResponses = + listOf( + QuestionnaireResponse().apply { + id = "1234" + questionnaire = "Questionnaire/1234" + }, + ) + val htmlPopulator = HtmlPopulator(questionnaireResponses) val populatedHtml = htmlPopulator.populateHtml(html) - Assert.assertEquals("@is-not-empty('link-a')

    Text

    @is-not-empty('link-b')", populatedHtml) + Assert.assertEquals( + "@is-not-empty('1234/link-a')

    Text

    @is-not-empty('1234/link-b')", + populatedHtml, + ) } @Test fun testIsNotEmptyShouldShowMalformedTagAndContentIfOnly1TagExist() { - val html = "@is-not-empty('link-a')

    Text

    " - val questionnaireResponse = QuestionnaireResponse() - val htmlPopulator = HtmlPopulator(questionnaireResponse) + val html = "@is-not-empty('1234/link-a')

    Text

    " + val questionnaireResponses = + listOf( + QuestionnaireResponse().apply { + id = "1234" + questionnaire = "Questionnaire/1234" + }, + ) + val htmlPopulator = HtmlPopulator(questionnaireResponses) val populatedHtml = htmlPopulator.populateHtml(html) - Assert.assertEquals("@is-not-empty('link-a')

    Text

    ", populatedHtml) + Assert.assertEquals("@is-not-empty('1234/link-a')

    Text

    ", populatedHtml) } @Test fun testIsNotEmptyShouldShowContentAndNestedMalformedTagIfAnswerOfRootTagExist() { - val html = "@is-not-empty('link-a')@is-not-empty('link-b')

    Text

    @is-not-empty('link-a')" - val questionnaireResponse = - QuestionnaireResponse().apply { - addItem().apply { - linkId = "link-a" - answer = buildList { - add( - QuestionnaireResponseItemAnswerComponent().apply { - value = Coding("system 1", "code 1", "display 1") - }, - ) + val html = + "@is-not-empty('1234/link-a')@is-not-empty('1234/link-b')

    Text

    @is-not-empty('1234/link-a')" + val questionnaireResponses = + listOf( + QuestionnaireResponse().apply { + id = "1234" + questionnaire = "Questionnaire/1234" + addItem().apply { + linkId = "link-a" + answer = buildList { + add( + QuestionnaireResponseItemAnswerComponent().apply { + value = Coding("system 1", "code 1", "display 1") + }, + ) + } } - } - } - val htmlPopulator = HtmlPopulator(questionnaireResponse) + }, + ) + val htmlPopulator = HtmlPopulator(questionnaireResponses) val populatedHtml = htmlPopulator.populateHtml(html) - Assert.assertEquals("@is-not-empty('link-b')

    Text

    ", populatedHtml) + Assert.assertEquals("@is-not-empty('1234/link-b')

    Text

    ", populatedHtml) } @Test fun testIsNotEmptyShouldHideContentAndNestedMalformedTagIfAnswerOfRootTagIsNotExist() { - val html = "@is-not-empty('link-a')@is-not-empty('link-b')

    Text

    @is-not-empty('link-a')" - val questionnaireResponse = - QuestionnaireResponse().apply { addItem().apply { linkId = "link-a" } } - val htmlPopulator = HtmlPopulator(questionnaireResponse) + val html = + "@is-not-empty('1234/link-a')@is-not-empty('1234/link-b')

    Text

    @is-not-empty('1234/link-a')" + val questionnaireResponses = + listOf( + QuestionnaireResponse().apply { + id = "1234" + questionnaire = "Questionnaire/1234" + addItem().apply { linkId = "link-a" } + }, + ) + val htmlPopulator = HtmlPopulator(questionnaireResponses) val populatedHtml = htmlPopulator.populateHtml(html) Assert.assertEquals("", populatedHtml) } @Test fun testIsNotEmptyShouldHideContentAndNestedMalformedTagIfAnswerOfRootTagIsEmpty() { - val html = "@is-not-empty('link-a')@is-not-empty('link-b')

    Text

    @is-not-empty('link-a')" - val questionnaireResponse = - QuestionnaireResponse().apply { - addItem().apply { - linkId = "link-a" - answer = emptyList() - } - } - val htmlPopulator = HtmlPopulator(questionnaireResponse) + val html = + "@is-not-empty('1234/link-a')@is-not-empty('1234/link-b')

    Text

    @is-not-empty('1234/link-a')" + val questionnaireResponses = + listOf( + QuestionnaireResponse().apply { + id = "1234" + questionnaire = "Questionnaire/1234" + addItem().apply { + linkId = "link-a" + answer = emptyList() + } + }, + ) + val htmlPopulator = HtmlPopulator(questionnaireResponses) val populatedHtml = htmlPopulator.populateHtml(html) Assert.assertEquals("", populatedHtml) } @Test fun testIsNotEmptyShouldShowEmptyContentIfAnswerExist() { - val html = "@is-not-empty('link-a')@is-not-empty('link-a')" - val questionnaireResponse = - QuestionnaireResponse().apply { - addItem().apply { - linkId = "link-a" - answer = buildList { - add( - QuestionnaireResponseItemAnswerComponent().apply { - value = Coding("system 1", "code 1", "display 1") - }, - ) + val html = "@is-not-empty('1234/link-a')@is-not-empty('1234/link-a')" + val questionnaireResponses = + listOf( + QuestionnaireResponse().apply { + id = "1234" + questionnaire = "Questionnaire/1234" + addItem().apply { + linkId = "link-a" + answer = buildList { + add( + QuestionnaireResponseItemAnswerComponent().apply { + value = Coding("system 1", "code 1", "display 1") + }, + ) + } } - } - } - val htmlPopulator = HtmlPopulator(questionnaireResponse) + }, + ) + val htmlPopulator = HtmlPopulator(questionnaireResponses) val populatedHtml = htmlPopulator.populateHtml(html) Assert.assertEquals("", populatedHtml) } @Test fun testProcessAnswerAsListShouldShowAnswerAsListWhenAnswerExistInQR() { - val html = "
      @answer-as-list('link-a')
    " - val questionnaireResponse = - QuestionnaireResponse().apply { - addItem().apply { - linkId = "link-a" - answer = buildList { - add( - QuestionnaireResponseItemAnswerComponent().apply { - value = Coding("system 1", "code 1", "display 1") - }, - ) - add( - QuestionnaireResponseItemAnswerComponent().apply { - value = Coding("system 2", "code 2", "display 2") - }, - ) + val html = "
      @answer-as-list('1234/link-a')
    " + val questionnaireResponses = + listOf( + QuestionnaireResponse().apply { + id = "1234" + questionnaire = "Questionnaire/1234" + addItem().apply { + linkId = "link-a" + answer = buildList { + add( + QuestionnaireResponseItemAnswerComponent().apply { + value = Coding("system 1", "code 1", "display 1") + }, + ) + add( + QuestionnaireResponseItemAnswerComponent().apply { + value = Coding("system 2", "code 2", "display 2") + }, + ) + } } - } - } - val htmlPopulator = HtmlPopulator(questionnaireResponse) + }, + ) + val htmlPopulator = HtmlPopulator(questionnaireResponses) val populatedHtml = htmlPopulator.populateHtml(html) Assert.assertEquals("
    • display 1
    • display 2
    ", populatedHtml) } @Test fun testProcessAnswerAsListShouldShowEmptyAnswerAsListWhenAnswerNotExistInQR() { - val html = "
      @answer-as-list('link-a')
    " - val questionnaireResponse = - QuestionnaireResponse().apply { addItem().apply { linkId = "link-a" } } - val htmlPopulator = HtmlPopulator(questionnaireResponse) + val html = "
      @answer-as-list('1234/link-a')
    " + val questionnaireResponses = + listOf( + QuestionnaireResponse().apply { + id = "1234" + questionnaire = "Questionnaire/1234" + addItem().apply { linkId = "link-a" } + }, + ) + val htmlPopulator = HtmlPopulator(questionnaireResponses) val populatedHtml = htmlPopulator.populateHtml(html) Assert.assertEquals("
      ", populatedHtml) } @Test fun testProcessAnswerAsListShouldShowEmptyAnswerAsListWhenLinkIdNotExistInQR() { - val html = "
        @answer-as-list('link-a')
      " - val questionnaireResponse = QuestionnaireResponse() - val htmlPopulator = HtmlPopulator(questionnaireResponse) + val html = "
        @answer-as-list('1234/link-a')
      " + val questionnaireResponses = + listOf( + QuestionnaireResponse().apply { + id = "1234" + questionnaire = "Questionnaire/1234" + }, + ) + val htmlPopulator = HtmlPopulator(questionnaireResponses) val populatedHtml = htmlPopulator.populateHtml(html) Assert.assertEquals("
        ", populatedHtml) } @Test fun testProcessAnswerShouldShowAnswerWhenAnswerExistInQR() { - val html = "

        @answer('link-a')

        " - val questionnaireResponse = - QuestionnaireResponse().apply { - addItem().apply { - linkId = "link-a" - answer = buildList { - add( - QuestionnaireResponseItemAnswerComponent().apply { value = StringType("string 1") }, - ) + val html = "

        @answer('1234/link-a')

        " + val questionnaireResponses = + listOf( + QuestionnaireResponse().apply { + id = "1234" + questionnaire = "Questionnaire/1234" + addItem().apply { + linkId = "link-a" + answer = buildList { + add( + QuestionnaireResponseItemAnswerComponent().apply { value = StringType("string 1") }, + ) + } } - } - } - val htmlPopulator = HtmlPopulator(questionnaireResponse) + }, + ) + val htmlPopulator = HtmlPopulator(questionnaireResponses) val populatedHtml = htmlPopulator.populateHtml(html) Assert.assertEquals("

        string 1

        ", populatedHtml) } @Test fun testProcessAnswerShouldShowEmptyAnswerWhenAnswerNotExistInQR() { - val html = "

        @answer('link-a')

        " - val questionnaireResponse = - QuestionnaireResponse().apply { addItem().apply { linkId = "link-a" } } - val htmlPopulator = HtmlPopulator(questionnaireResponse) + val html = "

        @answer('1234/link-a')

        " + val questionnaireResponses = + listOf( + QuestionnaireResponse().apply { + id = "1234" + questionnaire = "Questionnaire/1234" + addItem().apply { linkId = "link-a" } + }, + ) + val htmlPopulator = HtmlPopulator(questionnaireResponses) val populatedHtml = htmlPopulator.populateHtml(html) Assert.assertEquals("

        ", populatedHtml) } @Test fun testProcessAnswerShouldShowEmptyAnswerWhenLinkIdNotExistInQR() { - val html = "

        @answer('link-a')

        " - val questionnaireResponse = QuestionnaireResponse() - val htmlPopulator = HtmlPopulator(questionnaireResponse) + val html = "

        @answer('1234/link-a')

        " + val questionnaireResponses = + listOf( + QuestionnaireResponse().apply { + id = "1234" + questionnaire = "Questionnaire/1234" + }, + ) + val htmlPopulator = HtmlPopulator(questionnaireResponses) val populatedHtml = htmlPopulator.populateHtml(html) Assert.assertEquals("

        ", populatedHtml) } @@ -260,21 +348,25 @@ class HtmlPopulatorTest { fun testProcessAnswerShouldShowDateAnswerWhenAnswerOfTypeDateExistInQR() { val calendar = Calendar.getInstance().apply { set(2024, Calendar.MAY, 14) } val specificDate: Date = calendar.time - val html = "

        @answer('link-a')

        " - val questionnaireResponse = - QuestionnaireResponse().apply { - addItem().apply { - linkId = "link-a" - answer = buildList { - add( - QuestionnaireResponseItemAnswerComponent().apply { - value = DateTimeType(specificDate) - }, - ) + val html = "

        @answer('1234/link-a')

        " + val questionnaireResponses = + listOf( + QuestionnaireResponse().apply { + id = "1234" + questionnaire = "Questionnaire/1234" + addItem().apply { + linkId = "link-a" + answer = buildList { + add( + QuestionnaireResponseItemAnswerComponent().apply { + value = DateTimeType(specificDate) + }, + ) + } } - } - } - val htmlPopulator = HtmlPopulator(questionnaireResponse) + }, + ) + val htmlPopulator = HtmlPopulator(questionnaireResponses) val populatedHtml = htmlPopulator.populateHtml(html) Assert.assertEquals("

        14-May-2024

        ", populatedHtml) } @@ -283,21 +375,25 @@ class HtmlPopulatorTest { fun testProcessAnswerShouldShowDateAnswerWithFormatWhenDateFormatExistInTheTag() { val calendar = Calendar.getInstance().apply { set(2024, Calendar.MAY, 14) } val specificDate: Date = calendar.time - val html = "

        @answer('link-a','MMMM d, yyyy')

        " - val questionnaireResponse = - QuestionnaireResponse().apply { - addItem().apply { - linkId = "link-a" - answer = buildList { - add( - QuestionnaireResponseItemAnswerComponent().apply { - value = DateTimeType(specificDate) - }, - ) + val html = "

        @answer('1234/link-a','MMMM d, yyyy')

        " + val questionnaireResponses = + listOf( + QuestionnaireResponse().apply { + id = "1234" + questionnaire = "Questionnaire/1234" + addItem().apply { + linkId = "link-a" + answer = buildList { + add( + QuestionnaireResponseItemAnswerComponent().apply { + value = DateTimeType(specificDate) + }, + ) + } } - } - } - val htmlPopulator = HtmlPopulator(questionnaireResponse) + }, + ) + val htmlPopulator = HtmlPopulator(questionnaireResponses) val populatedHtml = htmlPopulator.populateHtml(html) Assert.assertEquals("

        May 14, 2024

        ", populatedHtml) } @@ -306,10 +402,16 @@ class HtmlPopulatorTest { fun testProcessSubmittedDateShouldShow() { val calendar = Calendar.getInstance().apply { set(2024, Calendar.MAY, 14) } val specificDate: Date = calendar.time - val html = "

        @submitted-date

        " - val questionnaireResponse = - QuestionnaireResponse().apply { meta = Meta().apply { lastUpdated = specificDate } } - val htmlPopulator = HtmlPopulator(questionnaireResponse) + val html = "

        @submitted-date('1234')

        " + val questionnaireResponses = + listOf( + QuestionnaireResponse().apply { + id = "1234" + questionnaire = "Questionnaire/1234" + meta = Meta().apply { lastUpdated = specificDate } + }, + ) + val htmlPopulator = HtmlPopulator(questionnaireResponses) val populatedHtml = htmlPopulator.populateHtml(html) Assert.assertEquals("

        14-May-2024

        ", populatedHtml) } @@ -318,183 +420,249 @@ class HtmlPopulatorTest { fun testProcessSubmittedDateShouldShowWithFormatWhenDateFormatExistInTheTag() { val calendar = Calendar.getInstance().apply { set(2024, Calendar.MAY, 14) } val specificDate: Date = calendar.time - val html = "

        @submitted-date('MMMM d, yyyy')

        " - val questionnaireResponse = - QuestionnaireResponse().apply { meta = Meta().apply { lastUpdated = specificDate } } - val htmlPopulator = HtmlPopulator(questionnaireResponse) + val html = "

        @submitted-date('1234','MMMM d, yyyy')

        " + val questionnaireResponses = + listOf( + QuestionnaireResponse().apply { + id = "1234" + questionnaire = "Questionnaire/1234" + meta = Meta().apply { lastUpdated = specificDate } + }, + ) + val htmlPopulator = HtmlPopulator(questionnaireResponses) val populatedHtml = htmlPopulator.populateHtml(html) Assert.assertEquals("

        May 14, 2024

        ", populatedHtml) } @Test fun testProcessContainsShouldShowContentWhenIndicatorCodeMatchesWithAnswerOfTypeCoding() { - val html = "@contains('link-a','code 2')

        Text

        @contains('link-a')" - val questionnaireResponse = - QuestionnaireResponse().apply { - addItem().apply { - linkId = "link-a" - answer = buildList { - add( - QuestionnaireResponseItemAnswerComponent().apply { - value = Coding("system 1", "code 1", "display 1") - }, - ) - add( - QuestionnaireResponseItemAnswerComponent().apply { - value = Coding("system 2", "code 2", "display 2") - }, - ) + val html = "@contains('1234/link-a','code 2')

        Text

        @contains('1234/link-a')" + val questionnaireResponses = + listOf( + QuestionnaireResponse().apply { + id = "1234" + questionnaire = "Questionnaire/1234" + addItem().apply { + linkId = "link-a" + answer = buildList { + add( + QuestionnaireResponseItemAnswerComponent().apply { + value = Coding("system 1", "code 1", "display 1") + }, + ) + add( + QuestionnaireResponseItemAnswerComponent().apply { + value = Coding("system 2", "code 2", "display 2") + }, + ) + } } - } - } - val htmlPopulator = HtmlPopulator(questionnaireResponse) + }, + ) + val htmlPopulator = HtmlPopulator(questionnaireResponses) val populatedHtml = htmlPopulator.populateHtml(html) Assert.assertEquals("

        Text

        ", populatedHtml) } @Test fun testProcessContainsShouldHideContentWhenIndicatorCodeDoesNotMatchWithAnswerOfTypeCoding() { - val html = "@contains('link-a','code 3')

        Text

        @contains('link-a')" - val questionnaireResponse = - QuestionnaireResponse().apply { - addItem().apply { - linkId = "link-a" - answer = buildList { - add( - QuestionnaireResponseItemAnswerComponent().apply { - value = Coding("system 1", "code 1", "display 1") - }, - ) - add( - QuestionnaireResponseItemAnswerComponent().apply { - value = Coding("system 2", "code 2", "display 2") - }, - ) + val html = "@contains('1234/link-a','code 3')

        Text

        @contains('1234/link-a')" + val questionnaireResponses = + listOf( + QuestionnaireResponse().apply { + id = "1234" + questionnaire = "Questionnaire/1234" + addItem().apply { + linkId = "link-a" + answer = buildList { + add( + QuestionnaireResponseItemAnswerComponent().apply { + value = Coding("system 1", "code 1", "display 1") + }, + ) + add( + QuestionnaireResponseItemAnswerComponent().apply { + value = Coding("system 2", "code 2", "display 2") + }, + ) + } } - } - } - val htmlPopulator = HtmlPopulator(questionnaireResponse) + }, + ) + val htmlPopulator = HtmlPopulator(questionnaireResponses) val populatedHtml = htmlPopulator.populateHtml(html) Assert.assertEquals("", populatedHtml) } @Test fun testProcessContainsShouldShowContentWhenIndicatorStringIsContainedInAnswerOfTypeString() { - val html = "@contains('link-a','basket')

        Text

        @contains('link-a')" - val questionnaireResponse = - QuestionnaireResponse().apply { - addItem().apply { - linkId = "link-a" - answer = buildList { - add( - QuestionnaireResponseItemAnswerComponent().apply { value = StringType("basketball") }, - ) + val html = "@contains('1234/link-a','basket')

        Text

        @contains('1234/link-a')" + val questionnaireResponses = + listOf( + QuestionnaireResponse().apply { + id = "1234" + questionnaire = "Questionnaire/1234" + addItem().apply { + linkId = "link-a" + answer = buildList { + add( + QuestionnaireResponseItemAnswerComponent().apply { + value = StringType("basketball") + }, + ) + } } - } - } - val htmlPopulator = HtmlPopulator(questionnaireResponse) + }, + ) + val htmlPopulator = HtmlPopulator(questionnaireResponses) val populatedHtml = htmlPopulator.populateHtml(html) Assert.assertEquals("

        Text

        ", populatedHtml) } @Test fun testProcessContainsShouldShowContentWhenIndicatorIntegerMatchesAnswerOfTypeInteger() { - val html = "@contains('link-a','10')

        Text

        @contains('link-a')" - val questionnaireResponse = - QuestionnaireResponse().apply { - addItem().apply { - linkId = "link-a" - answer = buildList { - add( - QuestionnaireResponseItemAnswerComponent().apply { value = IntegerType("10") }, - ) + val html = "@contains('1234/link-a','10')

        Text

        @contains('1234/link-a')" + val questionnaireResponses = + listOf( + QuestionnaireResponse().apply { + id = "1234" + questionnaire = "Questionnaire/1234" + addItem().apply { + linkId = "link-a" + answer = buildList { + add( + QuestionnaireResponseItemAnswerComponent().apply { value = IntegerType("10") }, + ) + } } - } - } - val htmlPopulator = HtmlPopulator(questionnaireResponse) + }, + ) + val htmlPopulator = HtmlPopulator(questionnaireResponses) val populatedHtml = htmlPopulator.populateHtml(html) Assert.assertEquals("

        Text

        ", populatedHtml) } @Test fun testProcessContainsShouldShowContentWhenIndicatorDecimalMatchesAnswerOfTypeDecimal() { - val html = "@contains('link-a','1.5')

        Text

        @contains('link-a')" - val questionnaireResponse = - QuestionnaireResponse().apply { - addItem().apply { - linkId = "link-a" - answer = buildList { - add( - QuestionnaireResponseItemAnswerComponent().apply { value = DecimalType("1.5") }, - ) + val html = "@contains('1234/link-a','1.5')

        Text

        @contains('1234/link-a')" + val questionnaireResponses = + listOf( + QuestionnaireResponse().apply { + id = "1234" + questionnaire = "Questionnaire/1234" + addItem().apply { + linkId = "link-a" + answer = buildList { + add( + QuestionnaireResponseItemAnswerComponent().apply { value = DecimalType("1.5") }, + ) + } } - } - } - val htmlPopulator = HtmlPopulator(questionnaireResponse) + }, + ) + val htmlPopulator = HtmlPopulator(questionnaireResponses) val populatedHtml = htmlPopulator.populateHtml(html) Assert.assertEquals("

        Text

        ", populatedHtml) } @Test fun testProcessContainsShouldShowContentWhenIndicatorBooleanMatchesAnswerOfTypeBoolean() { - val html = "@contains('link-a','true')

        Text

        @contains('link-a')" - val questionnaireResponse = - QuestionnaireResponse().apply { - addItem().apply { - linkId = "link-a" - answer = buildList { - add( - QuestionnaireResponseItemAnswerComponent().apply { value = BooleanType("true") }, - ) + val html = "@contains('1234/link-a','true')

        Text

        @contains('1234/link-a')" + val questionnaireResponses = + listOf( + QuestionnaireResponse().apply { + id = "1234" + questionnaire = "Questionnaire/1234" + addItem().apply { + linkId = "link-a" + answer = buildList { + add( + QuestionnaireResponseItemAnswerComponent().apply { value = BooleanType("true") }, + ) + } } - } - } - val htmlPopulator = HtmlPopulator(questionnaireResponse) + }, + ) + val htmlPopulator = HtmlPopulator(questionnaireResponses) val populatedHtml = htmlPopulator.populateHtml(html) Assert.assertEquals("

        Text

        ", populatedHtml) } @Test fun testProcessContainsShouldShowContentWhenIndicatorQuantityMatchesAnswerOfTypeQuantity() { - val html = "@contains('link-a','3 years')

        Text

        @contains('link-a')" - val questionnaireResponse = - QuestionnaireResponse().apply { - addItem().apply { - linkId = "link-a" - answer = buildList { - add( - QuestionnaireResponseItemAnswerComponent().apply { - value = Quantity(null, 3, "system", "years", "years") - }, - ) + val html = "@contains('1234/link-a','3 years')

        Text

        @contains('1234/link-a')" + val questionnaireResponses = + listOf( + QuestionnaireResponse().apply { + id = "1234" + questionnaire = "Questionnaire/1234" + addItem().apply { + linkId = "link-a" + answer = buildList { + add( + QuestionnaireResponseItemAnswerComponent().apply { + value = Quantity(null, 3, "system", "years", "years") + }, + ) + } } - } - } - val htmlPopulator = HtmlPopulator(questionnaireResponse) + }, + ) + val htmlPopulator = HtmlPopulator(questionnaireResponses) val populatedHtml = htmlPopulator.populateHtml(html) Assert.assertEquals("

        Text

        ", populatedHtml) } @Test fun testProcessContainsShouldShowContentWhenIndicatorDateMatchesAnswerOfTypeDate() { - val html = "@contains('link-a','14-May-2024')

        Text

        @contains('link-a')" + val html = "@contains('1234/link-a','14-May-2024')

        Text

        @contains('1234/link-a')" val calendar = Calendar.getInstance().apply { set(2024, Calendar.MAY, 14) } val specificDate: Date = calendar.time - val questionnaireResponse = - QuestionnaireResponse().apply { - addItem().apply { - linkId = "link-a" - answer = buildList { - add( - QuestionnaireResponseItemAnswerComponent().apply { - value = DateTimeType(specificDate) - }, - ) + val questionnaireResponses = + listOf( + QuestionnaireResponse().apply { + id = "1234" + questionnaire = "Questionnaire/1234" + addItem().apply { + linkId = "link-a" + answer = buildList { + add( + QuestionnaireResponseItemAnswerComponent().apply { + value = DateTimeType(specificDate) + }, + ) + } } - } - } - val htmlPopulator = HtmlPopulator(questionnaireResponse) + }, + ) + val htmlPopulator = HtmlPopulator(questionnaireResponses) + val populatedHtml = htmlPopulator.populateHtml(html) + Assert.assertEquals("

        Text

        ", populatedHtml) + } + + @Test + fun testProcessIsQuestionnaireSubmittedShouldShowContentWhenTheRelatedQuestionnaireResponseExists() { + val html = + "@is-questionnaire-submitted('q-1234')

        Text

        @is-questionnaire-submitted('q-1234')" + val questionnaireResponses = + listOf( + QuestionnaireResponse().apply { + id = "1234" + questionnaire = "Questionnaire/q-1234" + }, + ) + val htmlPopulator = HtmlPopulator(questionnaireResponses) val populatedHtml = htmlPopulator.populateHtml(html) Assert.assertEquals("

        Text

        ", populatedHtml) } + + @Test + fun testProcessIsQuestionnaireSubmittedShouldNotShowContentWhenTheRelatedQuestionnaireResponseDoesNotExists() { + val html = + "@is-questionnaire-submitted('q-1234')

        Text

        @is-questionnaire-submitted('q-1234')" + val questionnaireResponses = listOf() + val htmlPopulator = HtmlPopulator(questionnaireResponses) + val populatedHtml = htmlPopulator.populateHtml(html) + Assert.assertEquals("", populatedHtml) + } } diff --git a/android/engine/src/test/java/org/smartregister/fhircore/engine/pdf/PdfGeneratorTest.kt b/android/engine/src/test/java/org/smartregister/fhircore/engine/pdf/PdfGeneratorTest.kt deleted file mode 100644 index ad1282e5609..00000000000 --- a/android/engine/src/test/java/org/smartregister/fhircore/engine/pdf/PdfGeneratorTest.kt +++ /dev/null @@ -1,66 +0,0 @@ -/* - * Copyright 2021-2024 Ona Systems, Inc - * - * 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 org.smartregister.fhircore.engine.pdf - -import android.content.Context -import android.print.PrintDocumentAdapter -import android.print.PrintManager -import android.webkit.WebView -import org.junit.Before -import org.junit.Test -import org.junit.runner.RunWith -import org.mockito.ArgumentMatchers.eq -import org.mockito.Mock -import org.mockito.Mockito.verify -import org.mockito.Mockito.`when` -import org.mockito.MockitoAnnotations -import org.mockito.junit.MockitoJUnitRunner - -@RunWith(MockitoJUnitRunner::class) -class PdfGeneratorTest { - - @Mock private lateinit var context: Context - - @Mock private lateinit var printManager: PrintManager - - @Mock private lateinit var webView: WebView - - @Mock private lateinit var printDocumentAdapter: PrintDocumentAdapter - - private lateinit var pdfGenerator: PdfGenerator - - @Before - fun setUp() { - MockitoAnnotations.initMocks(this) - `when`(context.getSystemService(Context.PRINT_SERVICE)).thenReturn(printManager) - pdfGenerator = PdfGenerator(context, webView) // Inject the mock webView - } - - @Test - fun testGeneratePdfWithHtml() { - val htmlContent = "

        Hello, World!

        " - val pdfTitle = "SamplePDF" - - `when`(webView.createPrintDocumentAdapter(pdfTitle)).thenReturn(printDocumentAdapter) - - pdfGenerator.generatePdfWithHtml(htmlContent, pdfTitle) - - verify(webView).loadDataWithBaseURL(null, htmlContent, "text/HTML", "UTF-8", null) - verify(webView).createPrintDocumentAdapter(pdfTitle) - verify(printManager).print(eq(pdfTitle), eq(printDocumentAdapter), eq(null)) - } -} diff --git a/android/engine/src/test/java/org/smartregister/fhircore/engine/rulesengine/ResourceDataRulesExecutorTest.kt b/android/engine/src/test/java/org/smartregister/fhircore/engine/rulesengine/ResourceDataRulesExecutorTest.kt index 7542e3f2150..1a0626c36d9 100644 --- a/android/engine/src/test/java/org/smartregister/fhircore/engine/rulesengine/ResourceDataRulesExecutorTest.kt +++ b/android/engine/src/test/java/org/smartregister/fhircore/engine/rulesengine/ResourceDataRulesExecutorTest.kt @@ -19,8 +19,10 @@ package org.smartregister.fhircore.engine.rulesengine import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.snapshots.SnapshotStateList import androidx.test.core.app.ApplicationProvider +import ca.uhn.fhir.context.FhirContext import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidTest +import io.mockk.mockk import io.mockk.spyk import java.util.LinkedList import javax.inject.Inject @@ -39,7 +41,8 @@ import org.smartregister.fhircore.engine.app.fakes.Faker import org.smartregister.fhircore.engine.configuration.ConfigurationRegistry import org.smartregister.fhircore.engine.configuration.register.RegisterCardConfig import org.smartregister.fhircore.engine.configuration.view.ListProperties -import org.smartregister.fhircore.engine.configuration.view.ListResource +import org.smartregister.fhircore.engine.configuration.view.ListResourceConfig +import org.smartregister.fhircore.engine.data.local.DefaultRepository import org.smartregister.fhircore.engine.domain.model.RepositoryResourceData import org.smartregister.fhircore.engine.domain.model.ResourceData import org.smartregister.fhircore.engine.domain.model.RuleConfig @@ -69,10 +72,14 @@ class ResourceDataRulesExecutorTest : RobolectricTest() { private lateinit var rulesFactory: RulesFactory private lateinit var resourceDataRulesExecutor: ResourceDataRulesExecutor + @Inject lateinit var fhirContext: FhirContext + private lateinit var defaultRepository: DefaultRepository + @Before @kotlinx.coroutines.ExperimentalCoroutinesApi fun setUp() { hiltAndroidRule.inject() + defaultRepository = mockk(relaxed = true) rulesFactory = spyk( RulesFactory( @@ -81,6 +88,8 @@ class ResourceDataRulesExecutorTest : RobolectricTest() { fhirPathDataExtractor = fhirPathDataExtractor, dispatcherProvider = dispatcherProvider, locationService = locationService, + fhirContext = fhirContext, + defaultRepository = defaultRepository, ), ) resourceDataRulesExecutor = ResourceDataRulesExecutor(rulesFactory) @@ -149,7 +158,7 @@ class ResourceDataRulesExecutorTest : RobolectricTest() { val anotherPatient = Faker.buildPatient(id = "anotherPatient", given = "Abel", family = "Mandela") val listResource = - ListResource( + ListResourceConfig( "id", resourceType = ResourceType.Patient, sortConfig = @@ -202,7 +211,7 @@ class ResourceDataRulesExecutorTest : RobolectricTest() { val viewType = ViewType.CARD val patient = Faker.buildPatient() val listResource = - ListResource( + ListResourceConfig( "id", resourceType = ResourceType.Patient, conditionalFhirPathExpression = "Patient.active", @@ -245,13 +254,13 @@ class ResourceDataRulesExecutorTest : RobolectricTest() { val viewType = ViewType.CARD val patient = Faker.buildPatient() val listResource = - ListResource( + ListResourceConfig( "id", resourceType = ResourceType.Patient, conditionalFhirPathExpression = "Patient.active", relatedResources = listOf( - ListResource( + ListResourceConfig( null, resourceType = ResourceType.Task, fhirPathExpression = "Task.for.reference", @@ -311,12 +320,12 @@ class ResourceDataRulesExecutorTest : RobolectricTest() { val patient = Faker.buildPatient() val anotherPatient = Faker.buildPatient("anotherId") val listResource = - ListResource( + ListResourceConfig( resourceType = ResourceType.Patient, conditionalFhirPathExpression = "Patient.active", relatedResources = listOf( - ListResource( + ListResourceConfig( id = patientReadyTasks, resourceType = ResourceType.Task, conditionalFhirPathExpression = "Task.status = 'ready'", diff --git a/android/engine/src/test/java/org/smartregister/fhircore/engine/rulesengine/RulesEngineServiceTest.kt b/android/engine/src/test/java/org/smartregister/fhircore/engine/rulesengine/RulesEngineServiceTest.kt index c919bd3d913..d2ee25343d2 100644 --- a/android/engine/src/test/java/org/smartregister/fhircore/engine/rulesengine/RulesEngineServiceTest.kt +++ b/android/engine/src/test/java/org/smartregister/fhircore/engine/rulesengine/RulesEngineServiceTest.kt @@ -37,7 +37,6 @@ import org.junit.Test import org.smartregister.fhircore.engine.domain.model.RelatedResourceCount import org.smartregister.fhircore.engine.domain.model.ServiceStatus import org.smartregister.fhircore.engine.robolectric.RobolectricTest -import org.smartregister.fhircore.engine.util.extension.plusDays @HiltAndroidTest class RulesEngineServiceTest : RobolectricTest() { @@ -178,6 +177,14 @@ class RulesEngineServiceTest : RobolectricTest() { ) } + @Test + fun `generateTaskServiceStatus() should return en empty string when Task is NULL`() { + Assert.assertEquals( + "", + rulesEngineService.generateTaskServiceStatus(null), + ) + } + @Test fun `generateTaskServiceStatus() should return UPCOMING when Task#status is NULL`() { val task = Task().apply { status = Task.TaskStatus.NULL } @@ -289,6 +296,104 @@ class RulesEngineServiceTest : RobolectricTest() { ) } + @Test + fun `generateListTaskServiceStatus() should return OVERDUE when list have Task#status is READY but date passed`() { + val taskList = ArrayList() + val task = Task().apply { status = Task.TaskStatus.REQUESTED } + taskList.add(task) + + val sdf = SimpleDateFormat("dd/MM/yyyy") + val startDate: Date? = sdf.parse("01/01/2023") + val endDate: Date? = sdf.parse("01/02/2023") + + val taskOver = + Task().apply { + status = Task.TaskStatus.READY + executionPeriod = + Period().apply { + start = startDate + end = endDate + } + } + + taskList.add(taskOver) + Assert.assertTrue( + rulesEngineService.taskServiceStatusExist(taskList, ServiceStatus.OVERDUE.name), + ) + } + + @Test + fun `generateListTaskServiceStatus() should return DUE when list have Task#status is READY`() { + val taskList = ArrayList() + val task0 = Task().apply { status = Task.TaskStatus.READY } + val task1 = Task().apply { status = Task.TaskStatus.INPROGRESS } + val task2 = Task().apply { status = Task.TaskStatus.RECEIVED } + val task3 = Task().apply { status = Task.TaskStatus.CANCELLED } + val task4 = Task().apply { status = Task.TaskStatus.REJECTED } + taskList.add(task0) + taskList.add(task1) + taskList.add(task2) + taskList.add(task3) + taskList.add(task4) + + Assert.assertTrue(rulesEngineService.taskServiceStatusExist(taskList, ServiceStatus.DUE.name)) + } + + @Test + fun testGenerateListTaskServiceStatusShouldReturnUPCOMINGWhenListHaveTaskStatusIsREQUESTED() { + val taskList = ArrayList() + val task0 = Task().apply { status = Task.TaskStatus.REQUESTED } + val task2 = Task().apply { status = Task.TaskStatus.COMPLETED } + val task3 = Task().apply { status = Task.TaskStatus.RECEIVED } + + taskList.add(task0) + taskList.add(task2) + taskList.add(task3) + + Assert.assertTrue( + rulesEngineService.taskServiceStatusExist(taskList, ServiceStatus.UPCOMING.name), + ) + } + + @Test + fun testGenerateListTaskServiceStatusShouldReturnINPROGRESSWhenListHaveTaskStatusIsINPROGRESS() { + val taskList = ArrayList() + val task1 = Task().apply { status = Task.TaskStatus.INPROGRESS } + val task2 = Task().apply { status = Task.TaskStatus.COMPLETED } + val task3 = Task().apply { status = Task.TaskStatus.CANCELLED } + taskList.add(task1) + taskList.add(task2) + taskList.add(task3) + + Assert.assertTrue( + rulesEngineService.taskServiceStatusExist(taskList, ServiceStatus.IN_PROGRESS.name), + ) + } + + @Test + fun testGenerateListTaskServiceStatusShouldReturnEXPIREDWhenListHaveTaskStatusIsEXPIRED() { + val taskList = ArrayList() + val task2 = Task().apply { status = Task.TaskStatus.COMPLETED } + val task3 = Task().apply { status = Task.TaskStatus.CANCELLED } + taskList.add(task2) + taskList.add(task3) + + Assert.assertTrue( + rulesEngineService.taskServiceStatusExist(taskList, ServiceStatus.EXPIRED.name), + ) + } + + @Test + fun testGenerateListTaskServiceStatusShouldReturnCOMPLETEDWhenListHaveTaskStatusIsCOMPLETED() { + val taskList = ArrayList() + val task2 = Task().apply { status = Task.TaskStatus.COMPLETED } + taskList.add(task2) + + Assert.assertTrue( + rulesEngineService.taskServiceStatusExist(taskList, ServiceStatus.COMPLETED.name), + ) + } + @Test fun testFilterResourcesWithFhirPathExtraction() { val task = diff --git a/android/engine/src/test/java/org/smartregister/fhircore/engine/rulesengine/RulesFactoryTest.kt b/android/engine/src/test/java/org/smartregister/fhircore/engine/rulesengine/RulesFactoryTest.kt index 0048d0330d3..246507880a7 100644 --- a/android/engine/src/test/java/org/smartregister/fhircore/engine/rulesengine/RulesFactoryTest.kt +++ b/android/engine/src/test/java/org/smartregister/fhircore/engine/rulesengine/RulesFactoryTest.kt @@ -17,9 +17,13 @@ package org.smartregister.fhircore.engine.rulesengine import androidx.test.core.app.ApplicationProvider +import ca.uhn.fhir.context.FhirContext import com.google.android.fhir.datacapture.extensions.logicalId import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidTest +import io.mockk.Called +import io.mockk.coEvery +import io.mockk.coVerify import io.mockk.every import io.mockk.just import io.mockk.mockk @@ -29,6 +33,7 @@ import io.mockk.spyk import io.mockk.verify import java.util.Date import javax.inject.Inject +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.runTest import org.apache.commons.jexl3.JexlException import org.hl7.fhir.r4.model.CodeableConcept @@ -36,6 +41,8 @@ import org.hl7.fhir.r4.model.Coding import org.hl7.fhir.r4.model.Condition import org.hl7.fhir.r4.model.Enumerations import org.hl7.fhir.r4.model.Group +import org.hl7.fhir.r4.model.ListResource +import org.hl7.fhir.r4.model.Location import org.hl7.fhir.r4.model.Observation import org.hl7.fhir.r4.model.Patient import org.hl7.fhir.r4.model.Resource @@ -56,6 +63,7 @@ import org.junit.Test import org.robolectric.util.ReflectionHelpers import org.smartregister.fhircore.engine.app.fakes.Faker import org.smartregister.fhircore.engine.configuration.ConfigurationRegistry +import org.smartregister.fhircore.engine.data.local.DefaultRepository import org.smartregister.fhircore.engine.domain.model.RelatedResourceCount import org.smartregister.fhircore.engine.domain.model.RepositoryResourceData import org.smartregister.fhircore.engine.domain.model.RuleConfig @@ -64,6 +72,8 @@ import org.smartregister.fhircore.engine.rule.CoroutineTestRule import org.smartregister.fhircore.engine.rulesengine.services.LocationService import org.smartregister.fhircore.engine.util.DispatcherProvider import org.smartregister.fhircore.engine.util.extension.SDF_YYYY_MM_DD +import org.smartregister.fhircore.engine.util.extension.asReference +import org.smartregister.fhircore.engine.util.extension.plusYears import org.smartregister.fhircore.engine.util.fhirpath.FhirPathDataExtractor @HiltAndroidTest @@ -84,10 +94,14 @@ class RulesFactoryTest : RobolectricTest() { private lateinit var rulesFactory: RulesFactory private lateinit var rulesEngineService: RulesFactory.RulesEngineService + @Inject lateinit var fhirContext: FhirContext + private lateinit var defaultRepository: DefaultRepository + @Before @kotlinx.coroutines.ExperimentalCoroutinesApi fun setUp() { hiltAndroidRule.inject() + defaultRepository = mockk(relaxed = true) rulesFactory = spyk( RulesFactory( @@ -96,6 +110,8 @@ class RulesFactoryTest : RobolectricTest() { fhirPathDataExtractor = fhirPathDataExtractor, dispatcherProvider = dispatcherProvider, locationService = locationService, + fhirContext = fhirContext, + defaultRepository = defaultRepository, ), ) rulesEngineService = rulesFactory.RulesEngineService() @@ -283,6 +299,29 @@ class RulesFactoryTest : RobolectricTest() { Assert.assertEquals("careplan-1", result[0].logicalId) } + @Test + fun retrieveRelatedResourcesReturnsCorrectResourceWithForwardInclude() { + val patient = Faker.buildPatient() + val group = + Group().apply { + id = "grp1" + addMember( + Group.GroupMemberComponent().apply { entity = patient.asReference() }, + ) + } + populateFactsWithResources(group) + val result = + rulesEngineService.retrieveRelatedResources( + resource = group, + relatedResourceKey = ResourceType.Patient.name, + referenceFhirPathExpression = "Group.member.entity.reference", + isRevInclude = false, + ) + Assert.assertEquals(1, result.size) + Assert.assertEquals("Patient", result[0].resourceType.name) + Assert.assertEquals(patient.logicalId, result[0].logicalId) + } + @Test fun retrieveRelatedResourcesWithoutReferenceReturnsResources() { populateFactsWithResources() @@ -410,7 +449,7 @@ class RulesFactoryTest : RobolectricTest() { @Test fun mapResourceToLabeledCSVReturnsCorrectLabels() { val fhirPathExpression = "Patient.active and (Patient.birthDate >= today() - 5 'years')" - val resource = Patient().setActive(true).setBirthDate(LocalDate.parse("2019-10-03").toDate()) + val resource = Patient().setActive(true).setBirthDate(Date().plusYears(-5)) val result = rulesEngineService.mapResourceToLabeledCSV(resource, fhirPathExpression, "CHILD") Assert.assertEquals("CHILD", result) @@ -872,13 +911,16 @@ class RulesFactoryTest : RobolectricTest() { Assert.assertTrue(result.isEmpty()) } - private fun populateFactsWithResources() { + private fun populateFactsWithResources(vararg resource: Resource = emptyArray()) { val carePlanRelatedResource = mutableListOf(Faker.buildCarePlan()) - val patientRelatedResource = mutableListOf(Faker.buildPatient()) + val patient = Faker.buildPatient() + val patientRelatedResource = mutableListOf(patient) + val facts = ReflectionHelpers.getField(rulesFactory, "facts") facts.apply { put(carePlanRelatedResource[0].resourceType.name, carePlanRelatedResource) put(patientRelatedResource[0].resourceType.name, patientRelatedResource) + resource.forEach { put(it.resourceType.name, it) } } ReflectionHelpers.setField(rulesFactory, "facts", facts) } @@ -1016,4 +1058,308 @@ class RulesFactoryTest : RobolectricTest() { rulesEngineService.extractPractitionerInfoFromSharedPrefs(sharedPreferenceKey) } } + + @Test + fun testUpdateResourceWithNullResource() { + rulesEngineService.updateResource(null, "List.entry[0].item.reference", "new-ref") + verify { defaultRepository wasNot Called } + } + + @Test + fun testUpdateResourceWithNullPath() { + val resource = ListResource().apply { id = "list1" } + rulesEngineService.updateResource(resource, null, "new-value") + verify { defaultRepository wasNot Called } + } + + @Test + fun testUpdateResourceWithEmptyPath() { + val resource = ListResource().apply { id = "list1" } + rulesEngineService.updateResource(resource, "", "new-value") + verify { defaultRepository wasNot Called } + } + + @Test + fun testUpdateResourceWithValidResourceAndPathAndPurgeAffectedResourcesIsTrue() { + val resource = + ListResource().apply { + id = "list1" + addEntry().apply { item.apply { reference = "old-ref" } } + } + + runBlocking { + coEvery { defaultRepository.purge(any(), any()) } returns Unit + + rulesEngineService.updateResource( + resource = resource, + path = "List.entry[0].item.reference", + value = "Group/new-ref", + purgeAffectedResources = true, + createLocalChangeEntitiesAfterPurge = true, + ) + + coVerify { + defaultRepository.purge( + withArg { + Assert.assertEquals("Group/new-ref", (it as ListResource).entry[0].item.reference) + }, + any(), + ) + } + + coVerify { + defaultRepository.addOrUpdate( + any(), + withArg { + Assert.assertEquals("Group/new-ref", (it as ListResource).entry[0].item.reference) + }, + ) + } + } + } + + @Test + fun testUpdateResourceWithValidResourceAndPathAndPurgeAffectedResourcesIsTrueAndPathStartsWithDollarSign() { + val resource = + ListResource().apply { + id = "list1" + addEntry().apply { item.apply { reference = "old-ref" } } + } + + runBlocking { + coEvery { defaultRepository.purge(any(), any()) } returns Unit + + rulesEngineService.updateResource( + resource = resource, + path = "$.entry[0].item.reference", + value = "Group/new-ref", + purgeAffectedResources = true, + createLocalChangeEntitiesAfterPurge = true, + ) + + coVerify { + defaultRepository.purge( + withArg { + Assert.assertEquals("Group/new-ref", (it as ListResource).entry[0].item.reference) + }, + any(), + ) + } + + coVerify { + defaultRepository.addOrUpdate( + any(), + withArg { + Assert.assertEquals("Group/new-ref", (it as ListResource).entry[0].item.reference) + }, + ) + } + } + } + + @Test + fun testUpdateResourceWithValidResourceAndPathAndPurgeAffectedResourcesAndCreateLocalChangeEntitiesAfterPurgeAreTrue() { + val resource = + ListResource().apply { + id = "list1" + addEntry().apply { item.apply { reference = "old-ref" } } + } + + runBlocking { + coEvery { defaultRepository.purge(any(), any()) } returns Unit + coEvery { defaultRepository.addOrUpdate(any(), any()) } returns Unit + + rulesEngineService.updateResource( + resource = resource, + path = "List.entry[0].item.reference", + value = "Group/new-ref", + purgeAffectedResources = true, + createLocalChangeEntitiesAfterPurge = true, + ) + + coVerify { + defaultRepository.purge( + withArg { + Assert.assertEquals("Group/new-ref", (it as ListResource).entry[0].item.reference) + }, + any(), + ) + } + + coVerify { + defaultRepository.addOrUpdate( + any(), + withArg { + Assert.assertEquals("Group/new-ref", (it as ListResource).entry[0].item.reference) + }, + ) + } + } + } + + @Test + fun testUpdateResourceWithValidResourceAndPathAndPurgeAffectedResourcesIsFalseAndCreateLocalChangeEntitiesAfterPurgeIsTrue() { + val resource = + ListResource().apply { + id = "list1" + addEntry().apply { item.apply { reference = "old-ref" } } + } + + runBlocking { + coEvery { defaultRepository.addOrUpdate(any(), any()) } returns Unit + + rulesEngineService.updateResource( + resource = resource, + path = "List.entry[0].item.reference", + value = "Group/new-ref", + purgeAffectedResources = true, + createLocalChangeEntitiesAfterPurge = true, + ) + + coVerify { + defaultRepository.addOrUpdate( + any(), + withArg { + Assert.assertEquals("Group/new-ref", (it as ListResource).entry[0].item.reference) + }, + ) + } + } + } + + @Test + fun testFilterResourcesByJsonPathWithNullResources() { + val results = + rulesEngineService.filterResourcesByJsonPath(null, "$.resourceType", "STRING", "Group", 0) + Assert.assertNull(results) + } + + @Test + fun testFilterResourcesByJsonPathWithBlankResources() { + val results = + rulesEngineService.filterResourcesByJsonPath(listOf(), "$.resourceType", "STRING", "Group", 0) + Assert.assertNull(results) + } + + @Test + fun testFilterResourcesByJsonPathWithBlankJsonPathExpression() { + val results = + rulesEngineService.filterResourcesByJsonPath(getListOfResource(), "", "STRING", "Group", 0) + Assert.assertNull(results) + } + + @Test + fun testFilterResourcesByJsonPathWithInvalidJsonPathExpression() { + val results = + rulesEngineService.filterResourcesByJsonPath( + getListOfResource(), + "$.date", + "STRING", + "Group", + 0, + ) + Assert.assertNull(results) + } + + @Test + fun testFilterResourcesByJsonPathWithInvalidDataType() { + val results = + rulesEngineService.filterResourcesByJsonPath( + getListOfResource(), + "$.resourceType", + "code", + "Group", + 0, + ) + Assert.assertEquals(0, results?.size) + } + + @Test + fun testFilterResourcesByJsonPathFieldWithValidResourcesAndJsonPathExpressionAndDataTypeAndCompareToResultAndNonExistentValue() { + val results = + rulesEngineService.filterResourcesByJsonPath( + getListOfResource(), + "$.resourceType", + "STRING", + "Patient", + 0, + ) + + Assert.assertEquals(0, results?.size) + } + + @Test + fun testFilterResourcesByJsonPathFieldWithValidResourcesAndJsonPathExpressionAndDataTypeAndValueAndCompareToResult() { + val results = + rulesEngineService.filterResourcesByJsonPath( + getListOfResource(), + "$.resourceType", + "STRING", + "Group", + 0, + ) + + Assert.assertEquals(2, results?.size) + with(results?.first() as Resource) { + Assert.assertEquals("group-id-1", id) + Assert.assertEquals("Group", resourceType.name) + } + } + + @Test + fun mapResourcesToExtractedValuesReturnsCorrectlyFormattedString() { + val patientsList = + listOf( + Patient().apply { + birthDate = LocalDate.parse("2015-10-03").toDate() + addName().apply { family = "alpha" } + }, + Patient().apply { + birthDate = LocalDate.parse("2017-10-03").toDate() + addName().apply { family = "beta" } + }, + Patient().apply { + birthDate = LocalDate.parse("2018-10-03").toDate() + addName().apply { family = "gamma" } + }, + ) + + val names = + rulesEngineService.mapResourcesToExtractedValues(patientsList, "Patient.name.family", " | ") + Assert.assertEquals("alpha | beta | gamma", names) + } + + @Test + fun mapResourcesToExtractedValuesReturnsEmptyStringWhenFhirPathExpressionIsEmpty() { + val patientsList = + listOf( + Patient().apply { + birthDate = LocalDate.parse("2015-10-03").toDate() + addName().apply { family = "alpha" } + }, + Patient().apply { + birthDate = LocalDate.parse("2017-10-03").toDate() + addName().apply { family = "beta" } + }, + Patient().apply { + birthDate = LocalDate.parse("2018-10-03").toDate() + addName().apply { family = "gamma" } + }, + ) + + val names = rulesEngineService.mapResourcesToExtractedValues(patientsList, "", " | ") + Assert.assertEquals("", names) + } + + private fun getListOfResource(): List { + return listOf( + Group().apply { id = "group-id-1" }, + Location().apply { id = "location-id-1" }, + ListResource().apply { + id = "list-id-1" + addEntry().apply { item.apply { reference = "Group/group-id-1" } } + }, + Group().apply { id = "group-id-2" }, + ) + } } diff --git a/android/engine/src/test/java/org/smartregister/fhircore/engine/sync/AppSyncWorkerTest.kt b/android/engine/src/test/java/org/smartregister/fhircore/engine/sync/AppSyncWorkerTest.kt index 46dbf9b34cc..c423e4e6eef 100644 --- a/android/engine/src/test/java/org/smartregister/fhircore/engine/sync/AppSyncWorkerTest.kt +++ b/android/engine/src/test/java/org/smartregister/fhircore/engine/sync/AppSyncWorkerTest.kt @@ -21,9 +21,10 @@ import androidx.work.impl.utils.taskexecutor.TaskExecutor import com.google.android.fhir.FhirEngine import com.google.android.fhir.sync.AcceptLocalConflictResolver import com.google.android.fhir.sync.ParamMap +import io.mockk.coEvery +import io.mockk.coVerify import io.mockk.every import io.mockk.mockk -import io.mockk.verify import org.hl7.fhir.r4.model.ResourceType import org.junit.Assert import org.junit.Test @@ -41,13 +42,13 @@ class AppSyncWorkerTest : RobolectricTest() { every { taskExecutor.serialTaskExecutor } returns mockk() every { workerParams.taskExecutor } returns taskExecutor - every { syncListenerManager.loadSyncParams() } returns syncParams + coEvery { syncListenerManager.loadResourceSearchParams() } returns syncParams val appSyncWorker = AppSyncWorker(mockk(), workerParams, syncListenerManager, fhirEngine, timeContext) appSyncWorker.getDownloadWorkManager() - verify { syncListenerManager.loadSyncParams() } + coVerify { syncListenerManager.loadResourceSearchParams() } Assert.assertEquals(AcceptLocalConflictResolver, appSyncWorker.getConflictResolver()) Assert.assertEquals(fhirEngine, appSyncWorker.getFhirEngine()) diff --git a/android/engine/src/test/java/org/smartregister/fhircore/engine/sync/CustomSyncWorkerTest.kt b/android/engine/src/test/java/org/smartregister/fhircore/engine/sync/CustomSyncWorkerTest.kt new file mode 100644 index 00000000000..9368f37fac6 --- /dev/null +++ b/android/engine/src/test/java/org/smartregister/fhircore/engine/sync/CustomSyncWorkerTest.kt @@ -0,0 +1,184 @@ +/* + * Copyright 2021-2024 Ona Systems, Inc + * + * 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 org.smartregister.fhircore.engine.sync + +import android.content.Context +import android.util.Log +import androidx.compose.ui.state.ToggleableState +import androidx.test.core.app.ApplicationProvider +import androidx.work.Configuration +import androidx.work.ListenableWorker +import androidx.work.WorkerFactory +import androidx.work.WorkerParameters +import androidx.work.testing.SynchronousExecutor +import androidx.work.testing.TestListenableWorkerBuilder +import androidx.work.testing.WorkManagerTestInitHelper +import com.google.gson.Gson +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import io.mockk.mockk +import io.mockk.spyk +import java.util.UUID +import javax.inject.Inject +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.runTest +import org.hl7.fhir.r4.model.ResourceType +import org.junit.Assert +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.smartregister.fhircore.engine.app.fakes.Faker +import org.smartregister.fhircore.engine.configuration.ConfigurationRegistry +import org.smartregister.fhircore.engine.data.remote.fhir.resource.FhirResourceDataSource +import org.smartregister.fhircore.engine.data.remote.fhir.resource.FhirResourceService +import org.smartregister.fhircore.engine.datastore.syncLocationIdsProtoStore +import org.smartregister.fhircore.engine.domain.model.SyncLocationState +import org.smartregister.fhircore.engine.robolectric.RobolectricTest +import org.smartregister.fhircore.engine.rule.CoroutineTestRule +import org.smartregister.fhircore.engine.util.DispatcherProvider +import org.smartregister.fhircore.engine.util.SharedPreferencesHelper + +@HiltAndroidTest +class CustomSyncWorkerTest : RobolectricTest() { + + @kotlinx.coroutines.ExperimentalCoroutinesApi + private val resourceService: FhirResourceService = mockk() + + @OptIn(ExperimentalCoroutinesApi::class) + private var fhirResourceDataSource: FhirResourceDataSource = + spyk(FhirResourceDataSource(resourceService)) + + @get:Rule(order = 0) val hiltRule = HiltAndroidRule(this) + + @get:Rule(order = 1) val coroutineTestRule = CoroutineTestRule() + + private lateinit var sharedPreferencesHelper: SharedPreferencesHelper + + @Inject lateinit var dispatcherProvider: DispatcherProvider + + private lateinit var configurationRegistry: ConfigurationRegistry + private lateinit var customSyncWorker: CustomSyncWorker + + @Before + @kotlinx.coroutines.ExperimentalCoroutinesApi + fun setUp() { + hiltRule.inject() + initializeWorkManager() + } + + @Test + fun `should create sync worker with expected properties`() { + sharedPreferencesHelper = + SharedPreferencesHelper(ApplicationProvider.getApplicationContext(), Gson()) + configurationRegistry = Faker.buildTestConfigurationRegistry(sharedPreferencesHelper) + + customSyncWorker = + TestListenableWorkerBuilder( + ApplicationProvider.getApplicationContext(), + ) + .setWorkerFactory(CustomSyncWorkerFactory()) + .build() + + val expected = runBlocking { customSyncWorker.doWork() } + + Assert.assertEquals(ListenableWorker.Result.success(), expected) + } + + @Test + fun `should create sync worker with organization`() = runTest { + sharedPreferencesHelper = + SharedPreferencesHelper(ApplicationProvider.getApplicationContext(), Gson()) + + val organizationId1 = "organization-id1" + val organizationId2 = "organization-id2" + sharedPreferencesHelper.write( + ResourceType.Organization.name, + listOf(organizationId1, organizationId2), + ) + val locationId = UUID.randomUUID().toString() + sharedPreferencesHelper.context.syncLocationIdsProtoStore.updateData { + mapOf( + locationId to SyncLocationState(locationId, null, ToggleableState.On), + ) + } + configurationRegistry = Faker.buildTestConfigurationRegistry(sharedPreferencesHelper) + + customSyncWorker = + TestListenableWorkerBuilder( + ApplicationProvider.getApplicationContext(), + ) + .setWorkerFactory(CustomSyncWorkerFactory()) + .build() + + val expected = runBlocking { customSyncWorker.doWork() } + + Assert.assertEquals(ListenableWorker.Result.success(), expected) + } + + private fun writePrefs(key: String, value: String) { + if (::sharedPreferencesHelper.isInitialized) { + sharedPreferencesHelper.write(key, value) + } + } + + @Test + fun `should create sync worker with failure results`() { + configurationRegistry = Faker.buildTestConfigurationRegistry() + + customSyncWorker = + TestListenableWorkerBuilder( + ApplicationProvider.getApplicationContext(), + ) + .setWorkerFactory(CustomSyncWorkerFactory()) + .build() + + val expected = runBlocking { customSyncWorker.doWork() } + + Assert.assertEquals(ListenableWorker.Result.failure(), expected) + } + + private fun initializeWorkManager() { + val config: Configuration = + Configuration.Builder() + .setMinimumLoggingLevel(Log.DEBUG) + .setExecutor(SynchronousExecutor()) + .build() + + // Initialize WorkManager for instrumentation tests. + WorkManagerTestInitHelper.initializeTestWorkManager( + ApplicationProvider.getApplicationContext(), + config, + ) + } + + inner class CustomSyncWorkerFactory : WorkerFactory() { + override fun createWorker( + appContext: Context, + workerClassName: String, + workerParameters: WorkerParameters, + ): ListenableWorker { + return CustomSyncWorker( + appContext = appContext, + workerParams = workerParameters, + configurationRegistry = configurationRegistry, + dispatcherProvider = dispatcherProvider, + fhirResourceDataSource = fhirResourceDataSource, + ) + } + } +} diff --git a/android/engine/src/test/java/org/smartregister/fhircore/engine/sync/SyncBroadcasterTest.kt b/android/engine/src/test/java/org/smartregister/fhircore/engine/sync/SyncBroadcasterTest.kt index 215f716330b..b8b883b5c1e 100644 --- a/android/engine/src/test/java/org/smartregister/fhircore/engine/sync/SyncBroadcasterTest.kt +++ b/android/engine/src/test/java/org/smartregister/fhircore/engine/sync/SyncBroadcasterTest.kt @@ -17,6 +17,7 @@ package org.smartregister.fhircore.engine.sync import androidx.test.core.app.ApplicationProvider +import androidx.work.WorkManager import com.google.android.fhir.FhirEngine import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidTest @@ -26,6 +27,7 @@ import io.mockk.mockk import io.mockk.spyk import javax.inject.Inject import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest import org.hl7.fhir.r4.model.ResourceType import org.junit.Assert import org.junit.Before @@ -37,7 +39,6 @@ import org.smartregister.fhircore.engine.configuration.app.ConfigService import org.smartregister.fhircore.engine.robolectric.RobolectricTest import org.smartregister.fhircore.engine.rule.CoroutineTestRule import org.smartregister.fhircore.engine.util.DefaultDispatcherProvider -import org.smartregister.fhircore.engine.util.SharedPreferenceKey import org.smartregister.fhircore.engine.util.SharedPreferencesHelper import org.smartregister.fhircore.engine.util.extension.isIn @@ -54,7 +55,9 @@ class SyncBroadcasterTest : RobolectricTest() { @Inject lateinit var configService: ConfigService @Inject lateinit var dispatcherProvider: DefaultDispatcherProvider - private val configurationRegistry: ConfigurationRegistry = Faker.buildTestConfigurationRegistry() + + @Inject lateinit var workManager: WorkManager + private lateinit var configurationRegistry: ConfigurationRegistry private val fhirEngine = mockk() private lateinit var syncListenerManager: SyncListenerManager private lateinit var syncBroadcaster: SyncBroadcaster @@ -64,6 +67,8 @@ class SyncBroadcasterTest : RobolectricTest() { fun setup() { hiltAndroidRule.inject() MockKAnnotations.init(this) + configurationRegistry = + Faker.buildTestConfigurationRegistry(sharedPreferencesHelper, dispatcherProvider) syncListenerManager = SyncListenerManager( configService = configService, @@ -80,36 +85,18 @@ class SyncBroadcasterTest : RobolectricTest() { fhirEngine = fhirEngine, dispatcherProvider = dispatcherProvider, syncListenerManager = syncListenerManager, + workManager = workManager, context = context, ), ) } @Test - fun testLoadSyncParamsShouldLoadFromConfiguration() { + fun testLoadSyncParamsShouldLoadFromConfiguration() = runTest { sharedPreferencesHelper.write(ResourceType.CareTeam.name, listOf("1")) sharedPreferencesHelper.write(ResourceType.Organization.name, listOf("2")) sharedPreferencesHelper.write(ResourceType.Location.name, listOf("3")) - sharedPreferencesHelper.write( - SharedPreferenceKey.REMOTE_SYNC_RESOURCES.name, - arrayOf( - ResourceType.CarePlan.name, - ResourceType.Condition.name, - ResourceType.Encounter.name, - ResourceType.Group.name, - ResourceType.Library.name, - ResourceType.Observation.name, - ResourceType.Patient.name, - ResourceType.PlanDefinition.name, - ResourceType.Questionnaire.name, - ResourceType.QuestionnaireResponse.name, - ResourceType.StructureMap.name, - ResourceType.Task.name, - ) - .sorted(), - ) - - val syncParam = syncBroadcaster.syncListenerManager.loadSyncParams() + val syncParam = syncBroadcaster.syncListenerManager.loadResourceSearchParams() Assert.assertTrue(syncParam.isNotEmpty()) @@ -120,6 +107,7 @@ class SyncBroadcasterTest : RobolectricTest() { ResourceType.Encounter, ResourceType.Group, ResourceType.Library, + ResourceType.Measure, ResourceType.Observation, ResourceType.Patient, ResourceType.PlanDefinition, @@ -169,42 +157,43 @@ class SyncBroadcasterTest : RobolectricTest() { } @Test - fun `loadSyncParams() should load configuration when remote sync preference is missing`() { - sharedPreferencesHelper.write(ResourceType.CareTeam.name, listOf("1")) - sharedPreferencesHelper.write(ResourceType.Organization.name, listOf("2")) - sharedPreferencesHelper.write(ResourceType.Location.name, listOf("3")) - sharedPreferencesHelper.resetSharedPrefs() - - val syncParam = syncBroadcaster.syncListenerManager.loadSyncParams() - - Assert.assertTrue(syncParam.isNotEmpty()) - - val resourceTypes = - arrayOf( - ResourceType.CarePlan, - ResourceType.Condition, - ResourceType.Encounter, - ResourceType.Group, - ResourceType.Library, - ResourceType.Observation, - ResourceType.Measure, - ResourceType.Patient, - ResourceType.PlanDefinition, - ResourceType.Questionnaire, - ResourceType.QuestionnaireResponse, - ResourceType.StructureMap, - ResourceType.Task, - ) - .sorted() - - Assert.assertEquals(resourceTypes, syncParam.keys.toTypedArray().sorted()) - } + fun `loadSyncParams() should load configuration when remote sync preference is missing`() = + runTest { + sharedPreferencesHelper.write(ResourceType.CareTeam.name, listOf("1")) + sharedPreferencesHelper.write(ResourceType.Organization.name, listOf("2")) + sharedPreferencesHelper.write(ResourceType.Location.name, listOf("3")) + sharedPreferencesHelper.resetSharedPrefs() + + val syncParam = syncBroadcaster.syncListenerManager.loadResourceSearchParams() + + Assert.assertTrue(syncParam.isNotEmpty()) + + val resourceTypes = + arrayOf( + ResourceType.CarePlan, + ResourceType.Condition, + ResourceType.Encounter, + ResourceType.Group, + ResourceType.Library, + ResourceType.Observation, + ResourceType.Measure, + ResourceType.Patient, + ResourceType.PlanDefinition, + ResourceType.Questionnaire, + ResourceType.QuestionnaireResponse, + ResourceType.StructureMap, + ResourceType.Task, + ) + .sorted() + + Assert.assertEquals(resourceTypes, syncParam.keys.toTypedArray().sorted()) + } @Test - fun loadSyncParamsShouldHaveOrganizationId() { + fun loadSyncParamsShouldHaveOrganizationId() = runTest { val organizationId = "organization-id" sharedPreferencesHelper.write(ResourceType.Organization.name, listOf(organizationId)) - val syncParam = syncBroadcaster.syncListenerManager.loadSyncParams() + val syncParam = syncBroadcaster.syncListenerManager.loadResourceSearchParams() // Resource types that can be filtered based on Organization val resourceTypes = @@ -229,10 +218,10 @@ class SyncBroadcasterTest : RobolectricTest() { // TODO: Not supported yet; need to refactor sync implementation to be based on tags. @Test - fun loadSyncParamsShouldHaveCareTeamIdNotSupported() { + fun loadSyncParamsShouldHaveCareTeamIdNotSupported() = runTest { val careTeamId = "care-team-id" sharedPreferencesHelper.write(ResourceType.CareTeam.name, listOf(careTeamId)) - val syncParam = syncBroadcaster.syncListenerManager.loadSyncParams() + val syncParam = syncBroadcaster.syncListenerManager.loadResourceSearchParams() Assert.assertTrue(syncParam.isNotEmpty()) syncParam.values.forEach { Assert.assertFalse(it.containsValue(careTeamId)) } @@ -240,10 +229,10 @@ class SyncBroadcasterTest : RobolectricTest() { // TODO: Not supported yet; need to refactor sync implementation to be based on tags. @Test - fun loadSyncParamsShouldNotHaveLocationIdNotSupported() { + fun loadSyncParamsShouldNotHaveLocationIdNotSupported() = runTest { val locationId = "location-id" sharedPreferencesHelper.write(ResourceType.Location.name, listOf(locationId)) - val syncParam = syncBroadcaster.syncListenerManager.loadSyncParams() + val syncParam = syncBroadcaster.syncListenerManager.loadResourceSearchParams() Assert.assertTrue(syncParam.isNotEmpty()) syncParam.values.forEach { Assert.assertFalse(it.containsValue(locationId)) } @@ -251,10 +240,10 @@ class SyncBroadcasterTest : RobolectricTest() { // TODO: Not supported yet; need to refactor sync implementation to be based on tags. @Test - fun loadSyncParamsShouldNotHavePractitionerIdNotSupported() { + fun loadSyncParamsShouldNotHavePractitionerIdNotSupported() = runTest { val practitionerId = "practitioner-id" sharedPreferencesHelper.write(ResourceType.Practitioner.name, listOf(practitionerId)) - val syncParam = syncBroadcaster.syncListenerManager.loadSyncParams() + val syncParam = syncBroadcaster.syncListenerManager.loadResourceSearchParams() Assert.assertTrue(syncParam.isNotEmpty()) syncParam.values.forEach { Assert.assertFalse(it.containsValue(practitionerId)) } diff --git a/android/engine/src/test/java/org/smartregister/fhircore/engine/task/FhirCarePlanGeneratorTest.kt b/android/engine/src/test/java/org/smartregister/fhircore/engine/task/FhirCarePlanGeneratorTest.kt index 6b6a4a1e2d8..e0ff0dd89ed 100644 --- a/android/engine/src/test/java/org/smartregister/fhircore/engine/task/FhirCarePlanGeneratorTest.kt +++ b/android/engine/src/test/java/org/smartregister/fhircore/engine/task/FhirCarePlanGeneratorTest.kt @@ -28,14 +28,12 @@ import com.google.android.fhir.datacapture.extensions.logicalId import com.google.android.fhir.get import com.google.android.fhir.knowledge.KnowledgeManager import com.google.android.fhir.search.Search -import com.google.android.fhir.search.search import com.google.android.fhir.workflow.FhirOperator import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidTest import io.mockk.Runs import io.mockk.coEvery import io.mockk.coVerify -import io.mockk.every import io.mockk.just import io.mockk.mockk import io.mockk.runs @@ -107,16 +105,20 @@ import org.mockito.ArgumentMatchers.anyBoolean import org.smartregister.fhircore.engine.app.fakes.Faker import org.smartregister.fhircore.engine.configuration.ConfigurationRegistry import org.smartregister.fhircore.engine.configuration.QuestionnaireConfig +import org.smartregister.fhircore.engine.configuration.app.ConfigService import org.smartregister.fhircore.engine.configuration.event.EventTriggerCondition import org.smartregister.fhircore.engine.configuration.event.EventWorkflow +import org.smartregister.fhircore.engine.data.local.ContentCache import org.smartregister.fhircore.engine.data.local.DefaultRepository import org.smartregister.fhircore.engine.domain.model.ResourceConfig import org.smartregister.fhircore.engine.robolectric.RobolectricTest import org.smartregister.fhircore.engine.rule.CoroutineTestRule import org.smartregister.fhircore.engine.util.DispatcherProvider +import org.smartregister.fhircore.engine.util.SharedPreferencesHelper import org.smartregister.fhircore.engine.util.extension.REFERENCE import org.smartregister.fhircore.engine.util.extension.SDF_YYYY_MM_DD import org.smartregister.fhircore.engine.util.extension.asReference +import org.smartregister.fhircore.engine.util.extension.batchedSearch import org.smartregister.fhircore.engine.util.extension.decodeResourceFromString import org.smartregister.fhircore.engine.util.extension.encodeResourceToString import org.smartregister.fhircore.engine.util.extension.extractId @@ -131,6 +133,7 @@ import org.smartregister.fhircore.engine.util.extension.plusYears import org.smartregister.fhircore.engine.util.extension.referenceValue import org.smartregister.fhircore.engine.util.extension.updateDependentTaskDueDate import org.smartregister.fhircore.engine.util.extension.valueToString +import org.smartregister.fhircore.engine.util.fhirpath.FhirPathDataExtractor import org.smartregister.fhircore.engine.util.helper.TransformSupportServices @OptIn(ExperimentalCoroutinesApi::class) @@ -147,14 +150,23 @@ class FhirCarePlanGeneratorTest : RobolectricTest() { @Inject lateinit var fhirEngine: FhirEngine - @Inject lateinit var testDispatcher: DispatcherProvider + @Inject lateinit var dispatcherProvider: DispatcherProvider + + @Inject lateinit var configService: ConfigService + + @Inject lateinit var sharedPreferencesHelper: SharedPreferencesHelper + + @Inject lateinit var fhirPathDataExtractor: FhirPathDataExtractor @Inject lateinit var configurationRegistry: ConfigurationRegistry + @Inject lateinit var contentCache: ContentCache + private val context: Context = ApplicationProvider.getApplicationContext() private val knowledgeManager = KnowledgeManager.create(context) private val fhirContext: FhirContext = FhirContext.forCached(FhirVersionEnum.R4) + private lateinit var defaultRepository: DefaultRepository private lateinit var fhirResourceUtil: FhirResourceUtil private lateinit var fhirCarePlanGenerator: FhirCarePlanGenerator private lateinit var structureMapUtilities: StructureMapUtilities @@ -162,7 +174,7 @@ class FhirCarePlanGeneratorTest : RobolectricTest() { private lateinit var encounter: Encounter private lateinit var opv0: Task private lateinit var opv1: Task - private val defaultRepository: DefaultRepository = mockk(relaxed = true) + private val iParser: IParser = fhirContext.newJsonParser() private val jsonParser = fhirContext.getCustomJsonParser() private val xmlParser = fhirContext.newXmlParser() @@ -171,8 +183,22 @@ class FhirCarePlanGeneratorTest : RobolectricTest() { fun setup() { hiltRule.inject() structureMapUtilities = StructureMapUtilities(transformSupportServices.simpleWorkerContext) - every { defaultRepository.dispatcherProvider } returns testDispatcher - every { defaultRepository.fhirEngine } returns fhirEngine + defaultRepository = + spyk( + DefaultRepository( + fhirEngine = fhirEngine, + dispatcherProvider = dispatcherProvider, + sharedPreferencesHelper = sharedPreferencesHelper, + configurationRegistry = mockk(), + configService = configService, + configRulesExecutor = mockk(), + fhirPathDataExtractor = fhirPathDataExtractor, + parser = iParser, + context = context, + contentCache = contentCache, + ), + ) + coEvery { defaultRepository.create(anyBoolean(), any()) } returns listOf() fhirResourceUtil = @@ -201,8 +227,8 @@ class FhirCarePlanGeneratorTest : RobolectricTest() { fhirCarePlanGenerator = FhirCarePlanGenerator( fhirEngine = fhirEngine, - transformSupportServices = transformSupportServices, fhirPathEngine = fhirPathEngine, + transformSupportServices = transformSupportServices, defaultRepository = defaultRepository, fhirResourceUtil = fhirResourceUtil, workflowCarePlanGenerator = workflowCarePlanGenerator, @@ -566,8 +592,14 @@ class FhirCarePlanGeneratorTest : RobolectricTest() { carePlan.description, ) assertEquals(patient.logicalId, carePlan.subject.extractId()) - assertEquals(DateTimeType.now().value.makeItReadable(), carePlan.created.makeItReadable()) - assertEquals(patient.generalPractitionerFirstRep.extractId(), carePlan.author.extractId()) + assertEquals( + DateTimeType.now().value.makeItReadable(), + carePlan.created.makeItReadable(), + ) + assertEquals( + patient.generalPractitionerFirstRep.extractId(), + carePlan.author.extractId(), + ) assertEquals( DateTimeType.now().value.makeItReadable(), carePlan.period.start.makeItReadable(), @@ -645,7 +677,10 @@ class FhirCarePlanGeneratorTest : RobolectricTest() { assertEquals("HH Routine visit Plan", carePlan.title) assertEquals("sample plan", carePlan.description) assertEquals(group.logicalId, carePlan.subject.extractId()) - assertEquals(DateTimeType.now().value.makeItReadable(), carePlan.created.makeItReadable()) + assertEquals( + DateTimeType.now().value.makeItReadable(), + carePlan.created.makeItReadable(), + ) assertNotNull(carePlan.period.start) assertTrue(carePlan.activityFirstRep.outcomeReference.isNotEmpty()) @@ -716,7 +751,10 @@ class FhirCarePlanGeneratorTest : RobolectricTest() { carePlan.description, ) assertEquals(group.logicalId, carePlan.subject.extractId()) - assertEquals(DateTimeType.now().value.makeItReadable(), carePlan.created.makeItReadable()) + assertEquals( + DateTimeType.now().value.makeItReadable(), + carePlan.created.makeItReadable(), + ) assertNotNull(carePlan.period.start) assertTrue(carePlan.activityFirstRep.outcomeReference.isNotEmpty()) @@ -880,12 +918,18 @@ class FhirCarePlanGeneratorTest : RobolectricTest() { val createdTasksSlot = mutableListOf() val updatedTasksSlot = mutableListOf() val booleanSlot = slot() - coEvery { defaultRepository.addOrUpdate(capture(booleanSlot), capture(createdTasksSlot)) } just - runs + coEvery { + defaultRepository.addOrUpdate( + capture(booleanSlot), + capture(createdTasksSlot), + ) + } just runs coEvery { defaultRepository.addOrUpdate(any(), capture(updatedTasksSlot)) } just runs coEvery { fhirEngine.update(any()) } just runs coEvery { fhirEngine.get("528a8603-2e43-4a2e-a33d-1ec2563ffd3e") } returns structureMapReferral + .encodeResourceToString() + .decodeResourceFromString() // Ensure 'months' Duration code is correctly escaped coEvery { fhirEngine.search(Search(ResourceType.CarePlan)) } returns listOf( SearchResult( @@ -912,7 +956,9 @@ class FhirCarePlanGeneratorTest : RobolectricTest() { subject = patient, data = Bundle() - .addEntry(Bundle.BundleEntryComponent().apply { resource = questionnaireResponse }), + .addEntry( + Bundle.BundleEntryComponent().apply { resource = questionnaireResponse }, + ), ) ?.also { carePlan: CarePlan -> assertEquals(CarePlan.CarePlanStatus.COMPLETED, carePlan.status) @@ -967,6 +1013,8 @@ class FhirCarePlanGeneratorTest : RobolectricTest() { runs coEvery { fhirEngine.get("528a8603-2e43-4a2e-a33d-1ec2563ffd3e") } returns structureMap + .encodeResourceToString() + .decodeResourceFromString() // Ensure 'months' Duration code is correctly escaped coEvery { fhirEngine.search(Search(ResourceType.CarePlan)) } returns listOf() @@ -974,7 +1022,10 @@ class FhirCarePlanGeneratorTest : RobolectricTest() { .generateOrUpdateCarePlan( plandefinition, patient, - Bundle().addEntry(Bundle.BundleEntryComponent().apply { resource = questionnaireResponse }), + Bundle() + .addEntry( + Bundle.BundleEntryComponent().apply { resource = questionnaireResponse }, + ), ) .also { _ -> resourcesSlot.forEach { println(it.encodeResourceToString()) } @@ -1190,7 +1241,10 @@ class FhirCarePlanGeneratorTest : RobolectricTest() { val ancStart = fhirCarePlanGenerator - .evaluateToDate(DateTimeType(lmp.value), "\$this + 3 'month'")!! + .evaluateToDate( + DateTimeType(lmp.value), + "\$this + 3 'month'", + )!! .value this.forEachIndexed { index, task -> assertEquals( @@ -1336,7 +1390,14 @@ class FhirCarePlanGeneratorTest : RobolectricTest() { it.code.codingFirstRep.code == "33879002" }, ) - assertTrue(tasks.all { it.description.contains(it.reasonCode.text, true) }) + assertTrue( + tasks.all { + it.description.contains( + it.reasonCode.text, + true, + ) + }, + ) assertTrue( tasks.all { it.`for`.reference == questionnaireResponses.first().subject.reference @@ -1508,7 +1569,14 @@ class FhirCarePlanGeneratorTest : RobolectricTest() { it.code.codingFirstRep.code == "33879002" }, ) - assertTrue(tasks.all { it.description.contains(it.reasonCode.text, true) }) + assertTrue( + tasks.all { + it.description.contains( + it.reasonCode.text, + true, + ) + }, + ) assertTrue( tasks.all { it.`for`.reference == questionnaireResponses.first().subject.reference @@ -1536,7 +1604,11 @@ class FhirCarePlanGeneratorTest : RobolectricTest() { "IPV" -> assertEquals(task.groupIdentifier.value, "14_wk") "MEASLES 1" -> assertEquals(task.groupIdentifier.value, "9_mo") "MEASLES 2" -> assertEquals(task.groupIdentifier.value, "15_mo") - "YELLOW FEVER" -> assertEquals(task.groupIdentifier.value, "9_mo") + "YELLOW FEVER" -> + assertEquals( + task.groupIdentifier.value, + "9_mo", + ) "TYPHOID" -> assertEquals(task.groupIdentifier.value, "9_mo") "HPV 1" -> assertEquals(task.groupIdentifier.value, "108_mo") "HPV 2" -> assertEquals(task.groupIdentifier.value, "114_mo") @@ -1605,8 +1677,14 @@ class FhirCarePlanGeneratorTest : RobolectricTest() { val pcv2 = tasks.first { it.description.contains("PCV 2") } val bcg = tasks.first { it.description.contains("BCG") } - assertEquals(opv2.partOf.first().reference.toString(), opv1.referenceValue()) - assertEquals(pcv3.partOf.first().reference.toString(), pcv2.referenceValue()) + assertEquals( + opv2.partOf.first().reference.toString(), + opv1.referenceValue(), + ) + assertEquals( + pcv3.partOf.first().reference.toString(), + pcv2.referenceValue(), + ) assertTrue(bcg.partOf.isEmpty()) val c = Calendar.getInstance() c.time = opv1.restriction?.period?.start!! @@ -1666,10 +1744,21 @@ class FhirCarePlanGeneratorTest : RobolectricTest() { carePlan.description, ) assertEquals(patient.logicalId, carePlan.subject.extractId()) - assertEquals(DateTimeType.now().value.makeItReadable(), carePlan.created.makeItReadable()) - assertEquals(patient.generalPractitionerFirstRep.extractId(), carePlan.author.extractId()) + assertEquals( + DateTimeType.now().value.makeItReadable(), + carePlan.created.makeItReadable(), + ) + assertEquals( + patient.generalPractitionerFirstRep.extractId(), + carePlan.author.extractId(), + ) assertTrue(carePlan.activityFirstRep.outcomeReference.isNotEmpty()) - coEvery { defaultRepository.addOrUpdate(capture(booleanSlot), capture(resourcesSlot)) } + coEvery { + defaultRepository.addOrUpdate( + capture(booleanSlot), + capture(resourcesSlot), + ) + } resourcesSlot .filter { res -> res.resourceType == ResourceType.Task } .map { it as Task } @@ -1725,7 +1814,7 @@ class FhirCarePlanGeneratorTest : RobolectricTest() { } val bundle = Bundle().apply { addEntry().resource = patient } coEvery { - fhirEngine.search { + fhirEngine.batchedSearch { filter( CarePlan.INSTANTIATES_CANONICAL, { value = "${PlanDefinition().fhirType()}/plandef-1" }, @@ -1777,7 +1866,7 @@ class FhirCarePlanGeneratorTest : RobolectricTest() { } val bundle = Bundle().apply { addEntry().resource = patient } coEvery { - fhirEngine.search { + fhirEngine.batchedSearch { filter( CarePlan.INSTANTIATES_CANONICAL, { value = "${PlanDefinition().fhirType()}/plandef-1" }, @@ -1876,7 +1965,12 @@ class FhirCarePlanGeneratorTest : RobolectricTest() { @Test fun `updateDependentTaskDueDate should update dependent task without output`() { coEvery { fhirEngine.search(any()) } returns emptyList() - coEvery { fhirEngine.get(ResourceType.Task, "650203d2-f327-4eb4-a9fd-741e0ce29c3f") } returns + coEvery { + fhirEngine.get( + ResourceType.Task, + "650203d2-f327-4eb4-a9fd-741e0ce29c3f", + ) + } returns opv0.apply { status = TaskStatus.READY partOf = listOf(Reference("Task/650203d2-f327-4eb4-a9fd-741e0ce29c3f")) @@ -1902,7 +1996,12 @@ class FhirCarePlanGeneratorTest : RobolectricTest() { @Test fun `updateDependentTaskDueDate should run with dependent task, with output but no execution period start date`() { - coEvery { fhirEngine.get(ResourceType.Task, "650203d2-f327-4eb4-a9fd-741e0ce29c3f") } returns + coEvery { + fhirEngine.get( + ResourceType.Task, + "650203d2-f327-4eb4-a9fd-741e0ce29c3f", + ) + } returns opv0.apply { status = TaskStatus.INPROGRESS output = listOf() @@ -1918,7 +2017,12 @@ class FhirCarePlanGeneratorTest : RobolectricTest() { @Test fun `updateDependentTaskDueDate with dependent task with output, execution period start date, and encounter part of reference that is null`() { coEvery { fhirEngine.search(any()) } returns emptyList() - coEvery { fhirEngine.get(ResourceType.Task, "650203d2-f327-4eb4-a9fd-741e0ce29c3f") } returns + coEvery { + fhirEngine.get( + ResourceType.Task, + "650203d2-f327-4eb4-a9fd-741e0ce29c3f", + ) + } returns opv1.apply { status = TaskStatus.INPROGRESS output = @@ -1951,7 +2055,12 @@ class FhirCarePlanGeneratorTest : RobolectricTest() { @Test fun `updateDependentTaskDueDate with dependent task with output, execution period start date, and encounter part of reference that is not an Immunization`() { coEvery { fhirEngine.search(any()) } returns emptyList() - coEvery { fhirEngine.get(ResourceType.Task, "650203d2-f327-4eb4-a9fd-741e0ce29c3f") } returns + coEvery { + fhirEngine.get( + ResourceType.Task, + "650203d2-f327-4eb4-a9fd-741e0ce29c3f", + ) + } returns opv1.apply { status = TaskStatus.INPROGRESS output = @@ -1991,7 +2100,12 @@ class FhirCarePlanGeneratorTest : RobolectricTest() { @Test fun `updateDependentTaskDueDate with Task input value equal or greater than difference between administration date and depedentTask executionPeriod start`() { - coEvery { fhirEngine.get(ResourceType.Task, "650203d2-f327-4eb4-a9fd-741e0ce29c3f") } returns + coEvery { + fhirEngine.get( + ResourceType.Task, + "650203d2-f327-4eb4-a9fd-741e0ce29c3f", + ) + } returns opv1.apply { status = TaskStatus.INPROGRESS output = @@ -2004,7 +2118,7 @@ class FhirCarePlanGeneratorTest : RobolectricTest() { input = listOf(Task.ParameterComponent(CodeableConcept(), StringType("9"))) } coEvery { - fhirEngine.search { + fhirEngine.batchedSearch { filter( referenceParameter = ReferenceClientParam("part-of"), { value = opv1.id.extractLogicalIdUuid() }, @@ -2020,7 +2134,7 @@ class FhirCarePlanGeneratorTest : RobolectricTest() { ), ) coEvery { - fhirEngine.search { + fhirEngine.batchedSearch { filter( referenceParameter = ReferenceClientParam("part-of"), { value = immunizationResource.id.extractLogicalIdUuid() }, @@ -2028,7 +2142,7 @@ class FhirCarePlanGeneratorTest : RobolectricTest() { } } returns listOf(SearchResult(resource = immunizationResource, null, null)) coEvery { - fhirEngine.search { + fhirEngine.batchedSearch { filter( referenceParameter = ReferenceClientParam("part-of"), { value = encounter.id.extractLogicalIdUuid() }, @@ -2449,8 +2563,12 @@ class FhirCarePlanGeneratorTest : RobolectricTest() { coEvery { fhirEngine.search(Search(ResourceType.CarePlan)) } returns listOf() coEvery { fhirEngine.get(structureMapRegister.logicalId) } returns structureMapRegister + .encodeResourceToString() + .decodeResourceFromString() // Ensure 'months' Duration code is correctly escaped coEvery { fhirEngine.get("528a8603-2e43-4a2e-a33d-1ec2563ffd3e") } returns structureMapReferral + .encodeResourceToString() + .decodeResourceFromString() // Ensure 'months' Duration code is correctly escaped return PlanDefinitionResources( planDefinition, @@ -2496,7 +2614,10 @@ class FhirCarePlanGeneratorTest : RobolectricTest() { assertEquals(planDefinition.title, carePlan.title) assertEquals(planDefinition.description, carePlan.description) assertEquals(patient.logicalId, carePlan.subject.extractId()) - assertEquals(DateTimeType(dateToday).value.makeItReadable(), carePlan.created.makeItReadable()) + assertEquals( + DateTimeType(dateToday).value.makeItReadable(), + carePlan.created.makeItReadable(), + ) assertEquals(patient.generalPractitionerFirstRep.extractId(), carePlan.author.extractId()) assertEquals(referenceDate.makeItReadable(), carePlan.period.start.makeItReadable()) @@ -2529,6 +2650,13 @@ class FhirCarePlanGeneratorTest : RobolectricTest() { "HPV 1" to patient.birthDate.plusMonths(108), "HPV 2" to patient.birthDate.plusMonths(114), ) + + @Test + fun cleanPlanDefinitionCanonical() { + val carePlan = CarePlan().apply { instantiatesCanonical = listOf(CanonicalType("123456")) } + fhirCarePlanGenerator.invokeCleanPlanDefinitionCanonical(carePlan) + assertEquals("PlanDefinition/123456", carePlan.instantiatesCanonical.first().value) + } } private fun Date.asYyyyMmDd(): String = this.formatDate(SDF_YYYY_MM_DD) diff --git a/android/engine/src/test/java/org/smartregister/fhircore/engine/task/FhirResourceExpireWorkerTest.kt b/android/engine/src/test/java/org/smartregister/fhircore/engine/task/FhirResourceExpireWorkerTest.kt index e6ce1dfe6b8..ba434828c5c 100644 --- a/android/engine/src/test/java/org/smartregister/fhircore/engine/task/FhirResourceExpireWorkerTest.kt +++ b/android/engine/src/test/java/org/smartregister/fhircore/engine/task/FhirResourceExpireWorkerTest.kt @@ -56,6 +56,7 @@ import org.junit.Rule import org.junit.Test import org.smartregister.fhircore.engine.app.fakes.Faker import org.smartregister.fhircore.engine.configuration.ConfigurationRegistry +import org.smartregister.fhircore.engine.data.local.ContentCache import org.smartregister.fhircore.engine.data.local.DefaultRepository import org.smartregister.fhircore.engine.robolectric.RobolectricTest import org.smartregister.fhircore.engine.rule.CoroutineTestRule @@ -78,6 +79,9 @@ class FhirResourceExpireWorkerTest : RobolectricTest() { @Inject lateinit var dispatcherProvider: DispatcherProvider @Inject lateinit var parser: IParser + + @Inject lateinit var contentCache: ContentCache + private val configurationRegistry: ConfigurationRegistry = Faker.buildTestConfigurationRegistry() private lateinit var defaultRepository: DefaultRepository @@ -95,7 +99,7 @@ class FhirResourceExpireWorkerTest : RobolectricTest() { period = Period().apply { end = DateTime().plusDays(-2).toDate() } } } - val serviceRequest = + private val serviceRequest = ServiceRequest().apply { id = UUID.randomUUID().toString() status = ServiceRequest.ServiceRequestStatus.COMPLETED @@ -120,6 +124,7 @@ class FhirResourceExpireWorkerTest : RobolectricTest() { fhirPathDataExtractor = mockk(), parser = parser, context = ApplicationProvider.getApplicationContext(), + contentCache = contentCache, ), ) diff --git a/android/engine/src/test/java/org/smartregister/fhircore/engine/ui/base/AlertDialogueTest.kt b/android/engine/src/test/java/org/smartregister/fhircore/engine/ui/base/AlertDialogueTest.kt index 36296d09c91..401ef8cc2cc 100644 --- a/android/engine/src/test/java/org/smartregister/fhircore/engine/ui/base/AlertDialogueTest.kt +++ b/android/engine/src/test/java/org/smartregister/fhircore/engine/ui/base/AlertDialogueTest.kt @@ -151,6 +151,8 @@ class AlertDialogueTest : ActivityRobolectricTest() { confirmButtonText = R.string.questionnaire_alert_back_pressed_save_draft_button_title, neutralButtonListener = {}, neutralButtonText = R.string.questionnaire_alert_back_pressed_button_title, + negativeButtonListener = {}, + negativeButtonText = R.string.questionnaire_alert_negative_button_title, ) val dialog = shadowOf(ShadowAlertDialog.getLatestAlertDialog()) diff --git a/android/engine/src/test/java/org/smartregister/fhircore/engine/util/FhirDataTypesUtilKtTest.kt b/android/engine/src/test/java/org/smartregister/fhircore/engine/util/FhirDataTypesUtilKtTest.kt new file mode 100644 index 00000000000..20a263df2dc --- /dev/null +++ b/android/engine/src/test/java/org/smartregister/fhircore/engine/util/FhirDataTypesUtilKtTest.kt @@ -0,0 +1,97 @@ +/* + * Copyright 2021-2024 Ona Systems, Inc + * + * 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 org.smartregister.fhircore.engine.util + +import java.io.IOException +import java.math.BigDecimal +import org.hl7.fhir.r4.model.BooleanType +import org.hl7.fhir.r4.model.Coding +import org.hl7.fhir.r4.model.DateTimeType +import org.hl7.fhir.r4.model.DateType +import org.hl7.fhir.r4.model.DecimalType +import org.hl7.fhir.r4.model.Enumerations.DataType +import org.hl7.fhir.r4.model.IntegerType +import org.hl7.fhir.r4.model.Quantity +import org.hl7.fhir.r4.model.Reference +import org.hl7.fhir.r4.model.StringType +import org.hl7.fhir.r4.model.TimeType +import org.hl7.fhir.r4.model.UriType +import org.junit.Assert +import org.junit.Test +import org.smartregister.fhircore.engine.util.extension.valueToString + +class FhirDataTypesUtilKtTest { + @Test + fun testCastToTypeReturnsCorrectTypes() { + val booleanType = "true".castToType(DataType.BOOLEAN) + Assert.assertEquals(BooleanType().fhirType(), booleanType?.fhirType()) + Assert.assertEquals("true", booleanType.valueToString()) + + val decimalType = "6.4".castToType(DataType.DECIMAL) + Assert.assertEquals(DecimalType().fhirType(), decimalType?.fhirType()) + Assert.assertEquals("6.4", decimalType.valueToString()) + + val integerType = "4".castToType(DataType.INTEGER) + Assert.assertEquals(IntegerType().fhirType(), integerType?.fhirType()) + Assert.assertEquals("4", integerType.valueToString()) + + val dateType = "2020-02-02".castToType(DataType.DATE) + Assert.assertEquals(DateType().fhirType(), dateType?.fhirType()) + Assert.assertEquals("02-Feb-2020", dateType.valueToString()) + + val dateTimeType = "2020-02-02T13:00:32".castToType(DataType.DATETIME) + Assert.assertEquals(DateTimeType().fhirType(), dateTimeType?.fhirType()) + Assert.assertEquals("02-Feb-2020", dateTimeType.valueToString()) + + val timeType = "T13:00:32".castToType(DataType.TIME) + Assert.assertEquals(TimeType().fhirType(), timeType?.fhirType()) + Assert.assertEquals("T13:00:32", timeType.valueToString()) + + val stringType = "str".castToType(DataType.STRING) + Assert.assertEquals(StringType().fhirType(), stringType?.fhirType()) + Assert.assertEquals("str", stringType.valueToString()) + + val uriType = "https://str.org".castToType(DataType.URI) + Assert.assertEquals(UriType().fhirType(), uriType?.fhirType()) + Assert.assertEquals("https://str.org", uriType.valueToString()) + + val codingType = "{ \"code\": \"alright\" }".castToType(DataType.CODING) + Assert.assertTrue(codingType is Coding) + codingType as Coding + Assert.assertEquals("alright", codingType.code) + + Assert.assertThrows(IOException::class.java) { + val codingType = "invalid".castToType(DataType.CODING) + Assert.assertEquals(null, codingType) + } + + val quantityType = " { \"value\": 42 }".castToType(DataType.QUANTITY) + Assert.assertTrue(quantityType is Quantity) + quantityType as Quantity + Assert.assertEquals(BigDecimal(42), quantityType.value) + + Assert.assertThrows(IOException::class.java) { + val quantityType = "invalid".castToType(DataType.QUANTITY) + Assert.assertEquals(null, quantityType) + } + + val referenceType = "{ \"reference\": \"Patient/0\"}".castToType(DataType.REFERENCE) + Assert.assertTrue(referenceType is Reference) + referenceType as Reference + Assert.assertEquals("Patient/0", referenceType.reference) + } +} diff --git a/android/engine/src/test/java/org/smartregister/fhircore/engine/util/KnowledgeManagerUtilTest.kt b/android/engine/src/test/java/org/smartregister/fhircore/engine/util/KnowledgeManagerUtilTest.kt new file mode 100644 index 00000000000..2cd1c0dbf78 --- /dev/null +++ b/android/engine/src/test/java/org/smartregister/fhircore/engine/util/KnowledgeManagerUtilTest.kt @@ -0,0 +1,75 @@ +/* + * Copyright 2021-2024 Ona Systems, Inc + * + * 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 org.smartregister.fhircore.engine.util + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import ca.uhn.fhir.context.FhirContext +import java.io.File +import org.hl7.fhir.r4.model.StructureMap +import org.junit.After +import org.junit.Assert +import org.junit.Before +import org.junit.Test +import org.smartregister.fhircore.engine.app.AppConfigService +import org.smartregister.fhircore.engine.robolectric.RobolectricTest + +class KnowledgeManagerUtilTest : RobolectricTest() { + + private lateinit var configService: AppConfigService + private val context = ApplicationProvider.getApplicationContext()!! + + @Before + fun setUp() { + configService = AppConfigService(context) + } + + @Test + fun testWriteToFile() { + val structureMap = StructureMap().apply { id = "structure-map-id" } + + val filePath = + "${KnowledgeManagerUtil.KNOWLEDGE_MANAGER_ASSETS_SUBFOLDER}/StructureMap/structure-map-id.json" + val absoluteFilePath = "${context.filesDir}/$filePath" + + val file = File(absoluteFilePath) + Assert.assertFalse(file.exists()) + + KnowledgeManagerUtil.writeToFile(filePath, structureMap, configService, context) + + Assert.assertTrue(file.exists()) + + val savedStructureMap = + FhirContext.forR4Cached().newJsonParser().parseResource(file.readText()) as StructureMap + Assert.assertNotNull(savedStructureMap.url) + Assert.assertEquals( + "http://fake.base.url.com/StructureMap/structure-map-id", + savedStructureMap.url, + ) + } + + @After + fun tearDown() { + val testFile = + File( + "${context.filesDir}/${KnowledgeManagerUtil.KNOWLEDGE_MANAGER_ASSETS_SUBFOLDER}/StructureMap/structure-map-id.json", + ) + if (testFile.exists()) { + testFile.delete() + } + } +} diff --git a/android/engine/src/test/java/org/smartregister/fhircore/engine/util/SecureSharedPreferenceTest.kt b/android/engine/src/test/java/org/smartregister/fhircore/engine/util/SecureSharedPreferenceTest.kt index 9866da119a1..b015f3464c1 100644 --- a/android/engine/src/test/java/org/smartregister/fhircore/engine/util/SecureSharedPreferenceTest.kt +++ b/android/engine/src/test/java/org/smartregister/fhircore/engine/util/SecureSharedPreferenceTest.kt @@ -22,7 +22,10 @@ import androidx.test.core.app.ApplicationProvider import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidTest import io.mockk.every +import io.mockk.mockk import io.mockk.spyk +import io.mockk.verify +import kotlinx.coroutines.runBlocking import org.junit.Assert import org.junit.Before import org.junit.Rule @@ -45,6 +48,34 @@ internal class SecureSharedPreferenceTest : RobolectricTest() { secureSharedPreference = spyk(SecureSharedPreference(application)) } + @Test + fun testInitEncryptedSharedPreferences() { + val result = secureSharedPreference.initEncryptedSharedPreferences() + Assert.assertNotNull(result) + } + + @Test + fun testInitEncryptedSharedPreferencesHandlesException() { + every { secureSharedPreference.createEncryptedSharedPreferences() } throws + RuntimeException("Exception") andThenAnswer + { + callOriginal() + } + + val result = secureSharedPreference.initEncryptedSharedPreferences() + + Assert.assertNotNull(result) + + verify(exactly = 2) { secureSharedPreference.createEncryptedSharedPreferences() } + verify(exactly = 1) { secureSharedPreference.resetSharedPrefs() } + } + + @Test + fun testCreateEncryptedSharedPreferences() { + val result = secureSharedPreference.createEncryptedSharedPreferences() + Assert.assertNotNull(result) + } + @Test fun testSaveCredentialsAndRetrieveSessionToken() { secureSharedPreference.saveCredentials(username = "userName", password = "!@#$".toCharArray()) @@ -73,9 +104,13 @@ internal class SecureSharedPreferenceTest : RobolectricTest() { } @Test - fun testSaveAndRetrievePin() { + fun testSaveAndRetrievePin() = runBlocking { every { secureSharedPreference.get256RandomBytes() } returns byteArrayOf(-100, 0, 100, 101) - secureSharedPreference.saveSessionPin(pin = "1234".toCharArray()) + + val onSavedPinMock = mockk<() -> Unit>(relaxed = true) + secureSharedPreference.saveSessionPin(pin = "1234".toCharArray(), onSavedPin = onSavedPinMock) + + verify { onSavedPinMock.invoke() } Assert.assertEquals( "1234".toCharArray().toPasswordHash(byteArrayOf(-100, 0, 100, 101)), secureSharedPreference.retrieveSessionPin(), @@ -85,10 +120,13 @@ internal class SecureSharedPreferenceTest : RobolectricTest() { } @Test - fun testResetSharedPrefsClearsData() { + fun testResetSharedPrefsClearsData() = runBlocking { every { secureSharedPreference.get256RandomBytes() } returns byteArrayOf(-128, 100, 112, 127) - secureSharedPreference.saveSessionPin(pin = "6699".toCharArray()) + val onSavedPinMock = mockk<() -> Unit>(relaxed = true) + secureSharedPreference.saveSessionPin(pin = "6699".toCharArray(), onSavedPin = onSavedPinMock) + + verify { onSavedPinMock.invoke() } val retrievedSessionPin = secureSharedPreference.retrieveSessionPin() diff --git a/android/engine/src/test/java/org/smartregister/fhircore/engine/util/extension/DateTimeExtensionTest.kt b/android/engine/src/test/java/org/smartregister/fhircore/engine/util/extension/DateTimeExtensionTest.kt index a20342c18e3..8b207c7464d 100644 --- a/android/engine/src/test/java/org/smartregister/fhircore/engine/util/extension/DateTimeExtensionTest.kt +++ b/android/engine/src/test/java/org/smartregister/fhircore/engine/util/extension/DateTimeExtensionTest.kt @@ -137,4 +137,26 @@ class DateTimeExtensionTest : RobolectricTest() { fun isTodayWithDateYesterdayShouldReturnFalse() { assertFalse(yesterday().isToday()) } + + @Test + fun testReformatDateWithValidDate() { + val inputDateString = "2022-02-02" + val currentFormat = "yyyy-MM-dd" + val desiredFormat = "dd/MM/yyyy" + + val result = reformatDate(inputDateString, currentFormat, desiredFormat) + + assertEquals("02/02/2022", result) + } + + @Test + fun testReformatDateWithInvalidDateFormat() { + val inputDateString = "02/02/2022" + val currentFormat = "yyyy-MM-dd" + val desiredFormat = "dd/MM/yyyy" + + val result = reformatDate(inputDateString, currentFormat, desiredFormat) + + assertEquals(inputDateString, result) + } } diff --git a/android/engine/src/test/java/org/smartregister/fhircore/engine/util/extension/FhirEngineExtensionTest.kt b/android/engine/src/test/java/org/smartregister/fhircore/engine/util/extension/FhirEngineExtensionTest.kt index 1061fd6d808..8a3d0b38512 100644 --- a/android/engine/src/test/java/org/smartregister/fhircore/engine/util/extension/FhirEngineExtensionTest.kt +++ b/android/engine/src/test/java/org/smartregister/fhircore/engine/util/extension/FhirEngineExtensionTest.kt @@ -28,6 +28,7 @@ import io.mockk.coEvery import io.mockk.coVerify import io.mockk.mockk import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.runTest import org.hl7.fhir.r4.model.CanonicalType import org.hl7.fhir.r4.model.Composition import org.hl7.fhir.r4.model.Library @@ -60,7 +61,7 @@ class FhirEngineExtensionTest : RobolectricTest() { } @Test - fun searchCompositionByIdentifier() = runBlocking { + fun searchCompositionByIdentifier() = runTest { coEvery { fhirEngine.search(any()) } returns listOf(SearchResult(resource = Composition().apply { id = "123" }, null, null)) diff --git a/android/engine/src/test/java/org/smartregister/fhircore/engine/util/extension/PatientExtensionTest.kt b/android/engine/src/test/java/org/smartregister/fhircore/engine/util/extension/PatientExtensionTest.kt index 3176a04e8fe..e8024083886 100644 --- a/android/engine/src/test/java/org/smartregister/fhircore/engine/util/extension/PatientExtensionTest.kt +++ b/android/engine/src/test/java/org/smartregister/fhircore/engine/util/extension/PatientExtensionTest.kt @@ -16,283 +16,29 @@ package org.smartregister.fhircore.engine.util.extension -import android.app.Application -import androidx.test.core.app.ApplicationProvider -import androidx.test.platform.app.InstrumentationRegistry -import java.time.LocalDate -import java.time.ZoneId -import java.util.Calendar -import java.util.Date -import org.hl7.fhir.r4.model.Enumerations +import org.hl7.fhir.r4.model.Coding +import org.hl7.fhir.r4.model.Meta import org.hl7.fhir.r4.model.Patient import org.junit.Assert import org.junit.Test -import org.smartregister.fhircore.engine.R import org.smartregister.fhircore.engine.robolectric.RobolectricTest class PatientExtensionTest : RobolectricTest() { - private val context = InstrumentationRegistry.getInstrumentation().context - - private fun getDateFromDaysAgo(daysAgo: Long, localDateNow: LocalDate = LocalDate.now()): Date { - val localDate = localDateNow.minusDays(daysAgo) - return Date.from(localDate.atStartOfDay(ZoneId.systemDefault()).toInstant()) - } - - @Test - fun testGetAgeString() { - val expectedAge = "1y" - Assert.assertEquals( - expectedAge, - calculateAge( - getDateFromDaysAgo(365, LocalDate.of(2023, 4, 4)), - context, - LocalDate.of(2023, 4, 4), - ), - ) - - val expectedAge2 = "1y 1m" - // passing days value for 1y 1m - Assert.assertEquals( - expectedAge2, - calculateAge( - getDateFromDaysAgo(399, LocalDate.of(2023, 4, 4)), - context, - LocalDate.of(2023, 4, 4), - ), - ) - - val expectedAge3 = "1y" - // passing days value for 1y 1w - Assert.assertEquals( - expectedAge3, - calculateAge( - getDateFromDaysAgo(372, LocalDate.of(2023, 4, 4)), - context, - LocalDate.of(2023, 4, 4), - ), - ) - - val expectedAge4 = "1m" - Assert.assertEquals( - expectedAge4, - calculateAge( - getDateFromDaysAgo(32, LocalDate.of(2023, 4, 4)), - context, - LocalDate.of(2023, 4, 4), - ), - ) - - val expectedAge6 = "1w" - Assert.assertEquals( - expectedAge6, - calculateAge( - getDateFromDaysAgo(7, LocalDate.of(2023, 4, 4)), - context, - LocalDate.of(2023, 4, 4), - ), - ) - - val expectedAge7 = "1w 2d" - Assert.assertEquals( - expectedAge7, - calculateAge( - getDateFromDaysAgo(9, LocalDate.of(2023, 4, 4)), - context, - LocalDate.of(2023, 4, 4), - ), - ) - - val expectedAge8 = "3d" - Assert.assertEquals( - expectedAge8, - calculateAge( - getDateFromDaysAgo(3, LocalDate.of(2023, 4, 4)), - context, - LocalDate.of(2023, 4, 4), - ), - ) - - val expectedAge9 = "1y 2m" - Assert.assertEquals( - expectedAge9, - calculateAge( - getDateFromDaysAgo(450, LocalDate.of(2023, 4, 4)), - context, - LocalDate.of(2023, 4, 4), - ), - ) - - val expectedAge10 = "40y 3m" - Assert.assertNotEquals( - expectedAge10, - calculateAge( - getDateFromDaysAgo(14700, LocalDate.of(2023, 4, 4)), - context, - LocalDate.of(2023, 4, 4), - ), - ) - - val expectedAge11 = "40y" - Assert.assertEquals( - expectedAge11, - calculateAge( - getDateFromDaysAgo(14700, LocalDate.of(2023, 4, 4)), - context, - LocalDate.of(2023, 4, 4), - ), - ) - - val expectedAge12 = "0d" - // if difference b/w current date and DOB is O from extractAge extension - Assert.assertEquals( - expectedAge12, - calculateAge( - getDateFromDaysAgo(0, LocalDate.of(2023, 4, 4)), - context, - LocalDate.of(2023, 4, 4), - ), - ) - - val expectedAge13 = "1y 6m" - // passing days value for 1y 6m - Assert.assertEquals( - expectedAge13, - calculateAge( - getDateFromDaysAgo(550, LocalDate.of(2023, 4, 4)), - context, - LocalDate.of(2023, 4, 4), - ), - ) - - val expectedAge14 = "5y" - // passing days value for 5y - Assert.assertEquals( - expectedAge14, - calculateAge( - getDateFromDaysAgo(1826, LocalDate.of(2023, 4, 4)), - context, - LocalDate.of(2023, 4, 4), - ), - ) - } - - @Test - fun testGetAgeStringFor49DayPeriod() { - val expectedAge5 = "1m 3w" - Assert.assertEquals( - expectedAge5, - calculateAge( - getDateFromDaysAgo(49, LocalDate.of(2023, 4, 4)), - context, - LocalDate.of(2023, 4, 4), - ), - ) - } - @Test - fun testExtractAge() { - val patient = - Patient().apply { birthDate = Calendar.getInstance().apply { add(Calendar.YEAR, -19) }.time } - - Assert.assertEquals("19y", patient.extractAge(context)) - } - - @Test - fun testExtractGenderShouldReturnMaleStringWhenPatientGenderIsMale() { - val patient = Patient().apply { gender = Enumerations.AdministrativeGender.MALE } - - Assert.assertEquals( - (ApplicationProvider.getApplicationContext() as Application).getString(R.string.male), - patient.extractGender(ApplicationProvider.getApplicationContext()), - ) - } - - @Test - fun testExtractGenderShouldReturnFemaleStringWhenPatientGenderIsFemale() { - val patient = Patient().apply { gender = Enumerations.AdministrativeGender.FEMALE } - - Assert.assertEquals( - (ApplicationProvider.getApplicationContext() as Application).getString(R.string.female), - patient.extractGender(ApplicationProvider.getApplicationContext()), - ) - } - - @Test - fun testExtractGenderShouldReturnOtherStringWhenPatientGenderIsOther() { - val patient = Patient().apply { gender = Enumerations.AdministrativeGender.OTHER } - - val applicationContext = (ApplicationProvider.getApplicationContext() as Application) - - Assert.assertEquals( - applicationContext.getString(R.string.other), - patient.extractGender(applicationContext), - ) - } - - @Test - fun testExtractGenderShouldReturnUnknownStringWhenPatientGenderIsUnknown() { - val patient = Patient().apply { gender = Enumerations.AdministrativeGender.UNKNOWN } - - Assert.assertEquals( - (ApplicationProvider.getApplicationContext() as Application).getString(R.string.unknown), - patient.extractGender(ApplicationProvider.getApplicationContext()), - ) - } - - @Test - fun testExtractGenderShouldReturnAnEmptyStringWhenPatientGenderIsNull() { - val patient = Patient().apply { gender = Enumerations.AdministrativeGender.NULL } - - Assert.assertEquals("", patient.extractGender(ApplicationProvider.getApplicationContext())) - } - - @Test - fun testExtractAgeShouldReturnAnEmptyStringWhenPatientDoesNotHaveBirthDate() { - val patient = Patient() - - Assert.assertEquals("", patient.extractAge(context)) - } - - @Test - fun testExtractAgeShouldReturnCallGetAgeStringFromDaysWhenPatientHasBirthDate() { - val currentDate = LocalDate.now() - - val oneYearAgo = currentDate.minusYears(1) - - val calendar = - Calendar.getInstance().apply { - timeInMillis = oneYearAgo.atStartOfDay(ZoneId.systemDefault()).toInstant().toEpochMilli() + fun testExtractFamilyTag() { + val familyTag = + Coding().apply { + system = "http://example.org/family-tags" + code = "family" + display = "Family Head" } - val patient = Patient().apply { birthDate = calendar.time } - Assert.assertEquals("1y", patient.extractAge(context)) - } - - @Test - fun testTranslateMaleGender() { - val patient = Patient().apply { gender = Enumerations.AdministrativeGender.MALE } - Assert.assertEquals( - "Male", - patient.gender.translateGender(ApplicationProvider.getApplicationContext()), - ) - } + val patient = Patient().apply { meta = Meta().apply { tag.add(familyTag) } } - @Test - fun testTranslateFemaleGender() { - val patient = Patient().apply { gender = Enumerations.AdministrativeGender.FEMALE } - Assert.assertEquals( - "Female", - patient.gender.translateGender(ApplicationProvider.getApplicationContext()), - ) - } - - @Test - fun testTranslateGenderReturnsUnknownWhenValeIsNotMaleOrFemale() { - val patient = Patient().apply { gender = Enumerations.AdministrativeGender.OTHER } Assert.assertEquals( - "Unknown", - patient.gender.translateGender(ApplicationProvider.getApplicationContext()), + familyTag, + patient.extractFamilyTag(), ) } } diff --git a/android/engine/src/test/java/org/smartregister/fhircore/engine/util/extension/QuestionnaireExtensionTest.kt b/android/engine/src/test/java/org/smartregister/fhircore/engine/util/extension/QuestionnaireExtensionTest.kt index 37d703c4a4a..be5cffa2609 100644 --- a/android/engine/src/test/java/org/smartregister/fhircore/engine/util/extension/QuestionnaireExtensionTest.kt +++ b/android/engine/src/test/java/org/smartregister/fhircore/engine/util/extension/QuestionnaireExtensionTest.kt @@ -16,27 +16,29 @@ package org.smartregister.fhircore.engine.util.extension -import org.hl7.fhir.r4.model.BooleanType +import java.util.UUID +import kotlinx.coroutines.test.runTest import org.hl7.fhir.r4.model.Bundle import org.hl7.fhir.r4.model.Coding -import org.hl7.fhir.r4.model.DateTimeType -import org.hl7.fhir.r4.model.DateType -import org.hl7.fhir.r4.model.DecimalType +import org.hl7.fhir.r4.model.Enumerations import org.hl7.fhir.r4.model.Enumerations.DataType import org.hl7.fhir.r4.model.Expression import org.hl7.fhir.r4.model.Extension import org.hl7.fhir.r4.model.IntegerType import org.hl7.fhir.r4.model.Questionnaire import org.hl7.fhir.r4.model.QuestionnaireResponse +import org.hl7.fhir.r4.model.ResourceType import org.hl7.fhir.r4.model.StringType -import org.hl7.fhir.r4.model.TimeType -import org.hl7.fhir.r4.model.UriType +import org.hl7.fhir.r4.model.Type import org.junit.Assert import org.junit.Before import org.junit.Test import org.smartregister.fhircore.engine.app.fakes.Faker +import org.smartregister.fhircore.engine.configuration.QuestionnaireConfig import org.smartregister.fhircore.engine.domain.model.ActionParameter import org.smartregister.fhircore.engine.domain.model.ActionParameterType +import org.smartregister.fhircore.engine.domain.model.QuestionnaireType +import org.smartregister.fhircore.engine.domain.model.RuleConfig import org.smartregister.fhircore.engine.robolectric.RobolectricTest class QuestionnaireExtensionTest : RobolectricTest() { @@ -401,46 +403,89 @@ class QuestionnaireExtensionTest : RobolectricTest() { } @Test - fun testCastToTypeReturnsCorrectTypes() { - val booleanType = "true".castToType(DataType.BOOLEAN) - Assert.assertEquals(BooleanType().fhirType(), booleanType?.fhirType()) - Assert.assertEquals("true", booleanType.valueToString()) - - val decimalType = "6.4".castToType(DataType.DECIMAL) - Assert.assertEquals(DecimalType().fhirType(), decimalType?.fhirType()) - Assert.assertEquals("6.4", decimalType.valueToString()) - - val integerType = "4".castToType(DataType.INTEGER) - Assert.assertEquals(IntegerType().fhirType(), integerType?.fhirType()) - Assert.assertEquals("4", integerType.valueToString()) - - val dateType = "2020-02-02".castToType(DataType.DATE) - Assert.assertEquals(DateType().fhirType(), dateType?.fhirType()) - Assert.assertEquals("02-Feb-2020", dateType.valueToString()) - - val dateTimeType = "2020-02-02T13:00:32".castToType(DataType.DATETIME) - Assert.assertEquals(DateTimeType().fhirType(), dateTimeType?.fhirType()) - Assert.assertEquals("02-Feb-2020", dateTimeType.valueToString()) - - val timeType = "T13:00:32".castToType(DataType.TIME) - Assert.assertEquals(TimeType().fhirType(), timeType?.fhirType()) - Assert.assertEquals("T13:00:32", timeType.valueToString()) + fun testPrepopulateQuestionnaireWithComputedValues() = runTest { + val questionnaireConfig = + QuestionnaireConfig( + id = UUID.randomUUID().toString(), + resourceIdentifier = "patient.id", + resourceType = ResourceType.Patient, + barcodeLinkId = "patient-barcode", + type = QuestionnaireType.READ_ONLY.name, + configRules = + listOf( + RuleConfig( + name = "rule1", + actions = listOf("data.put('rule1', 'Sample Rule')"), + ), + ), + extraParams = + listOf( + ActionParameter( + key = "rule1", + value = "@{rule1}", + paramType = ActionParameterType.PARAMDATA, + ), + ), + ) + val patientAgeLinkId = "patient-age" + val actionParameter = + listOf( + ActionParameter( + paramType = ActionParameterType.PREPOPULATE, + linkId = patientAgeLinkId, + dataType = Enumerations.DataType.INTEGER, + key = patientAgeLinkId, + value = "20", + ), + ) + val questionnaire = + Questionnaire().apply { + addItem( + Questionnaire.QuestionnaireItemComponent().apply { + linkId = patientAgeLinkId + type = Questionnaire.QuestionnaireItemType.INTEGER + readOnly = true + }, + ) + } - val stringType = "str".castToType(DataType.STRING) - Assert.assertEquals(StringType().fhirType(), stringType?.fhirType()) - Assert.assertEquals("str", stringType.valueToString()) + questionnaire.prepopulateWithComputedConfigValues( + questionnaireConfig, + actionParameter, + { mapOf(patientAgeLinkId to "20") }, + { _, _ -> "" }, + ) - val uriType = "https://str.org".castToType(DataType.URI) - Assert.assertEquals(UriType().fhirType(), uriType?.fhirType()) - Assert.assertEquals("https://str.org", uriType.valueToString()) + // Questionnaire.item pre-populated + val questionnairePatientAgeItem = questionnaire.find(patientAgeLinkId) + val itemValue: Type? = questionnairePatientAgeItem?.initial?.firstOrNull()?.value + Assert.assertTrue(itemValue is IntegerType) + Assert.assertEquals(20, itemValue?.primitiveValue()?.toInt()) + + // Barcode linkId updated + val questionnaireBarcodeItem = questionnaireConfig.barcodeLinkId?.let { questionnaire.find(it) } + val barCodeItemValue: Type? = questionnaireBarcodeItem?.initial?.firstOrNull()?.value + Assert.assertFalse(barCodeItemValue is StringType) + Assert.assertNull( + questionnaireConfig.resourceIdentifier, + barCodeItemValue?.primitiveValue(), + ) + } - // test invalid JSON - val codingType = "invalid".castToType(DataType.CODING) - Assert.assertEquals(null, codingType) + @Test + fun testQuestionnaireResponseStatusReturnsCompletedWhenIsEditableIsTrue() { + val questionnaireConfig = + QuestionnaireConfig(id = "patient-reg-config", type = QuestionnaireType.EDIT.name) + Assert.assertEquals("completed", questionnaireConfig.questionnaireResponseStatus()) + } - val quantityType = "invalid".castToType(DataType.QUANTITY) - Assert.assertEquals(null, quantityType) + fun testQuestionnaireResponseStatusReturnsInProgressWhenSaveDraftIsTrue() { + val questionnaireConfig = QuestionnaireConfig(id = "patient-reg-config", saveDraft = true) + Assert.assertEquals("in-progress", questionnaireConfig.questionnaireResponseStatus()) + } - // TODO: test valid JSON + fun testQuestionnaireResponseStatusReturnsNullWhenBothSaveDraftAndIsEditableAreFalse() { + val questionnaireConfig = QuestionnaireConfig(id = "patient-reg-config", saveDraft = true) + Assert.assertEquals("in-progress", questionnaireConfig.questionnaireResponseStatus()) } } diff --git a/android/engine/src/test/java/org/smartregister/fhircore/engine/util/extension/QuestionnaireResponseExtensionTest.kt b/android/engine/src/test/java/org/smartregister/fhircore/engine/util/extension/QuestionnaireResponseExtensionTest.kt index d1d89ca8389..d2c12ca414c 100644 --- a/android/engine/src/test/java/org/smartregister/fhircore/engine/util/extension/QuestionnaireResponseExtensionTest.kt +++ b/android/engine/src/test/java/org/smartregister/fhircore/engine/util/extension/QuestionnaireResponseExtensionTest.kt @@ -16,7 +16,9 @@ package org.smartregister.fhircore.engine.util.extension +import org.hl7.fhir.r4.model.IntegerType import org.hl7.fhir.r4.model.QuestionnaireResponse +import org.hl7.fhir.r4.model.StringType import org.junit.Assert import org.junit.Before import org.junit.Test @@ -47,4 +49,85 @@ class QuestionnaireResponseExtensionTest { val item2 = item1.itemFirstRep Assert.assertNull(item2.text) } + + @Test + fun testQuestionnaireResponsePackingRepeatedGroups() { + val unPackedRepeatingGroupQuestionnaireResponse = + QuestionnaireResponse().apply { + addItem( + QuestionnaireResponse.QuestionnaireResponseItemComponent(StringType("page-1")).apply { + addItem( + QuestionnaireResponse.QuestionnaireResponseItemComponent( + StringType("repeating-group"), + ) + .apply { + addItem( + QuestionnaireResponse.QuestionnaireResponseItemComponent(StringType("bp")) + .apply { + addAnswer( + QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { + value = + IntegerType( + 124, + ) + }, + ) + }, + ) + }, + ) + + addItem( + QuestionnaireResponse.QuestionnaireResponseItemComponent( + StringType("repeating-group"), + ) + .apply { + addItem( + QuestionnaireResponse.QuestionnaireResponseItemComponent(StringType("bp")) + .apply { + addAnswer( + QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { + value = + IntegerType( + 104, + ) + }, + ) + }, + ) + }, + ) + + addItem( + QuestionnaireResponse.QuestionnaireResponseItemComponent( + StringType("repeating-group"), + ) + .apply { + addItem( + QuestionnaireResponse.QuestionnaireResponseItemComponent(StringType("bp")) + .apply { + addAnswer( + QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { + value = + IntegerType( + 138, + ) + }, + ) + }, + ) + }, + ) + }, + ) + } + Assert.assertEquals(3, unPackedRepeatingGroupQuestionnaireResponse.itemFirstRep.item.size) + val packedRepeatingGroupsQuestionnaireResponse = + unPackedRepeatingGroupQuestionnaireResponse.copy().apply { this.packRepeatedGroups() } + Assert.assertEquals(1, packedRepeatingGroupsQuestionnaireResponse.itemFirstRep.item.size) + Assert.assertEquals( + 3, + packedRepeatingGroupsQuestionnaireResponse.itemFirstRep.itemFirstRep.answer.size, + ) + } } diff --git a/android/engine/src/test/java/org/smartregister/fhircore/engine/util/extension/ResourceExtensionTest.kt b/android/engine/src/test/java/org/smartregister/fhircore/engine/util/extension/ResourceExtensionTest.kt index 748b4e02ec4..2a11213eb1e 100644 --- a/android/engine/src/test/java/org/smartregister/fhircore/engine/util/extension/ResourceExtensionTest.kt +++ b/android/engine/src/test/java/org/smartregister/fhircore/engine/util/extension/ResourceExtensionTest.kt @@ -18,9 +18,6 @@ package org.smartregister.fhircore.engine.util.extension import android.app.Application import androidx.test.core.app.ApplicationProvider -import ca.uhn.fhir.context.FhirContext -import ca.uhn.fhir.context.FhirVersionEnum -import ca.uhn.fhir.parser.IParser import com.google.android.fhir.datacapture.extensions.logicalId import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidTest @@ -28,6 +25,10 @@ import io.mockk.coEvery import io.mockk.coVerify import io.mockk.mockk import java.math.BigDecimal +import java.text.SimpleDateFormat +import java.time.LocalDate +import java.time.ZoneId +import java.util.Calendar import java.util.Date import javax.inject.Inject import kotlinx.coroutines.runBlocking @@ -35,6 +36,7 @@ import org.hl7.fhir.r4.model.BooleanType import org.hl7.fhir.r4.model.CodeableConcept import org.hl7.fhir.r4.model.Coding import org.hl7.fhir.r4.model.Condition +import org.hl7.fhir.r4.model.Consent import org.hl7.fhir.r4.model.DateTimeType import org.hl7.fhir.r4.model.Encounter import org.hl7.fhir.r4.model.Enumerations @@ -59,6 +61,7 @@ import org.junit.Assert import org.junit.Before import org.junit.Rule import org.junit.Test +import org.smartregister.fhircore.engine.R import org.smartregister.fhircore.engine.configuration.LinkIdConfig import org.smartregister.fhircore.engine.configuration.LinkIdType import org.smartregister.fhircore.engine.configuration.QuestionnaireConfig @@ -685,46 +688,6 @@ class ResourceExtensionTest : RobolectricTest() { ) } - @Test - fun testGenerateMissingItemsFromQuestionnaireShouldNotThrowException() { - val patientRegistrationQuestionnaire = - "register-patient-missingitems/missingitem-questionnaire.json".readFile() - val patientRegistrationQuestionnaireResponse = - "register-patient-missingitems/missingitem-questionnaire-response.json".readFile() - val iParser: IParser = FhirContext.forCached(FhirVersionEnum.R4).newJsonParser() - val questionnaire = - iParser.parseResource(Questionnaire::class.java, patientRegistrationQuestionnaire) - val questionnaireResponse = - iParser.parseResource( - QuestionnaireResponse::class.java, - patientRegistrationQuestionnaireResponse, - ) - - questionnaire.item.generateMissingItems(questionnaireResponse.item) - - Assert.assertTrue(questionnaireResponse.item.size <= questionnaire.item.size) - } - - @Test - fun testGenerateMissingItemsFromQuestionnaireResponseShouldNotThrowException() { - val patientRegistrationQuestionnaire = - "register-patient-missingitems/missingitem-questionnaire.json".readFile() - val patientRegistrationQuestionnaireResponse = - "register-patient-missingitems/missingitem-questionnaire-response.json".readFile() - val iParser: IParser = FhirContext.forCached(FhirVersionEnum.R4).newJsonParser() - val questionnaire = - iParser.parseResource(Questionnaire::class.java, patientRegistrationQuestionnaire) - val questionnaireResponse = - iParser.parseResource( - QuestionnaireResponse::class.java, - patientRegistrationQuestionnaireResponse, - ) - - questionnaireResponse.generateMissingItems(questionnaire) - - Assert.assertTrue(questionnaireResponse.item.size <= questionnaire.item.size) - } - @Test fun `prepareQuestionsForReadingOrEditing should set readOnly to true when passed`() { val questionnaire = Questionnaire() @@ -896,6 +859,13 @@ class ResourceExtensionTest : RobolectricTest() { Assert.assertEquals("Organization/12345", patient.managingOrganization.reference) } + @Test + fun `test Organization Info Appended on Consent Resource`() { + val consent = Consent().apply { this.id = "123456" } + consent.appendOrganizationInfo(listOf("Organization/12345")) + Assert.assertEquals("Organization/12345", consent.organization.first().reference) + } + @Test fun `prepareQuestionsForEditing should set readOnly correctly when readOnlyLinkIds passed`() { val questionnaire = Questionnaire() @@ -908,4 +878,320 @@ class ResourceExtensionTest : RobolectricTest() { Assert.assertFalse(questionnaire.item[1].readOnly) Assert.assertTrue(questionnaire.item[2].readOnly) } + + @Test + fun testExtractGenderShouldReturnMaleStringWhenPatientGenderIsMale() { + val patient = Patient().apply { gender = Enumerations.AdministrativeGender.MALE } + + Assert.assertEquals( + (ApplicationProvider.getApplicationContext() as Application).getString(R.string.male), + patient.extractGender(ApplicationProvider.getApplicationContext()), + ) + } + + @Test + fun testExtractGenderShouldReturnMaleStringWhenRelatedPersonGenderIsMale() { + val relatedPerson = RelatedPerson().apply { gender = Enumerations.AdministrativeGender.MALE } + + Assert.assertEquals( + (ApplicationProvider.getApplicationContext() as Application).getString(R.string.male), + relatedPerson.extractGender(ApplicationProvider.getApplicationContext()), + ) + } + + @Test + fun testExtractGenderShouldReturnFemaleStringWhenPatientGenderIsFemale() { + val patient = Patient().apply { gender = Enumerations.AdministrativeGender.FEMALE } + + Assert.assertEquals( + (ApplicationProvider.getApplicationContext() as Application).getString(R.string.female), + patient.extractGender(ApplicationProvider.getApplicationContext()), + ) + } + + @Test + fun testExtractGenderShouldReturnOtherStringWhenPatientGenderIsOther() { + val patient = Patient().apply { gender = Enumerations.AdministrativeGender.OTHER } + + val applicationContext = (ApplicationProvider.getApplicationContext() as Application) + + Assert.assertEquals( + applicationContext.getString(R.string.other), + patient.extractGender(applicationContext), + ) + } + + @Test + fun testExtractGenderShouldReturnUnknownStringWhenPatientGenderIsUnknown() { + val patient = Patient().apply { gender = Enumerations.AdministrativeGender.UNKNOWN } + + Assert.assertEquals( + (ApplicationProvider.getApplicationContext() as Application).getString(R.string.unknown), + patient.extractGender(ApplicationProvider.getApplicationContext()), + ) + } + + @Test + fun testExtractGenderShouldReturnNullWhenPatientGenderIsNull() { + val patient = Patient().apply { gender = Enumerations.AdministrativeGender.NULL } + + Assert.assertEquals("", patient.extractGender(ApplicationProvider.getApplicationContext())) + } + + @Test + fun testExtractGenderShouldReturnNullWhenResourceDoesNotHaveGenderField() { + val resource = Task() + + Assert.assertEquals("", resource.extractGender(ApplicationProvider.getApplicationContext())) + } + + @Test + fun testTranslateMaleGender() { + val patient = Patient().apply { gender = Enumerations.AdministrativeGender.MALE } + Assert.assertEquals( + "Male", + patient.gender.translateGender(ApplicationProvider.getApplicationContext()), + ) + } + + @Test + fun testTranslateFemaleGender() { + val patient = Patient().apply { gender = Enumerations.AdministrativeGender.FEMALE } + Assert.assertEquals( + "Female", + patient.gender.translateGender(ApplicationProvider.getApplicationContext()), + ) + } + + @Test + fun testTranslateGenderReturnsUnknownWhenValeIsNotMaleOrFemale() { + val patient = Patient().apply { gender = Enumerations.AdministrativeGender.OTHER } + Assert.assertEquals( + "Unknown", + patient.gender.translateGender(ApplicationProvider.getApplicationContext()), + ) + } + + private fun getDateFromDaysAgo(daysAgo: Long, localDateNow: LocalDate = LocalDate.now()): Date { + val localDate = localDateNow.minusDays(daysAgo) + return Date.from(localDate.atStartOfDay(ZoneId.systemDefault()).toInstant()) + } + + @Test + fun testGetAgeString() { + val expectedAge = "1y" + Assert.assertEquals( + expectedAge, + calculateAge( + getDateFromDaysAgo(365, LocalDate.of(2023, 4, 4)), + context, + LocalDate.of(2023, 4, 4), + ), + ) + + val expectedAge2 = "1y 1m" + // passing days value for 1y 1m + Assert.assertEquals( + expectedAge2, + calculateAge( + getDateFromDaysAgo(399, LocalDate.of(2023, 4, 4)), + context, + LocalDate.of(2023, 4, 4), + ), + ) + + val expectedAge3 = "1y" + // passing days value for 1y 1w + Assert.assertEquals( + expectedAge3, + calculateAge( + getDateFromDaysAgo(372, LocalDate.of(2023, 4, 4)), + context, + LocalDate.of(2023, 4, 4), + ), + ) + + val expectedAge4 = "1m" + Assert.assertEquals( + expectedAge4, + calculateAge( + getDateFromDaysAgo(32, LocalDate.of(2023, 4, 4)), + context, + LocalDate.of(2023, 4, 4), + ), + ) + + val expectedAge6 = "1w" + Assert.assertEquals( + expectedAge6, + calculateAge( + getDateFromDaysAgo(7, LocalDate.of(2023, 4, 4)), + context, + LocalDate.of(2023, 4, 4), + ), + ) + + val expectedAge7 = "1w 2d" + Assert.assertEquals( + expectedAge7, + calculateAge( + getDateFromDaysAgo(9, LocalDate.of(2023, 4, 4)), + context, + LocalDate.of(2023, 4, 4), + ), + ) + + val expectedAge8 = "3d" + Assert.assertEquals( + expectedAge8, + calculateAge( + getDateFromDaysAgo(3, LocalDate.of(2023, 4, 4)), + context, + LocalDate.of(2023, 4, 4), + ), + ) + + val expectedAge9 = "1y 2m" + Assert.assertEquals( + expectedAge9, + calculateAge( + getDateFromDaysAgo(450, LocalDate.of(2023, 4, 4)), + context, + LocalDate.of(2023, 4, 4), + ), + ) + + val expectedAge10 = "40y 3m" + Assert.assertNotEquals( + expectedAge10, + calculateAge( + getDateFromDaysAgo(14700, LocalDate.of(2023, 4, 4)), + context, + LocalDate.of(2023, 4, 4), + ), + ) + + val expectedAge11 = "40y" + Assert.assertEquals( + expectedAge11, + calculateAge( + getDateFromDaysAgo(14700, LocalDate.of(2023, 4, 4)), + context, + LocalDate.of(2023, 4, 4), + ), + ) + + val expectedAge12 = "0d" + // if difference b/w current date and DOB is O from extractAge extension + Assert.assertEquals( + expectedAge12, + calculateAge( + getDateFromDaysAgo(0, LocalDate.of(2023, 4, 4)), + context, + LocalDate.of(2023, 4, 4), + ), + ) + + val expectedAge13 = "1y 6m" + // passing days value for 1y 6m + Assert.assertEquals( + expectedAge13, + calculateAge( + getDateFromDaysAgo(550, LocalDate.of(2023, 4, 4)), + context, + LocalDate.of(2023, 4, 4), + ), + ) + + val expectedAge14 = "5y" + // passing days value for 5y + Assert.assertEquals( + expectedAge14, + calculateAge( + getDateFromDaysAgo(1826, LocalDate.of(2023, 4, 4)), + context, + LocalDate.of(2023, 4, 4), + ), + ) + } + + @Test + fun testGetAgeStringFor49DayPeriod() { + val expectedAge5 = "1m 3w" + Assert.assertEquals( + expectedAge5, + calculateAge( + getDateFromDaysAgo(49, LocalDate.of(2023, 4, 4)), + context, + LocalDate.of(2023, 4, 4), + ), + ) + } + + @Test + fun testExtractAgeReturnsCorrectDateStringForAPatient() { + val patient = + Patient().apply { birthDate = Calendar.getInstance().apply { add(Calendar.YEAR, -19) }.time } + + Assert.assertEquals("19y", patient.extractAge(context)) + } + + @Test + fun testExtractAgeReturnsCorrectDateStringForARelatedPerson() { + val relatedPerson = + RelatedPerson().apply { + birthDate = Calendar.getInstance().apply { add(Calendar.YEAR, -21) }.time + } + + Assert.assertEquals("21y", relatedPerson.extractAge(context)) + } + + @Test + fun testExtractAgeShouldReturnAnEmptyStringWhenResourceDoesNotHaveBirthDate() { + val resource = Task() + + Assert.assertEquals("", resource.extractAge(context)) + } + + @Test + fun testExtractAgeShouldReturnAgeStringFromDaysWhenPatientHasBirthDate() { + val currentDate = LocalDate.now() + val oneYearAgo = currentDate.minusYears(1) + + val calendar = + Calendar.getInstance().apply { + timeInMillis = oneYearAgo.atStartOfDay(ZoneId.systemDefault()).toInstant().toEpochMilli() + } + + val patient = Patient().apply { birthDate = calendar.time } + Assert.assertEquals("1y", patient.extractAge(context)) + } + + @Test + fun extractBirthDateReturnsCorrectDateWhenResourceIsPatientAndHasAValidBirthDate() { + val resource = Patient().setBirthDate(org.joda.time.LocalDate.parse("2015-10-03").toDate()) + + Assert.assertEquals( + "03/10/2015", + resource.extractBirthDate()?.let { SimpleDateFormat("dd/MM/yyyy").format(it) }, + ) + } + + @Test + fun extractBirthDateReturnsCorrectDateWhenResourceIsRelatedPersonAndHasAValidBirthDate() { + val resource = + RelatedPerson().setBirthDate(org.joda.time.LocalDate.parse("2015-10-03").toDate()) + + Assert.assertEquals( + "03/10/2015", + resource.extractBirthDate()?.let { SimpleDateFormat("dd/MM/yyyy").format(it) }, + ) + } + + @Test + fun extractBirthDateReturnsNullWhenResourceDoesNotHaveABirthDateField() { + val resource = Task() + + Assert.assertNull(resource.extractBirthDate()) + } } diff --git a/android/engine/src/test/java/org/smartregister/fhircore/engine/util/helper/TransformSupportServicesTest.kt b/android/engine/src/test/java/org/smartregister/fhircore/engine/util/helper/TransformSupportServicesTest.kt index 278956a416e..f7c78a0faba 100644 --- a/android/engine/src/test/java/org/smartregister/fhircore/engine/util/helper/TransformSupportServicesTest.kt +++ b/android/engine/src/test/java/org/smartregister/fhircore/engine/util/helper/TransformSupportServicesTest.kt @@ -19,6 +19,7 @@ package org.smartregister.fhircore.engine.util.helper import io.mockk.mockk import org.hl7.fhir.exceptions.FHIRException import org.hl7.fhir.r4.model.CarePlan +import org.hl7.fhir.r4.model.Consent import org.hl7.fhir.r4.model.Encounter import org.hl7.fhir.r4.model.EpisodeOfCare import org.hl7.fhir.r4.model.Group @@ -129,6 +130,44 @@ class TransformSupportServicesTest : RobolectricTest() { ) } + @Test + fun `createType() should return ConsentPolicyComponent when given Consent_Policy`() { + Assert.assertTrue( + transformSupportServices.createType("", "Consent_Policy") is Consent.ConsentPolicyComponent, + ) + } + + @Test + fun `createType() should return ConsentVerificationComponent when given Consent_Verification`() { + Assert.assertTrue( + transformSupportServices.createType("", "Consent_Verification") + is Consent.ConsentVerificationComponent, + ) + } + + @Test + fun `createType() should return provisionComponent when given Consent_Provision`() { + Assert.assertTrue( + transformSupportServices.createType("", "Consent_Provision") is Consent.provisionComponent, + ) + } + + @Test + fun `createType() should return provisionActorComponent when given Consent_ProvisionActor`() { + Assert.assertTrue( + transformSupportServices.createType("", "Consent_ProvisionActor") + is Consent.provisionActorComponent, + ) + } + + @Test + fun `createType() should return provisionDataComponent when given Consent_ProvisionData`() { + Assert.assertTrue( + transformSupportServices.createType("", "Consent_ProvisionData") + is Consent.provisionDataComponent, + ) + } + @Test fun `createType() should return Time when given time`() { Assert.assertTrue(transformSupportServices.createType("", "time") is TimeType) diff --git a/android/engine/src/test/java/org/smartregister/fhircore/engine/util/location/LocationUtilsTest.kt b/android/engine/src/test/java/org/smartregister/fhircore/engine/util/location/LocationUtilsTest.kt index 5faf86078f9..81a95afbfea 100644 --- a/android/engine/src/test/java/org/smartregister/fhircore/engine/util/location/LocationUtilsTest.kt +++ b/android/engine/src/test/java/org/smartregister/fhircore/engine/util/location/LocationUtilsTest.kt @@ -26,7 +26,7 @@ import kotlin.coroutines.cancellation.CancellationException import kotlinx.coroutines.cancel import kotlinx.coroutines.delay import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals import org.junit.Assert.assertNotNull import org.junit.Before @@ -72,7 +72,7 @@ class LocationUtilsTest : RobolectricTest() { } @Test - fun `test getAccurateLocation`() = runBlocking { + fun `test getAccurateLocation`() = runTest { val location = Location("Test location").apply { latitude = 36.0 @@ -88,7 +88,7 @@ class LocationUtilsTest : RobolectricTest() { } @Test - fun `test getApproximateLocation`() = runBlocking { + fun `test getApproximateLocation`() = runTest { val location = Location("").apply { latitude = 36.0 @@ -102,7 +102,7 @@ class LocationUtilsTest : RobolectricTest() { } @Test - fun `test getAccurateLocation with cancellation`() = runBlocking { + fun `test getAccurateLocation with cancellation`() = runTest { val job = launch { delay(500) coroutineContext.cancel() diff --git a/android/engine/src/test/java/org/smartregister/fhircore/engine/util/validation/ResourceValidationRequestTest.kt b/android/engine/src/test/java/org/smartregister/fhircore/engine/util/validation/ResourceValidationRequestTest.kt new file mode 100644 index 00000000000..0e3d8d2a8f2 --- /dev/null +++ b/android/engine/src/test/java/org/smartregister/fhircore/engine/util/validation/ResourceValidationRequestTest.kt @@ -0,0 +1,151 @@ +/* + * Copyright 2021-2024 Ona Systems, Inc + * + * 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 org.smartregister.fhircore.engine.util.validation + +import ca.uhn.fhir.validation.FhirValidator +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import io.mockk.mockkObject +import io.mockk.unmockkObject +import io.mockk.verify +import javax.inject.Inject +import kotlinx.coroutines.test.runTest +import org.hl7.fhir.r4.model.CarePlan +import org.hl7.fhir.r4.model.Patient +import org.hl7.fhir.r4.model.Reference +import org.junit.Assert +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.smartregister.fhircore.engine.robolectric.RobolectricTest +import timber.log.Timber + +@HiltAndroidTest +class ResourceValidationRequestTest : RobolectricTest() { + @get:Rule var hiltRule = HiltAndroidRule(this) + + @Inject lateinit var validator: FhirValidator + + @Inject lateinit var resourceValidationRequestHandler: ResourceValidationRequestHandler + + @Before + fun setUp() { + hiltRule.inject() + } + + @Test + fun testHandleResourceValidationRequestValidatesInvalidResourceLoggingErrors() = runTest { + mockkObject(Timber) + val resource = + CarePlan().apply { + id = "test-careplan" + status = CarePlan.CarePlanStatus.ACTIVE + intent = CarePlan.CarePlanIntent.PLAN + subject = Reference("f4bd3e29-f0f8-464e-97af-923b83664ccc") + } + val validationRequest = ResourceValidationRequest(resource) + resourceValidationRequestHandler.handleResourceValidationRequest(validationRequest) + verify { + Timber.e( + withArg { + Assert.assertTrue( + it.contains( + "CarePlan.subject - The syntax of the reference 'f4bd3e29-f0f8-464e-97af-923b83664ccc' looks incorrect, and it should be checked -- (WARNING)", + ), + ) + }, + ) + } + unmockkObject(Timber) + } + + @Test + fun testCheckResourceValidValidatesResourceStructureWhenCarePlanResourceInvalid() = runTest { + val basicCarePlan = CarePlan() + val resultsWrapper = validator.checkResources(listOf(basicCarePlan)) + Assert.assertTrue( + resultsWrapper.errorMessages.any { + it.contains( + "CarePlan.status: minimum required = 1, but only found 0", + ignoreCase = true, + ) + }, + ) + Assert.assertTrue( + resultsWrapper.errorMessages.any { + it.contains( + "CarePlan.intent: minimum required = 1, but only found 0", + ignoreCase = true, + ) + }, + ) + } + + @Test + fun testCheckResourceValidValidatesReferenceType() = runTest { + val carePlan = + CarePlan().apply { + status = CarePlan.CarePlanStatus.ACTIVE + intent = CarePlan.CarePlanIntent.PLAN + subject = Reference("Task/unknown") + } + val resultsWrapper = validator.checkResources(listOf(carePlan)) + Assert.assertEquals(1, resultsWrapper.errorMessages.size) + Assert.assertTrue( + resultsWrapper.errorMessages + .first() + .contains( + "CarePlan.subject - The type 'Task' implied by the reference URL Task/unknown is not a valid Target for this element (must be one of [Group, Patient])", + ignoreCase = true, + ), + ) + } + + @Test + fun testCheckResourceValidValidatesReferenceWithNoType() = runTest { + val carePlan = + CarePlan().apply { + status = CarePlan.CarePlanStatus.ACTIVE + intent = CarePlan.CarePlanIntent.PLAN + subject = Reference("unknown") + } + val resultsWrapper = validator.checkResources(listOf(carePlan)) + Assert.assertEquals(1, resultsWrapper.errorMessages.size) + Assert.assertTrue( + resultsWrapper.errorMessages + .first() + .contains( + "CarePlan.subject - The syntax of the reference 'unknown' looks incorrect, and it should be checked", + ignoreCase = true, + ), + ) + } + + @Test + fun testCheckResourceValidValidatesResourceCorrectly() = runTest { + val patient = Patient() + val carePlan = + CarePlan().apply { + status = CarePlan.CarePlanStatus.ACTIVE + intent = CarePlan.CarePlanIntent.PLAN + subject = Reference(patient) + } + val resultsWrapper = validator.checkResources(listOf(carePlan)) + Assert.assertEquals(1, resultsWrapper.errorMessages.size) + Assert.assertTrue(resultsWrapper.errorMessages.first().isBlank()) + } +} diff --git a/android/engine/src/test/resources/plans/child-immunization-schedule/structure-map-register.txt b/android/engine/src/test/resources/plans/child-immunization-schedule/structure-map-register.txt index 0b200d89431..57281e92239 100644 --- a/android/engine/src/test/resources/plans/child-immunization-schedule/structure-map-register.txt +++ b/android/engine/src/test/resources/plans/child-immunization-schedule/structure-map-register.txt @@ -123,7 +123,7 @@ group extractTaskRestriction(source subject: Patient, target task: Task, source startDateTime.value = evaluate(start, $this.value.substring(0,10) + 'T00:00:00.00Z') "rule_period_start"; subject -> taskRestrictionPeriod.end = create('dateTime') as endDateTime, - endDateTime.value = evaluate(start, (($this + (((restrictionEndDate).toString() + ' \'days\'')).toQuantity()).value.substring(0,10)) + 'T00:00:00.00Z') "rule_period_end"; + endDateTime.value = evaluate(start, (($this + (((restrictionEndDate).toString() + ' days')).toQuantity()).value.substring(0,10)) + 'T00:00:00.00Z') "rule_period_end"; subject -> task.restriction = taskRestriction "rule_restriction_period"; } "rule_task_restriction_period"; diff --git a/android/engine/src/test/resources/plans/disease-followup/structure-map.txt b/android/engine/src/test/resources/plans/disease-followup/structure-map.txt index 3e5069a7d1b..36e3ab2eac8 100644 --- a/android/engine/src/test/resources/plans/disease-followup/structure-map.txt +++ b/android/engine/src/test/resources/plans/disease-followup/structure-map.txt @@ -27,7 +27,9 @@ group ExtractActivityDetail(source subject : Patient, source definition: Activit subject then ExtractDiseaseCode(src, det) "r_act_det_data"; subject -> det.scheduled = evaluate(definition, $this.timing) as timing, evaluate(timing, $this.repeat) as repeat then { - subject -> evaluate(subject, today()) as dueDate, evaluate(subject, today() + ((repeat.count.toString().toInteger() - 1).toString() + ' \'months\'').toQuantity()) as maxDate + subject -> evaluate(subject, ((repeat.count.toString().toInteger() - 1).toString() + ' months').toQuantity()) as duration, + duration.code = 'months', + evaluate(subject, today()) as dueDate, evaluate(subject, today() + duration) as maxDate then ExtractTasks(dueDate, maxDate, repeat, subject, careplan, activity, timing) "r_tasks"; subject -> repeat.count = create('positiveInt') as c, c.value = evaluate(activity, $this.outcomeReference.count().value) "r_task_rep_count"; } "r_tim_repeat"; @@ -48,7 +50,8 @@ group ExtractTasks( // start of task is today OR first date of every month if future month | end is last day of given month create('date') as startOfMonth, startOfMonth.value = evaluate(dueDate, $this.value.substring(0,7) + '-01'), create('date') as start, start.value = evaluate(dueDate, iif($this = today(), $this, startOfMonth).value ), - evaluate(startOfMonth, ($this + '1 \'months\''.toQuantity()) - '1 \'days\''.toQuantity()) as end, + evaluate(startOfMonth, '1 month'.toQuantity()) as duration1month, duration1month.code = 'month', + evaluate(startOfMonth, ($this + duration1month) - '1 day'.toQuantity()) as end, create('Period') as period, careplan.contained = create('Task') as task then { subject then ExtractPeriod(start, end, period) "r_task_period_extr"; @@ -67,7 +70,9 @@ group ExtractTasks( subject -> task.reasonReference = create('Reference') as ref, ref.reference = 'Questionnaire/e14b5743-0a06-4ab5-aaee-ac158d4cb64f' "r_task_reason_ref"; subject -> activity.outcomeReference = reference(task) "r_cp_task_ref"; subject -> timing.event = evaluate(period, $this.start) "r_activity_timing"; - repeat -> evaluate(period, $this.start + (repeat.period.toString() + ' \'months\'').toQuantity()) as nextDueDate + repeat -> evaluate(period, (repeat.period.toString() + ' months').toQuantity()) as duration, + duration.code = 'months', + evaluate(period, $this.start + duration) as nextDueDate then ExtractTasks(nextDueDate, maxDate, repeat, subject, careplan, activity, timing) "r_task_repeat"; } "r_cp_acti_outcome"; } diff --git a/android/engine/src/test/resources/plans/household-routine-visit/structure-map.txt b/android/engine/src/test/resources/plans/household-routine-visit/structure-map.txt index 2af35dee317..82708d89b97 100644 --- a/android/engine/src/test/resources/plans/household-routine-visit/structure-map.txt +++ b/android/engine/src/test/resources/plans/household-routine-visit/structure-map.txt @@ -84,7 +84,9 @@ group ExtractTimingCode(source subject : Group, target concept: CodeableConcept) group ExtractPeriod_1m(source offset : DateType, target period: Period){ offset -> offset as start, - evaluate(offset, $this + 1 'month') as end then + evaluate(offset, "1 month".toQuantity()) as duration1month, + duration1month.code = 'month', + evaluate(offset, $this + duration1month) as end then ExtractPeriod(start, end, period) "r_period"; } diff --git a/android/geowidget/build.gradle.kts b/android/geowidget/build.gradle.kts index 0ad95063d1f..442ee826130 100644 --- a/android/geowidget/build.gradle.kts +++ b/android/geowidget/build.gradle.kts @@ -31,7 +31,11 @@ android { } buildTypes { - getByName("debug") { enableUnitTestCoverage = true } + getByName("debug") { + enableUnitTestCoverage = BuildConfigs.enableUnitTestCoverage + enableAndroidTestCoverage = BuildConfigs.enableAndroidTestCoverage + } + create("debugNonProxy") { initWith(getByName("debug")) } getByName("release") { diff --git a/android/geowidget/src/main/AndroidManifest.xml b/android/geowidget/src/main/AndroidManifest.xml index 738a9194e0b..e721f61bbe3 100644 --- a/android/geowidget/src/main/AndroidManifest.xml +++ b/android/geowidget/src/main/AndroidManifest.xml @@ -1,8 +1,10 @@ - + diff --git a/android/geowidget/src/main/assets/conversion_config.json b/android/geowidget/src/main/assets/conversion_config.json deleted file mode 100644 index 8b16ae0668c..00000000000 --- a/android/geowidget/src/main/assets/conversion_config.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "Group": { - "name": "Group.name", - "family-id": "Group.id" - }, - "Location": { - } -} \ No newline at end of file diff --git a/android/geowidget/src/main/java/org/smartregister/fhircore/geowidget/model/GeoJsonFeature.kt b/android/geowidget/src/main/java/org/smartregister/fhircore/geowidget/model/GeoJsonFeature.kt new file mode 100644 index 00000000000..90d1ab21c52 --- /dev/null +++ b/android/geowidget/src/main/java/org/smartregister/fhircore/geowidget/model/GeoJsonFeature.kt @@ -0,0 +1,49 @@ +/* + * Copyright 2021-2024 Ona Systems, Inc + * + * 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 org.smartregister.fhircore.geowidget.model + +import com.mapbox.geojson.Feature +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonPrimitive +import org.smartregister.fhircore.engine.util.extension.encodeJson + +const val TYPE = "type" +const val POINT = "Point" +const val FEATURE = "Feature" + +@Serializable +data class GeoJsonFeature( + val geometry: Geometry? = null, + val id: String = "", + val properties: Map = emptyMap(), + val type: String = FEATURE, + val serverVersion: Int = 0, +) : java.io.Serializable { + fun toFeature(): Feature = Feature.fromJson(this.encodeJson()) +} + +/** + * A representation f a Point in a Map. Please note that these coordinates use longitude, latitude + * coordinate order (as opposed to latitude, longitude) to match the GeoJSON specification, which is + * equivalent to the OGC:CRS84 coordinate reference system. Refer to + * [MapBox Geography and geometry documentation](https://docs.mapbox.com/mapbox-gl-js/api/geography/) + */ +@Serializable +data class Geometry( + val coordinates: List? = emptyList(), + val type: String = POINT, +) : java.io.Serializable diff --git a/android/geowidget/src/main/java/org/smartregister/fhircore/geowidget/model/ServicePointType.kt b/android/geowidget/src/main/java/org/smartregister/fhircore/geowidget/model/ServicePointType.kt index 52ce4678f18..08c2a6f31cc 100644 --- a/android/geowidget/src/main/java/org/smartregister/fhircore/geowidget/model/ServicePointType.kt +++ b/android/geowidget/src/main/java/org/smartregister/fhircore/geowidget/model/ServicePointType.kt @@ -47,4 +47,9 @@ enum class ServicePointType( BSD(R.drawable.ic_gov, "bsd"), MEN(R.drawable.ic_men_service_point, "men"), DREN(R.drawable.ic_epp_service_point, "dren"), + DISTRICT_PPSPF(R.drawable.ic_gov, "District PPSPF"), + MAIRIE(R.drawable.ic_csb_service_point, "Mairie"), + ECOLE_COMMUNAUTAIRE(R.drawable.ic_epp_service_point, "Ecole Communautaire"), + ECOLE_PRIVÉ(R.drawable.ic_epp_service_point, "Ecole Privé"), + LYCÉE(R.drawable.ic_epp_service_point, "Lycée"), } diff --git a/android/geowidget/src/main/java/org/smartregister/fhircore/geowidget/model/Feature.kt b/android/geowidget/src/main/java/org/smartregister/fhircore/geowidget/screens/GeoJsonDataRequester.kt similarity index 54% rename from android/geowidget/src/main/java/org/smartregister/fhircore/geowidget/model/Feature.kt rename to android/geowidget/src/main/java/org/smartregister/fhircore/geowidget/screens/GeoJsonDataRequester.kt index 7d7b14c33a9..ce36c098089 100644 --- a/android/geowidget/src/main/java/org/smartregister/fhircore/geowidget/model/Feature.kt +++ b/android/geowidget/src/main/java/org/smartregister/fhircore/geowidget/screens/GeoJsonDataRequester.kt @@ -14,26 +14,10 @@ * limitations under the License. */ -package org.smartregister.fhircore.geowidget.model +package org.smartregister.fhircore.geowidget.screens -data class Feature( - val geometry: Geometry? = null, - val id: String = "", - val properties: Map = emptyMap(), - val serverVersion: Int = 0, - val type: String = FEATURE, -) +import org.smartregister.fhircore.geowidget.model.GeoJsonFeature -data class Geometry( - val coordinates: List? = emptyList(), - val type: String = POINT, -) - -data class Coordinates( - val latitude: Double = 0.0, - val longitude: Double = 0.0, -) - -const val TYPE = "type" -const val POINT = "Point" -const val FEATURE = "Feature" +interface GeoJsonDataRequester { + fun requestData(onReceiveData: (List) -> Unit) +} diff --git a/android/geowidget/src/main/java/org/smartregister/fhircore/geowidget/screens/GeoWidgetFragment.kt b/android/geowidget/src/main/java/org/smartregister/fhircore/geowidget/screens/GeoWidgetFragment.kt index ece02265250..96d69fbf51d 100644 --- a/android/geowidget/src/main/java/org/smartregister/fhircore/geowidget/screens/GeoWidgetFragment.kt +++ b/android/geowidget/src/main/java/org/smartregister/fhircore/geowidget/screens/GeoWidgetFragment.kt @@ -22,13 +22,14 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.LinearLayout +import androidx.core.content.ContextCompat import androidx.core.content.res.ResourcesCompat import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentManager -import androidx.fragment.app.viewModels -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.flowWithLifecycle +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import com.mapbox.geojson.FeatureCollection import com.mapbox.geojson.MultiPoint import com.mapbox.geojson.Point @@ -42,107 +43,105 @@ import com.mapbox.mapboxsdk.style.layers.PropertyFactory import com.mapbox.mapboxsdk.style.layers.SymbolLayer import com.mapbox.mapboxsdk.style.sources.GeoJsonSource import com.mapbox.turf.TurfMeasurement -import dagger.hilt.android.AndroidEntryPoint import io.ona.kujaku.callbacks.AddPointCallback import io.ona.kujaku.plugin.switcher.BaseLayerSwitcherPlugin import io.ona.kujaku.plugin.switcher.layer.StreetsBaseLayer import io.ona.kujaku.utils.CoordinateUtils import io.ona.kujaku.views.KujakuMapView -import java.util.LinkedList import kotlinx.coroutines.launch import org.jetbrains.annotations.VisibleForTesting import org.json.JSONObject import org.smartregister.fhircore.engine.configuration.geowidget.MapLayer import org.smartregister.fhircore.engine.configuration.geowidget.MapLayerConfig +import org.smartregister.fhircore.engine.util.extension.decodeJson import org.smartregister.fhircore.geowidget.BuildConfig import org.smartregister.fhircore.geowidget.R import org.smartregister.fhircore.geowidget.baselayers.MapBoxSatelliteLayer import org.smartregister.fhircore.geowidget.baselayers.StreetSatelliteLayer -import org.smartregister.fhircore.geowidget.model.Coordinates -import org.smartregister.fhircore.geowidget.model.Feature -import org.smartregister.fhircore.geowidget.model.Geometry +import org.smartregister.fhircore.geowidget.model.GeoJsonFeature import org.smartregister.fhircore.geowidget.model.ServicePointType import org.smartregister.fhircore.geowidget.model.TYPE import org.smartregister.fhircore.geowidget.util.ResourceUtils -import org.smartregister.fhircore.geowidget.util.extensions.featureProperties -import org.smartregister.fhircore.geowidget.util.extensions.geometry import timber.log.Timber -@AndroidEntryPoint class GeoWidgetFragment : Fragment() { - private val geoWidgetViewModel by viewModels() - internal var onAddLocationCallback: (Feature) -> Unit = {} + + internal var onAddLocationCallback: (GeoJsonFeature) -> Unit = {} internal var onCancelAddingLocationCallback: () -> Unit = {} - internal var onClickLocationCallback: (Feature, FragmentManager) -> Unit = - { feature: Feature, fragmentManager: FragmentManager -> + internal var onClickLocationCallback: (GeoJsonFeature, FragmentManager) -> Unit = + { _: GeoJsonFeature, _: FragmentManager -> } - internal var useGpsOnAddingLocation: Boolean = false - internal var mapLayers: List = ArrayList() - internal var showCurrentLocationButton: Boolean = true - internal var showPlaneSwitcherButton: Boolean = true - internal var showAddLocationButton: Boolean = true - - private lateinit var mapView: KujakuMapView - private var geoJsonSource: GeoJsonSource? = null - private var featureCollection: FeatureCollection? = null + private var useGpsOnAddingLocation: Boolean = false + private var mapLayers: List = ArrayList() + private var showCurrentLocationButton: Boolean = true + private var showPlaneSwitcherButton: Boolean = true + private var showAddLocationButton: Boolean = true + private var mapView: KujakuMapView? = null + private lateinit var geoWidgetViewModel: GeoWidgetViewModel override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?, ): View { - Mapbox.getInstance(requireContext(), BuildConfig.MAPBOX_SDK_TOKEN) - val view = setupViews() - mapView.onCreate(savedInstanceState) - return view + Mapbox.getInstance(requireActivity(), BuildConfig.MAPBOX_SDK_TOKEN) + mapView = setUpMapView() + return LinearLayout(requireContext()).apply { + orientation = LinearLayout.VERTICAL + addView(mapView) + } } - fun setKujakuMapview(kujakuMapView: KujakuMapView) { - mapView = kujakuMapView + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + mapView?.onCreate(savedInstanceState) + geoWidgetViewModel = ViewModelProvider(this)[GeoWidgetViewModel::class.java] } - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - setLocationCollector() + override fun onStart() { + super.onStart() + mapView?.onStart() } - private fun setLocationCollector() { - viewLifecycleOwner.lifecycleScope.launch { - geoWidgetViewModel.featuresFlow - .flowWithLifecycle(lifecycle, Lifecycle.State.STARTED) - .collect { features -> - val featureCollection = FeatureCollection.fromFeatures(features.toList()) - this@GeoWidgetFragment.featureCollection = featureCollection - if (geoJsonSource != null && featureCollection != null) { - geoJsonSource!!.setGeoJson(featureCollection) - zoomToLocationsOnMap(featureCollection) - } - } - } + override fun onResume() { + super.onResume() + mapView?.onResume() } - private fun setupViews(): LinearLayout { - mapView = setUpMapView() + override fun onPause() { + super.onPause() + mapView?.onPause() + } - return LinearLayout(requireContext()).apply { - orientation = LinearLayout.VERTICAL + override fun onStop() { + super.onStop() + mapView?.onStop() + } - addView(mapView) - } + override fun onDestroy() { + mapView?.onDestroy() + super.onDestroy() + mapView = null } - private fun setUpMapView(): KujakuMapView { + override fun onLowMemory() { + super.onLowMemory() + mapView?.onLowMemory() + } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + mapView?.onSaveInstanceState(outState) + } + + private fun setUpMapView(): KujakuMapView? { return try { - KujakuMapView(requireActivity()).apply { + KujakuMapView(requireContext()).apply { id = R.id.kujaku_widget - val builder = Style.Builder().fromUri(context.getString(R.string.style_map_fhir_core)) + val builder = Style.Builder().fromUri(MAP_STYLE) getMapAsync { mapboxMap -> mapboxMap.setStyle(builder) { style -> - geoJsonSource = style.getSourceAs(context.getString(R.string.data_set_quest)) addIconsLayer(style) addMapStyle(style) - if (geoJsonSource != null && featureCollection != null) { - geoJsonSource!!.setGeoJson(featureCollection) - } } } @@ -151,13 +150,15 @@ class GeoWidgetFragment : Fragment() { } setOnClickLocationListener(this) } - } catch (e: MapboxConfigurationException) { - Timber.e(e) - mapView + } catch (mapboxConfigurationException: MapboxConfigurationException) { + Timber.e(mapboxConfigurationException) + null } } private fun addIconsLayer(mMapboxMapStyle: Style) { + addIconBaseImage(mMapboxMapStyle) + val dynamicIconSize = Expression.interpolate( Expression.linear(), @@ -172,23 +173,28 @@ class GeoWidgetFragment : Fragment() { val icon: Bitmap? = ResourceUtils.drawableToBitmap( ResourcesCompat.getDrawable( - resources, - servicePointType.drawableId, - requireContext().theme, - )!!, + resources, + servicePointType.drawableId, + requireContext().theme, + )!! + .apply { + setTint( + ContextCompat.getColor( + requireContext(), + org.smartregister.fhircore.engine.R.color.white, + ), + ) + }, ) icon?.let { mMapboxMapStyle.addImage(key, icon) - val symbolLayer = - SymbolLayer( - String.format("%s.layer", key), - getString(R.string.data_set_quest), - ) + val symbolLayer = SymbolLayer(String.format("%s.layer", key), DATA_SET) symbolLayer.setProperties( PropertyFactory.iconImage(key), PropertyFactory.iconSize(dynamicIconSize), PropertyFactory.iconIgnorePlacement(false), - PropertyFactory.iconAllowOverlap(false), + PropertyFactory.iconAllowOverlap(true), + PropertyFactory.symbolSortKey(2f), ) symbolLayer.setFilter( Expression.eq( @@ -201,6 +207,39 @@ class GeoWidgetFragment : Fragment() { } } + private fun addIconBaseImage(mMapboxMapStyle: Style) { + val baseIcon: Bitmap? = + ResourceUtils.drawableToBitmap( + ResourcesCompat.getDrawable( + resources, + org.smartregister.fhircore.engine.R.drawable.base_icon, + requireContext().theme, + )!!, + ) + + baseIcon?.let { + val dynamicBaseIconSize = + Expression.interpolate( + Expression.linear(), + Expression.zoom(), + Expression.literal(0.7f), + Expression.literal(0.67f), + ) + + val baseKey = "base-image" + mMapboxMapStyle.addImage(baseKey, it) + val symbolLayer = SymbolLayer(String.format("%s.layer", baseKey), DATA_SET) + symbolLayer.setProperties( + PropertyFactory.iconImage(baseKey), + PropertyFactory.iconSize(dynamicBaseIconSize), + PropertyFactory.iconAllowOverlap(true), + PropertyFactory.symbolSortKey(1f), + PropertyFactory.iconOffset(arrayOf(0f, 8.5f)), + ) + mMapboxMapStyle.addLayerBelow(symbolLayer, DATA_POINTS) + } + } + private fun KujakuMapView.addMapStyle(style: Style) { val baseLayerSwitcherPlugin = BaseLayerSwitcherPlugin(this, style) @@ -208,7 +247,11 @@ class GeoWidgetFragment : Fragment() { mapLayers.forEach { when (it.layer) { MapLayer.STREET -> addBaseLayer(MapBoxSatelliteLayer(), it.active) - MapLayer.SATELLITE -> addBaseLayer(StreetsBaseLayer(requireContext()), it.active) + MapLayer.SATELLITE -> + addBaseLayer( + StreetsBaseLayer(requireContext()), + it.active, + ) MapLayer.STREET_SATELLITE -> addBaseLayer(StreetSatelliteLayer(requireContext()), it.active) } @@ -231,25 +274,7 @@ class GeoWidgetFragment : Fragment() { Timber.w("Only feature geometry of type Point is supported!") return@setOnFeatureClickListener } - - val point = (mapBoxFeature.geometry() as Point) - val feature = - Feature( - id = mapBoxFeature.id() ?: "", - geometry = - Geometry( - coordinates = - listOf( - Coordinates( - latitude = point.latitude(), - longitude = point.longitude(), - ), - ), - ), - properties = mapBoxFeature.properties()?.asMap()?.featureProperties()!!, - ) - - onClickLocationCallback(feature, parentFragmentManager) + onClickLocationCallback(mapBoxFeature.toJson().decodeJson(), parentFragmentManager) }, "quest-data-points", ) @@ -263,9 +288,7 @@ class GeoWidgetFragment : Fragment() { override fun onPointAdd(featureJSONObject: JSONObject?) { featureJSONObject ?: return - val position = featureJSONObject.geometry() ?: return - val feature = Feature(geometry = position) - onAddLocationCallback(feature) + onAddLocationCallback(featureJSONObject.toString().decodeJson()) } override fun onCancel() { @@ -275,85 +298,32 @@ class GeoWidgetFragment : Fragment() { ) } - private fun zoomToLocationsOnMap(featureCollection: FeatureCollection?) { - featureCollection ?: return - - val locationPoint = LinkedList() - featureCollection.features()?.forEach { feature -> - val geometry = feature.geometry() - if (geometry is Point) { - locationPoint.add(geometry) + private fun zoomMapWithFeatures() { + mapView?.getMapAsync { mapboxMap -> + val features = geoWidgetViewModel.mapFeatures.toList() + if (features.isNotEmpty()) { + val featureCollection = FeatureCollection.fromFeatures(features) + val locationPoints = + featureCollection + .features() + ?.asSequence() + ?.filter { it.geometry() is Point } + ?.map { it.geometry() as Point } + ?.toMutableList() ?: emptyList() + mapboxMap.getStyle { style -> + style.getSourceAs(DATA_SET)?.setGeoJson(featureCollection) + } + val bbox = TurfMeasurement.bbox(MultiPoint.fromLngLats(locationPoints)) + val paddedBbox = CoordinateUtils.getPaddedBbox(bbox, PADDING_IN_METRES) + val bounds = LatLngBounds.from(paddedBbox[3], paddedBbox[2], paddedBbox[1], paddedBbox[0]) + val finalCameraPosition = + CameraUpdateFactory.newLatLngBounds(bounds, CAMERA_POSITION_PADDING) + mapboxMap.easeCamera(finalCameraPosition) } } - - if ((featureCollection.features()?.size ?: 0) == 0) return - - val bbox = TurfMeasurement.bbox(MultiPoint.fromLngLats(locationPoint)) - val paddedBbox = CoordinateUtils.getPaddedBbox(bbox, 1000.0) - val bounds = LatLngBounds.from(paddedBbox[3], paddedBbox[2], paddedBbox[1], paddedBbox[0]) - val finalCameraPosition = CameraUpdateFactory.newLatLngBounds(bounds, 50) - - mapView.getMapAsync { mapboxMap -> mapboxMap.easeCamera(finalCameraPosition) } } - override fun onStart() { - super.onStart() - mapView.onStart() - } - - override fun onResume() { - super.onResume() - mapView.onResume() - } - - override fun onPause() { - super.onPause() - mapView.onPause() - } - - override fun onStop() { - super.onStop() - mapView.onStop() - } - - override fun onDestroy() { - super.onDestroy() - mapView.onDestroy() - } - - override fun onLowMemory() { - super.onLowMemory() - mapView.onLowMemory() - } - - override fun onSaveInstanceState(outState: Bundle) { - super.onSaveInstanceState(outState) - mapView.onSaveInstanceState(outState) - } - - fun addLocationsToMap(locations: Set) { - geoWidgetViewModel.addLocationsToMap(locations) - } - - companion object { - fun builder() = Builder() - } -} - -class Builder { - - private var onAddLocationCallback: (Feature) -> Unit = {} - private var onCancelAddingLocationCallback: () -> Unit = {} - private var onClickLocationCallback: (Feature, FragmentManager) -> Unit = - { feature: Feature, fragmentManager: FragmentManager -> - } - private var useGpsOnAddingLocation: Boolean = false - private var mapLayers: List = ArrayList() - private var showCurrentLocationButton: Boolean = true - private var showPlaneSwitcherButton: Boolean = true - private var showAddLocationButton: Boolean = true - - fun setOnAddLocationListener(onAddLocationCallback: (Feature) -> Unit) = apply { + fun setOnAddLocationListener(onAddLocationCallback: (GeoJsonFeature) -> Unit) = apply { this.onAddLocationCallback = onAddLocationCallback } @@ -361,10 +331,9 @@ class Builder { this.onCancelAddingLocationCallback = onCancelAddingLocationCallback } - fun setOnClickLocationListener(onClickLocationCallback: (Feature, FragmentManager) -> Unit) = - apply { - this.onClickLocationCallback = onClickLocationCallback - } + fun setOnClickLocationListener( + onClickLocationCallback: (GeoJsonFeature, FragmentManager) -> Unit, + ) = apply { this.onClickLocationCallback = onClickLocationCallback } fun setUseGpsOnAddingLocation(value: Boolean) = apply { this.useGpsOnAddingLocation = value } @@ -380,16 +349,41 @@ class Builder { this.showPlaneSwitcherButton = show } - fun build(): GeoWidgetFragment { - return GeoWidgetFragment().apply { - this.onAddLocationCallback = this@Builder.onAddLocationCallback - this.onCancelAddingLocationCallback = this@Builder.onCancelAddingLocationCallback - this.onClickLocationCallback = this@Builder.onClickLocationCallback - this.useGpsOnAddingLocation = this@Builder.useGpsOnAddingLocation - this.mapLayers = this@Builder.mapLayers - this.showCurrentLocationButton = this@Builder.showCurrentLocationButton - this.showPlaneSwitcherButton = this@Builder.showPlaneSwitcherButton - this.showAddLocationButton = this@Builder.showAddLocationButton + fun observerGeoJsonFeatures(mutableLiveData: MutableLiveData>) { + with(viewLifecycleOwner) { + lifecycleScope.launch { + repeatOnLifecycle(androidx.lifecycle.Lifecycle.State.CREATED) { + mutableLiveData.observe(this@with) { geoJsonFeatures -> + if (geoJsonFeatures.isNotEmpty()) { + geoWidgetViewModel.updateMapFeatures(geoJsonFeatures) + zoomMapWithFeatures() + } + } + } + } + } + } + + fun observerMapReset(clearMapLiveData: MutableLiveData) { + with(viewLifecycleOwner) { + lifecycleScope.launch { + repeatOnLifecycle(androidx.lifecycle.Lifecycle.State.CREATED) { + clearMapLiveData.observe(this@with) { reset -> + if (reset) { + geoWidgetViewModel.clearMapFeatures() + } + } + } + } } } + + companion object { + const val MAP_FEATURES_LIMIT = 1000 + const val PADDING_IN_METRES = 1000.0 + const val CAMERA_POSITION_PADDING = 50 + const val MAP_STYLE = "asset://fhircore_style.json" + const val DATA_SET = "quest-data-set" + const val DATA_POINTS = "quest-data-points" + } } diff --git a/android/geowidget/src/main/java/org/smartregister/fhircore/geowidget/screens/GeoWidgetViewModel.kt b/android/geowidget/src/main/java/org/smartregister/fhircore/geowidget/screens/GeoWidgetViewModel.kt index d4e8517ed92..87520d5630e 100644 --- a/android/geowidget/src/main/java/org/smartregister/fhircore/geowidget/screens/GeoWidgetViewModel.kt +++ b/android/geowidget/src/main/java/org/smartregister/fhircore/geowidget/screens/GeoWidgetViewModel.kt @@ -17,52 +17,22 @@ package org.smartregister.fhircore.geowidget.screens import androidx.lifecycle.ViewModel -import dagger.hilt.android.lifecycle.HiltViewModel -import javax.inject.Inject -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import org.jetbrains.annotations.VisibleForTesting -import org.json.JSONObject -import org.smartregister.fhircore.engine.util.DispatcherProvider -import org.smartregister.fhircore.geowidget.model.Feature +import com.mapbox.geojson.Feature +import org.smartregister.fhircore.geowidget.model.GeoJsonFeature import org.smartregister.fhircore.geowidget.model.ServicePointType -import org.smartregister.fhircore.geowidget.util.extensions.getGeoJsonGeometry -import org.smartregister.fhircore.geowidget.util.extensions.getProperties -import timber.log.Timber +import org.smartregister.fhircore.geowidget.screens.GeoWidgetFragment.Companion.MAP_FEATURES_LIMIT -@HiltViewModel -class GeoWidgetViewModel @Inject constructor(val dispatcherProvider: DispatcherProvider) : - ViewModel() { +class GeoWidgetViewModel : ViewModel() { - private val _featuresFlow: MutableStateFlow> = - MutableStateFlow(setOf()) - val featuresFlow: StateFlow> = _featuresFlow + val mapFeatures = ArrayDeque() - @VisibleForTesting - fun addLocationToMap(feature: Feature) { - try { - val jsonFeature = - JSONObject().apply { - put("id", feature.id) - put("type", feature.type) - put("properties", feature.getProperties()) - put("geometry", feature.getGeoJsonGeometry()) - put("serverVersion", feature.serverVersion) - } - val mapBoxfeature = com.mapbox.geojson.Feature.fromJson(jsonFeature.toString()) - _featuresFlow.value += mapBoxfeature - } catch (e: Exception) { - Timber.e(e) + fun updateMapFeatures(geoJsonFeatures: List) { + if (mapFeatures.size <= MAP_FEATURES_LIMIT) { + mapFeatures.addAll(geoJsonFeatures.map { it.toFeature() }) } } - fun addLocationsToMap(locations: Set) { - locations.forEach { location -> addLocationToMap(location) } - } - - fun clearLocations() { - _featuresFlow.value = setOf() - } + fun clearMapFeatures() = mapFeatures.clear() fun getServicePointKeyToType(): Map { val map: MutableMap = HashMap() @@ -90,6 +60,12 @@ class GeoWidgetViewModel @Inject constructor(val dispatcherProvider: DispatcherP map[ServicePointType.BSD.name.lowercase()] = ServicePointType.BSD map[ServicePointType.MEN.name.lowercase()] = ServicePointType.MEN map[ServicePointType.DREN.name.lowercase()] = ServicePointType.DREN + map[ServicePointType.DISTRICT_PPSPF.name.lowercase()] = ServicePointType.DISTRICT_PPSPF + map[ServicePointType.MAIRIE.name.lowercase()] = ServicePointType.MAIRIE + map[ServicePointType.ECOLE_COMMUNAUTAIRE.name.lowercase()] = + ServicePointType.ECOLE_COMMUNAUTAIRE + map[ServicePointType.ECOLE_PRIVÉ.name.lowercase()] = ServicePointType.ECOLE_PRIVÉ + map[ServicePointType.LYCÉE.name.lowercase()] = ServicePointType.LYCÉE return map } } diff --git a/android/geowidget/src/main/java/org/smartregister/fhircore/geowidget/util/extensions/GeoWidgetExtensions.kt b/android/geowidget/src/main/java/org/smartregister/fhircore/geowidget/util/extensions/GeoWidgetExtensions.kt deleted file mode 100644 index 5f3f0e836ed..00000000000 --- a/android/geowidget/src/main/java/org/smartregister/fhircore/geowidget/util/extensions/GeoWidgetExtensions.kt +++ /dev/null @@ -1,77 +0,0 @@ -/* - * Copyright 2021-2024 Ona Systems, Inc - * - * 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 org.smartregister.fhircore.geowidget.util.extensions - -import com.google.gson.JsonElement -import org.json.JSONArray -import org.json.JSONObject -import org.smartregister.fhircore.geowidget.model.Coordinates -import org.smartregister.fhircore.geowidget.model.Feature -import org.smartregister.fhircore.geowidget.model.Geometry - -fun Feature.getGeoJsonGeometry(): JSONObject { - geometry ?: return JSONObject() - - val geometryObject = JSONObject() - - /* TODO: Currently Geometry only supports type @Point. Point Geometry has coordinates with a JSONArray - having only 2 double values whereas any other Type has a different structure having JSONArray - with an Array of elements. See below examples - Point: {"geometry": { "type": "Point", "coordinates": [ 45.487, -25.208]}} - LineString: {"geometry": {"type": "LineString", "coordinates": [ [717, 1246.3812 ], [703.1146]]}} - */ - geometryObject.put("type", geometry.type) - geometryObject.put( - "coordinates", - JSONArray( - arrayOf( - geometry.coordinates?.get(0)?.longitude, - geometry.coordinates?.get(0)?.latitude, - ), - ), - ) - return geometryObject -} - -fun JSONObject.geometry(): Geometry? { - return optJSONObject("geometry")?.run { - optJSONArray("coordinates")?.run { - Geometry( - listOf( - Coordinates( - optDouble(0), - optDouble(1), - ), - ), - ) - } - } -} - -fun Map.featureProperties(): Map { - val properties = hashMapOf() - forEach { (key, value) -> properties[key] = value.asString } - return properties -} - -fun Feature.getProperties(): JSONObject { - properties ?: return JSONObject() - - val propertiesObject = JSONObject() - properties.forEach { key, value -> propertiesObject.put(key, value) } - return propertiesObject -} diff --git a/android/geowidget/src/main/res/values/ids.xml b/android/geowidget/src/main/res/values/ids.xml index 420f4fae43f..1bc2412530b 100644 --- a/android/geowidget/src/main/res/values/ids.xml +++ b/android/geowidget/src/main/res/values/ids.xml @@ -1,4 +1,5 @@ + diff --git a/android/geowidget/src/main/res/values/strings.xml b/android/geowidget/src/main/res/values/strings.xml deleted file mode 100644 index b32a728eb84..00000000000 --- a/android/geowidget/src/main/res/values/strings.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - asset://fhircore_style.json - quest-data-set - diff --git a/android/geowidget/src/test/java/org/smartregister/fhircore/geowidget/rule/CoroutineTestRule.kt b/android/geowidget/src/test/java/org/smartregister/fhircore/geowidget/rule/CoroutineTestRule.kt index 2240510d7ac..9c550cf424d 100644 --- a/android/geowidget/src/test/java/org/smartregister/fhircore/geowidget/rule/CoroutineTestRule.kt +++ b/android/geowidget/src/test/java/org/smartregister/fhircore/geowidget/rule/CoroutineTestRule.kt @@ -14,42 +14,24 @@ * limitations under the License. */ -package org.smartregister.fhircore.geowidget.rule - import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.TestCoroutineDispatcher -import kotlinx.coroutines.test.TestCoroutineScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.setMain -import org.junit.rules.TestRule +import org.junit.rules.TestWatcher import org.junit.runner.Description -import org.junit.runners.model.Statement -import org.smartregister.fhircore.engine.util.DispatcherProvider - -@ExperimentalCoroutinesApi -class CoroutineTestRule(val testDispatcher: TestCoroutineDispatcher = TestCoroutineDispatcher()) : - TestRule, TestCoroutineScope by TestCoroutineScope(testDispatcher) { - val testDispatcherProvider = - object : DispatcherProvider { - override fun default() = testDispatcher +@OptIn(ExperimentalCoroutinesApi::class) val testDispatcher = UnconfinedTestDispatcher() - override fun io() = testDispatcher - - override fun main() = testDispatcher +@ExperimentalCoroutinesApi +class CoroutineTestRule : TestWatcher() { - override fun unconfined() = testDispatcher - } + override fun starting(description: Description) { + Dispatchers.setMain(testDispatcher) + } - override fun apply(base: Statement?, description: Description?) = - object : Statement() { - @Throws(Throwable::class) - override fun evaluate() { - Dispatchers.setMain(testDispatcher) - base?.evaluate() - Dispatchers.resetMain() - testDispatcher.cleanupTestCoroutines() - } - } + override fun finished(description: Description) { + Dispatchers.resetMain() + } } diff --git a/android/geowidget/src/test/java/org/smartregister/fhircore/geowidget/screens/GeoWidgetFragmentTest.kt b/android/geowidget/src/test/java/org/smartregister/fhircore/geowidget/screens/GeoWidgetFragmentTest.kt index 7de44157287..dc91e045f09 100644 --- a/android/geowidget/src/test/java/org/smartregister/fhircore/geowidget/screens/GeoWidgetFragmentTest.kt +++ b/android/geowidget/src/test/java/org/smartregister/fhircore/geowidget/screens/GeoWidgetFragmentTest.kt @@ -17,7 +17,6 @@ package org.smartregister.fhircore.geowidget.screens import android.os.Build -import android.os.Bundle import com.mapbox.geojson.FeatureCollection import com.mapbox.mapboxsdk.style.sources.GeoJsonSource import dagger.hilt.android.testing.HiltAndroidRule @@ -36,6 +35,7 @@ import org.junit.runner.RunWith import org.robolectric.Robolectric import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config +import org.smartregister.fhircore.engine.util.test.HiltActivityForTest import org.smartregister.fhircore.geowidget.shadows.ShadowConnectivityReceiver import org.smartregister.fhircore.geowidget.shadows.ShadowKujakuMapView import org.smartregister.fhircore.geowidget.shadows.ShadowMapbox @@ -59,7 +59,7 @@ class GeoWidgetFragmentTest { fun setup() { hiltRule.inject() - Robolectric.buildActivity(GeoWidgetTestActivity::class.java).create().resume().get() + Robolectric.buildActivity(HiltActivityForTest::class.java).create().resume().get() geowidgetFragment = GeoWidgetFragment() @@ -90,28 +90,4 @@ class GeoWidgetFragmentTest { // Verify mocks verify { kujakuMapView.addPoint(any(), any()) } } - - @Test - fun testOnCreateViewAddsSavedStateToMapView() { - val activity = Robolectric.buildActivity(GeoWidgetTestActivity::class.java).create().get() - - val geowidgetFragment = GeoWidgetFragment() - - var kujakuMapView = mockk(relaxed = true) - - geowidgetFragment.setKujakuMapview(kujakuMapView) - - activity.supportFragmentManager - .beginTransaction() - .add(android.R.id.content, geowidgetFragment, "") - .commitNow() - - every { kujakuMapView.parent } returns null - - val savedInstanceBundle: Bundle = mockk() - - geowidgetFragment.onCreateView(activity.layoutInflater, null, savedInstanceBundle) - - verify { kujakuMapView.onCreate(savedInstanceBundle) } - } } diff --git a/android/geowidget/src/test/java/org/smartregister/fhircore/geowidget/screens/GeoWidgetViewModelTest.kt b/android/geowidget/src/test/java/org/smartregister/fhircore/geowidget/screens/GeoWidgetViewModelTest.kt index c3e827ca4a3..7e7be3064d0 100644 --- a/android/geowidget/src/test/java/org/smartregister/fhircore/geowidget/screens/GeoWidgetViewModelTest.kt +++ b/android/geowidget/src/test/java/org/smartregister/fhircore/geowidget/screens/GeoWidgetViewModelTest.kt @@ -18,41 +18,28 @@ package org.smartregister.fhircore.geowidget.screens import android.os.Build import androidx.arch.core.executor.testing.InstantTaskExecutorRule -import androidx.test.core.app.ApplicationProvider import ca.uhn.fhir.parser.IParser -import com.google.android.fhir.FhirEngine import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidTest import dagger.hilt.android.testing.HiltTestApplication -import io.mockk.coEvery import io.mockk.mockk -import io.mockk.spyk import java.util.UUID import javax.inject.Inject -import junit.framework.TestCase.assertEquals -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.runBlocking import org.junit.Assert import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith -import org.mockito.Mock import org.mockito.MockitoAnnotations import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config import org.smartregister.fhircore.engine.configuration.ConfigurationRegistry import org.smartregister.fhircore.engine.configuration.app.ConfigService -import org.smartregister.fhircore.engine.data.local.DefaultRepository -import org.smartregister.fhircore.engine.rulesengine.ConfigRulesExecutor -import org.smartregister.fhircore.engine.util.DispatcherProvider import org.smartregister.fhircore.engine.util.SharedPreferencesHelper import org.smartregister.fhircore.engine.util.fhirpath.FhirPathDataExtractor -import org.smartregister.fhircore.geowidget.model.Coordinates -import org.smartregister.fhircore.geowidget.model.Feature +import org.smartregister.fhircore.geowidget.model.GeoJsonFeature import org.smartregister.fhircore.geowidget.model.Geometry import org.smartregister.fhircore.geowidget.model.ServicePointType -import org.smartregister.fhircore.geowidget.rule.CoroutineTestRule @RunWith(RobolectricTestRunner::class) @Config(sdk = [Build.VERSION_CODES.O_MR1], application = HiltTestApplication::class) @@ -63,119 +50,31 @@ class GeoWidgetViewModelTest { @get:Rule(order = 1) var instantTaskExecutorRule = InstantTaskExecutorRule() - @get:Rule(order = 2) var coroutinesTestRule = CoroutineTestRule() - @Inject lateinit var configService: ConfigService - private lateinit var configurationRegistry: ConfigurationRegistry - - private lateinit var sharedPreferencesHelper: SharedPreferencesHelper - - private lateinit var geoWidgetViewModel: GeoWidgetViewModel - - private lateinit var defaultRepository: DefaultRepository - - private val fhirEngine = mockk() - - private val configRulesExecutor: ConfigRulesExecutor = mockk() - @Inject lateinit var fhirPathDataExtractor: FhirPathDataExtractor @Inject lateinit var parser: IParser - private lateinit var viewModel: GeoWidgetViewModel - @Mock private lateinit var dispatcherProvider: DispatcherProvider + private lateinit var configurationRegistry: ConfigurationRegistry + private lateinit var sharedPreferencesHelper: SharedPreferencesHelper + private lateinit var geoWidgetViewModel: GeoWidgetViewModel @Before fun setUp() { MockitoAnnotations.initMocks(this) - viewModel = GeoWidgetViewModel(dispatcherProvider) + geoWidgetViewModel = GeoWidgetViewModel() hiltRule.inject() sharedPreferencesHelper = mockk() configurationRegistry = mockk() - defaultRepository = - spyk( - DefaultRepository( - fhirEngine = fhirEngine, - dispatcherProvider = coroutinesTestRule.testDispatcherProvider, - sharedPreferencesHelper = sharedPreferencesHelper, - configurationRegistry = configurationRegistry, - configService = configService, - configRulesExecutor = configRulesExecutor, - fhirPathDataExtractor = fhirPathDataExtractor, - parser = parser, - context = ApplicationProvider.getApplicationContext(), - ), - ) - geoWidgetViewModel = spyk(GeoWidgetViewModel(coroutinesTestRule.testDispatcherProvider)) - - coEvery { defaultRepository.create(any()) } returns emptyList() } - @Test - fun `test adding locations to map`() { - // Given - val feature1 = - Feature( - geometry = Geometry(coordinates = listOf(Coordinates(0.0, 0.0))), - id = "id1", - type = "type1", - serverVersion = 1, - ) - val feature2 = - Feature( - geometry = Geometry(coordinates = listOf(Coordinates(0.0, 0.0))), - id = "id2", - type = "type2", - serverVersion = 2, - ) - val features = setOf(feature1, feature2) - - // When - viewModel.addLocationsToMap(features) - - // Then - val result = runBlocking { viewModel.featuresFlow.first() } - Assert.assertEquals(2, result.size) - } - - @Test - fun `test clearing locations`() { - // Given - val feature1 = - Feature( - geometry = Geometry(coordinates = listOf(Coordinates(0.0, 0.0))), - id = "id1", - type = "type1", - serverVersion = 1, - ) - val feature2 = - Feature( - geometry = Geometry(coordinates = listOf(Coordinates(0.0, 0.0))), - id = "id2", - type = "type2", - serverVersion = 2, - ) - val features = setOf(feature1, feature2) - viewModel.addLocationsToMap(features) - - // When - viewModel.clearLocations() - - // Then - val result = runBlocking { viewModel.featuresFlow.first() } - Assert.assertEquals(0, result.size) - } - - fun `test mapping service point keys to types`() { - // Given + fun testMappingServicePointKeysToTypes() { val expectedMap = mutableMapOf() - ServicePointType.values().forEach { expectedMap[it.name.lowercase()] = it } + ServicePointType.entries.forEach { expectedMap[it.name.lowercase()] = it } - // When - val result = viewModel.getServicePointKeyToType() + val result = geoWidgetViewModel.getServicePointKeyToType() - // Then Assert.assertEquals(expectedMap.size, result.size) expectedMap.forEach { (key, expectedValue) -> val actualValue = result[key] @@ -184,40 +83,37 @@ class GeoWidgetViewModelTest { } @Test - fun `add location to map`() { + fun testAddGeoJsonFeaturesToLiveData() { val serverVersion = (1..10).random() - val locations = - setOf( - Feature( + val geoJsonFeatures = + listOf( + GeoJsonFeature( id = UUID.randomUUID().toString(), geometry = Geometry( - coordinates = listOf(Coordinates(34.76, 68.23)), + coordinates = listOf(34.76, 68.23), ), properties = mapOf(), serverVersion = serverVersion, ), - Feature( + GeoJsonFeature( id = UUID.randomUUID().toString(), geometry = Geometry( - coordinates = listOf(Coordinates(34.76, 68.23)), + coordinates = listOf(34.76, 68.23), ), properties = mapOf(), serverVersion = serverVersion, ), ) - geoWidgetViewModel.addLocationsToMap(locations) + geoWidgetViewModel.updateMapFeatures(geoJsonFeatures) - Assert.assertEquals(geoWidgetViewModel.featuresFlow.value.size, locations.size) + Assert.assertEquals(geoWidgetViewModel.mapFeatures.size, geoJsonFeatures.size) } @Test - fun `should return a map of ServicePointType enum values based on their lowercase names`() { - // When + fun testThatMapOfServicePointTypeReturnsEnumValuesBasedOnTheirLowercaseNames() { val result = geoWidgetViewModel.getServicePointKeyToType() - - // Then val expectedMap = mapOf( "epp" to ServicePointType.EPP, @@ -244,6 +140,11 @@ class GeoWidgetViewModelTest { "bsd" to ServicePointType.BSD, "men" to ServicePointType.MEN, "dren" to ServicePointType.DREN, + "district_ppspf" to ServicePointType.DISTRICT_PPSPF, + "mairie" to ServicePointType.MAIRIE, + "ecole_communautaire" to ServicePointType.ECOLE_COMMUNAUTAIRE, + "ecole_privé" to ServicePointType.ECOLE_PRIVÉ, + "lycée" to ServicePointType.LYCÉE, ) Assert.assertEquals(expectedMap, result) } diff --git a/android/geowidget/src/test/java/org/smartregister/fhircore/geowidget/util/extensions/GeoWidgetExtensionsTest.kt b/android/geowidget/src/test/java/org/smartregister/fhircore/geowidget/util/extensions/GeoWidgetExtensionsTest.kt deleted file mode 100644 index 7bde8def862..00000000000 --- a/android/geowidget/src/test/java/org/smartregister/fhircore/geowidget/util/extensions/GeoWidgetExtensionsTest.kt +++ /dev/null @@ -1,84 +0,0 @@ -/* - * Copyright 2021-2024 Ona Systems, Inc - * - * 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 org.smartregister.fhircore.geowidget.util.extensions - -import android.os.Build -import com.google.gson.JsonElement -import com.google.gson.JsonPrimitive -import org.json.JSONArray -import org.json.JSONObject -import org.junit.Assert -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config - -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [Build.VERSION_CODES.O_MR1]) -class GeoWidgetExtensionsTest { - @Test - fun `returns geometry object when input JSONObject has non-null geometry and coordinates fields`() { - // Given - val jsonObject = JSONObject() - val geometryObject = JSONObject() - val coordinatesArray = JSONArray() - coordinatesArray.put(1.0) - coordinatesArray.put(2.0) - geometryObject.put("coordinates", coordinatesArray) - jsonObject.put("geometry", geometryObject) - - // When - val result = jsonObject.geometry() - - // Then - Assert.assertNotNull(result) - Assert.assertEquals(1, result?.coordinates?.size) - Assert.assertEquals(1.0, result?.coordinates?.get(0)?.latitude) - Assert.assertEquals(2.0, result?.coordinates?.get(0)?.longitude) - } - - // Should return an empty map when input map is empty - @Test - fun `should return empty map when input map is empty`() { - // Given - val inputMap = emptyMap() - - // When - val result = inputMap.featureProperties() - - // Then - Assert.assertTrue(result.isEmpty()) - } - - // Should return a map with boolean values converted to strings when input map has boolean values - @Test - fun `should return map with boolean values converted to strings when input map has boolean values`() { - // Given - val inputMap = - mapOf( - "key1" to JsonPrimitive(true), - "key2" to JsonPrimitive(false), - ) - - // When - val result = inputMap.featureProperties() - - // Then - Assert.assertEquals("true", result["key1"]) - Assert.assertEquals("false", result["key2"]) - } -} diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml index fcdb8a2e255..73f98b8d693 100644 --- a/android/gradle/libs.versions.toml +++ b/android/gradle/libs.versions.toml @@ -1,143 +1,146 @@ [versions] +accompanist = "0.23.1" activity-compose = "1.8.2" androidJunit5 = "1.8.2.1" -appcompat = "1.6.1" -benchmark-junit = "1.2.3" +androidx-camera = "1.4.0" +androidx-paging = "3.3.2" +androidx-test= "1.6.2" +appcompat = "1.7.0" +benchmark-junit = "1.3.3" cardview = "1.0.0" -commonsJexl3 = "3.2.1" +common-utils = "1.0.0-SNAPSHOT" +compose-ui = "1.6.8" compressor = "3.0.1" constraintlayout = "2.1.4" constraintlayout-compose = "1.0.1" -contrib-barcode = "0.1.0-beta3-preview7-SNAPSHOT" -contrib-locationwidget = "0.1.0-alpha01-preview2-SNAPSHOT" converter-gson = "2.9.0" -core-ktx = "1.12.0" -android-x-test= "1.5.2" +core-ktx = "1.13.1" core-testing = "2.2.0" -coverallsGradlePlugin = "2.12.0" +coverallsGradlePlugin = "2.12.2" cqfFhirCr = "3.0.0-PRE9" -data-capture = "1.1.0-preview10-SNAPSHOT" -datastore = "1.0.0" -desugar-jdk-libs = "2.0.4" -dokkaBase = "1.8.20" -easy-rules-jexl = "4.1.1-SNAPSHOT" -espresso-core = "3.5.1" -fhir-common-utils = "1.0.0-SNAPSHOT" -fhir-engine = "1.0.0-preview9-SNAPSHOT" -foundation = "1.6.3" -fragment-ktx = "1.6.2" +dagger-hilt = "2.51" +datastore = "1.1.1" +desugar-jdk-libs = "2.1.3" +dokkaBase = "1.9.20" +easyRulesCore = "4.1.1-SNAPSHOT" +espresso-core = "3.6.1" +fhir-sdk-contrib-barcode = "0.1.0-beta3-preview7-rc1-SNAPSHOT" +fhir-sdk-contrib-locationwidget = "0.1.0-alpha01-preview2-rc1-SNAPSHOT" +fhir-sdk-data-capture = "1.2.0-preview4-SNAPSHOT" +fhir-sdk-engine = "1.0.0-preview16-SNAPSHOT" +fhir-sdk-knowledge = "0.1.0-alpha03-preview5-rc1-SNAPSHOT" +fhir-sdk-workflow = "0.1.0-alpha04-preview10-rc1-SNAPSHOT" +fragment-ktx = "1.8.3" glide = "4.16.0" -gradle = "8.3.1" +gradle = "8.3.2" gson = "2.10.1" hilt = "1.2.0" -jjwt = "0.9.1" +java-jwt = "4.4.0" +jetbrains = "1.9.20" joda-time = "2.10.14" json = "20230618" -jsonPath = "2.8.0" -junit = "1.1.5" -junit-jupiter = "5.9.1" -junit-ktx = "1.1.5" -knowledge = "0.1.0-alpha03-preview5-SNAPSHOT" +jsonPath = "2.9.0" +junit = "1.2.1" +junit-jupiter = "5.10.3" +junit-ktx = "1.2.1" kotlin = "1.9.22" -kotlinx-coroutines = "1.7.3" +kotlin-serialization = "1.8.10" +kotlinx-coroutines = "1.9.0" kotlinx-serialization-json = "1.6.0" -kujaku-library = "0.10.2-SNAPSHOT" +kt3k-coveralls-ver="2.12.0" ktlint = "0.50.0" +kujaku-library = "0.10.8-SNAPSHOT" +kujaku-mapbox-sdk-turf = "7.2.0" leakcanary-android = "2.10" -lifecycle= "2.7.0" -mapbox-sdk-turf = "4.8.0" -material = "1.11.0" -compose-material-icons = "1.6.3" +lifecycle= "2.8.5" +logback-android = "3.0.0" +material = "1.12.0" +mlkit-barcode-scanning = "17.3.0" mockk = "1.13.8" mockk-android = "1.13.8" msg-simple = "1.2" navigation = "2.7.7" okhttp = "4.12.0" -okhttp-logging-interceptor = "4.11.0" -orchestrator = "1.4.2" -p2p-lib = "0.6.9-SNAPSHOT" -androidx-paging = "3.2.0" -playServicesLocation = "19.0.1" +okhttp-logging-interceptor = "4.12.0" +orchestrator = "1.5.1" +owasp = "8.2.1" +p2p-lib = "0.6.11-SNAPSHOT" +playServicesLocation = "21.3.0" +playServicesTasks = "18.2.0" preference-ktx = "1.2.1" prettytime = "5.0.2.Final" retrofit = "2.9.0" retrofit-mock = "2.9.0" retrofit2-kotlinx-serialization-converter = "0.8.0" -robolectric = "4.10.3" +robolectric = "4.13" +rules = "1.6.1" security-crypto = "1.1.0-alpha06" -slf4j-nop = "1.7.36" +slf4j-nop = "2.0.7" spotlessPluginGradle = "6.25.0" stax-api = "1.0-2" timber = "5.0.1" -ui = "1.6.3" -work = "2.9.0" -workflow = "0.1.0-alpha04-preview9-SNAPSHOT" -xercesImpl = "2.12.2" -jetbrains = "1.9.20" -owasp = "8.2.1" -kotlin-serialization = "1.8.10" -dagger-hilt = "2.50" -accompanist = "0.23.1" -jetbrains-kotlin-jvm="1.9.22" -kt3k-coveralls-ver="2.12.0" -playServicesTasks = "18.1.0" -google-playServicesLocation = "21.1.0" uiautomator = "2.3.0" -rules = "1.5.0" +work = "2.9.1" +xercesImpl = "2.12.2" +androidFragmentCompose = "1.8.5" [libraries] accompanist-flowlayout = { group = "com.google.accompanist", name = "accompanist-flowlayout", version.ref = "accompanist" } accompanist-placeholder = { group = "com.google.accompanist", name = "accompanist-placeholder", version.ref = "accompanist" } activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activity-compose" } +androidx-camera-camera2 = { group = "androidx.camera", name = "camera-camera2", version.ref = "androidx-camera"} +androidx-camera-extensions = { group = "androidx.camera", name = "camera-extensions", version.ref = "androidx-camera"} +androidx-camera-lifecycle = { group = "androidx.camera", name = "camera-lifecycle", version.ref = "androidx-camera"} +androidx-camera-mlkit-vision = {group = "androidx.camera", name = "camera-mlkit-vision", version.ref = "androidx-camera"} +androidx-camera-view = {group = "androidx.camera", name = "camera-view", version.ref = "androidx-camera"} activity-ktx = { group = "androidx.activity", name = "activity-ktx", version.ref = "activity-compose" } appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } benchmark-junit = { group = "androidx.benchmark", name = "benchmark-junit4", version.ref = "benchmark-junit" } cardview = { group = "androidx.cardview", name = "cardview", version.ref = "cardview" } -commons-jexl3 = { module = "org.apache.commons:commons-jexl3", version.ref = "commonsJexl3" } +compose-material-icons-core = { group = "androidx.compose.material", name = "material-icons-core", version.ref = "compose-ui" } +compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended", version.ref = "compose-ui" } compressor = { group = "id.zelory", name = "compressor", version.ref = "compressor" } constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "constraintlayout" } constraintlayout-compose = { group = "androidx.constraintlayout", name = "constraintlayout-compose", version.ref = "constraintlayout-compose" } -contrib-barcode = { group = "org.smartregister", name = "contrib-barcode", version.ref = "contrib-barcode" } -contrib-locationwidget = { group = "org.smartregister", name = "contrib-locationwidget", version.ref = "contrib-locationwidget" } +contrib-barcode = { group = "org.smartregister", name = "contrib-barcode", version.ref = "fhir-sdk-contrib-barcode" } +contrib-locationwidget = { group = "org.smartregister", name = "contrib-locationwidget", version.ref = "fhir-sdk-contrib-locationwidget" } converter-gson = { group = "com.squareup.retrofit2", name = "converter-gson", version.ref = "converter-gson" } core-desugar = { group = "com.android.tools", name = "desugar_jdk_libs", version.ref = "desugar-jdk-libs" } core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "core-ktx" } -core-ktx-test = { group = "androidx.test", name = "core-ktx", version.ref = "android-x-test" } core-testing = { group = "androidx.arch.core", name = "core-testing", version.ref = "core-testing" } coveralls-gradle-plugin = { module = "gradle.plugin.org.kt3k.gradle.plugin:coveralls-gradle-plugin", version.ref = "coverallsGradlePlugin" } cqf-fhir-cr = { module = "org.opencds.cqf.fhir:cqf-fhir-cr", version.ref = "cqfFhirCr" } dagger-hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "dagger-hilt" } -dagger-hilt-compiler = { group = "com.google.dagger", name = "hilt-compiler", version.ref = "dagger-hilt" } dagger-hilt-android-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "dagger-hilt" } -dagger-hilt-android-gradle-plugin = { module = "com.google.dagger:hilt-android-gradle-plugin", version.ref = "dagger-hilt" } dagger-hilt-android-testing = { group = "com.google.dagger", name = "hilt-android-testing", version.ref = "dagger-hilt" } -data-capture = { group = "org.smartregister", name = "data-capture", version.ref = "data-capture" } +dagger-hilt-compiler = { group = "com.google.dagger", name = "hilt-compiler", version.ref = "dagger-hilt" } +data-capture = { group = "org.smartregister", name = "data-capture", version.ref = "fhir-sdk-data-capture" } datastore = { group = "androidx.datastore", name = "datastore", version.ref = "datastore" } datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore"} dokka-base = { module = "org.jetbrains.dokka:dokka-base", version.ref = "dokkaBase" } -easy-rules-jexl = { group = "org.smartregister", name = "easy-rules-jexl", version.ref = "easy-rules-jexl" } +easy-rules-jexl = { group = "org.smartregister", name = "easy-rules-jexl", version.ref = "easyRulesCore" } espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espresso-core" } -fhir-common-utils = { group = "org.smartregister", name = "fhir-common-utils", version.ref = "fhir-common-utils" } -fhir-engine = { group = "org.smartregister", name = "engine", version.ref = "fhir-engine" } -foundation = { group = "androidx.compose.foundation", name = "foundation", version.ref = "foundation" } +fhir-common-utils = { group = "org.smartregister", name = "fhir-common-utils", version.ref = "common-utils" } +fhir-engine = { group = "org.smartregister", name = "engine", version.ref = "fhir-sdk-engine" } +foundation = { group = "androidx.compose.foundation", name = "foundation", version.ref = "compose-ui" } fragment-ktx = { group = "androidx.fragment", name = "fragment-ktx", version.ref = "fragment-ktx" } fragment-testing = { group = "androidx.fragment", name = "fragment-testing", version.ref = "fragment-ktx" } glide = { group = "com.github.bumptech.glide", name = "glide", version.ref = "glide" } +gms-play-services-location = { group = "com.google.android.gms", name = "play-services-location", version.ref = "playServicesLocation" } gradle = { module = "com.android.tools.build:gradle", version.ref = "gradle" } gson = { group = "com.google.code.gson", name = "gson", version.ref = "gson" } hilt-compiler = { group = "androidx.hilt", name = "hilt-compiler", version.ref = "hilt" } -hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "hilt" } hilt-work = { group = "androidx.hilt", name = "hilt-work", version.ref = "hilt" } -jjwt = { group = "io.jsonwebtoken", name = "jjwt", version.ref = "jjwt" } +java-jwt = { module = "com.auth0:java-jwt", version.ref = "java-jwt" } joda-time = { group = "joda-time", name = "joda-time", version.ref = "joda-time" } json = { group = "org.json", name = "json", version.ref = "json" } json-path = { module = "com.jayway.jsonpath:json-path", version.ref = "jsonPath" } junit = { group = "androidx.test.ext", name = "junit", version.ref = "junit" } junit-jupiter-api = { group = "org.junit.jupiter", name = "junit-jupiter-api", version.ref = "junit-jupiter" } junit-jupiter-engine = { group = "org.junit.jupiter", name = "junit-jupiter-engine", version.ref = "junit-jupiter" } -junit-vintage-engine = { group = "org.junit.vintage", name = "junit-vintage-engine", version.ref = "junit-jupiter" } junit-ktx = { group = "androidx.test.ext", name = "junit-ktx", version.ref = "junit-ktx" } -knowledge = { group = "org.smartregister", name = "knowledge", version.ref = "knowledge" } +junit-vintage-engine = { group = "org.junit.vintage", name = "junit-vintage-engine", version.ref = "junit-jupiter" } +knowledge = { group = "org.smartregister", name = "knowledge", version.ref = "fhir-sdk-knowledge" } kotlin-gradle-plugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" } kotlin-reflect = { group = "org.jetbrains.kotlin", name = "kotlin-reflect", version.ref = "kotlin" } kotlin-test = { group = "org.jetbrains.kotlin", name = "kotlin-test", version.ref = "kotlin" } @@ -145,24 +148,23 @@ kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx- kotlinx-coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" } kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" } kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinx-serialization-json" } -ktlint-main = { group = "com.pinterest", name = "ktlint", version.ref = "ktlint"} ktlint-cli-ruleset = { group = "com.pinterest.ktlint", name = "ktlint-cli-ruleset-core", version.ref = "ktlint" } +ktlint-main = { group = "com.pinterest", name = "ktlint", version.ref = "ktlint"} ktlint-rule-engine-core = { group = "com.pinterest.ktlint", name = "ktlint-rule-engine-core", version.ref = "ktlint" } kujaku-library = { group = "io.ona.kujaku", name ="library", version.ref = "kujaku-library" } leakcanary-android = { group = "com.squareup.leakcanary", name = "leakcanary-android", version.ref = "leakcanary-android" } lifecycle-livedata-ktx = { group = "androidx.lifecycle", name = "lifecycle-livedata-ktx", version.ref = "lifecycle" } lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" } lifecycle-viewmodel-ktx = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-ktx", version.ref = "lifecycle" } -mapbox-sdk-turf = { group = "com.mapbox.mapboxsdk", name = "mapbox-sdk-turf", version.ref = "mapbox-sdk-turf" } +logback-android = { module = "com.github.tony19:logback-android", version.ref = "logback-android" } +mapbox-sdk-turf = { group = "com.mapbox.mapboxsdk", name = "mapbox-sdk-turf", version.ref = "kujaku-mapbox-sdk-turf" } material = { group = "com.google.android.material", name = "material", version.ref = "material" } -compose-material-icons-core = { group = "androidx.compose.material", name = "material-icons-core", version.ref = "compose-material-icons" } -compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended", version.ref = "compose-material-icons" } +mlkit-barcode-scanning = { group = "com.google.mlkit", name = "barcode-scanning", version.ref = "mlkit-barcode-scanning"} mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" } mockk-android = { group = "io.mockk", name = "mockk-android", version.ref = "mockk-android" } msg-simple = { group = "com.github.java-json-tools", name = "msg-simple", version.ref = "msg-simple" } navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigation" } navigation-fragment-ktx = { group = "androidx.navigation", name = "navigation-fragment-ktx", version.ref = "navigation" } -navigation-safe-args-gradle-plugin = { module = "androidx.navigation:navigation-safe-args-gradle-plugin", version.ref = "navigation" } navigation-testing = { group = "androidx.navigation", name = "navigation-testing", version.ref = "navigation" } navigation-ui-ktx = { group = "androidx.navigation", name = "navigation-ui-ktx", version.ref = "navigation" } okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" } @@ -171,58 +173,58 @@ orchestrator = { group = "androidx.test", name = "orchestrator", version.ref = " p2p-lib = { group = "org.smartregister", name = "p2p-lib", version.ref = "p2p-lib" } paging-compose = { group = "androidx.paging", name = "paging-compose", version.ref = "androidx-paging" } paging-runtime-ktx = { group = "androidx.paging", name = "paging-runtime-ktx", version.ref = "androidx-paging" } -play-services-location = { module = "com.google.android.gms:play-services-location", version.ref = "playServicesLocation" } +play-services-tasks = { group = "com.google.android.gms", name = "play-services-tasks", version.ref = "playServicesTasks" } preference-ktx = { group = "androidx.preference", name = "preference-ktx", version.ref = "preference-ktx" } prettytime = { group = "org.ocpsoft.prettytime", name = "prettytime", version.ref = "prettytime" } retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" } retrofit-mock = { group = "com.squareup.retrofit2", name = "retrofit-mock", version.ref = "retrofit-mock" } retrofit2-kotlinx-serialization-converter = { group = "com.jakewharton.retrofit", name = "retrofit2-kotlinx-serialization-converter", version.ref = "retrofit2-kotlinx-serialization-converter" } robolectric = { group = "org.robolectric", name = "robolectric", version.ref = "robolectric" } -runner = { module = "androidx.test:runner", version.ref = "android-x-test" } -runtime-livedata = { group = "androidx.compose.runtime", name = "runtime-livedata", version.ref = "ui" } +rules = { group = "androidx.test", name = "rules", version.ref = "rules" } +runner = { module = "androidx.test:runner", version.ref = "androidx-test" } +runtime-livedata = { group = "androidx.compose.runtime", name = "runtime-livedata", version.ref = "compose-ui" } security-crypto = { group = "androidx.security", name = "security-crypto", version.ref = "security-crypto" } slf4j-nop = { group = "org.slf4j", name = "slf4j-nop", version.ref = "slf4j-nop" } -spotless-plugin-gradle = { module = "com.diffplug.spotless:spotless-plugin-gradle", version.ref = "spotlessPluginGradle" } stax-api = { group = "javax.xml.stream", name = "stax-api", version.ref = "stax-api" } timber = { group = "com.jakewharton.timber", name = "timber", version.ref = "timber" } -ui = { group = "androidx.compose.ui", name = "ui", version.ref = "ui" } -ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4", version.ref = "ui" } -ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest", version.ref = "ui" } -ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling", version.ref = "ui" } -ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview", version.ref = "ui" } +ui = { group = "androidx.compose.ui", name = "ui", version.ref = "compose-ui" } +ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4", version.ref = "compose-ui" } +ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest", version.ref = "compose-ui" } +ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling", version.ref = "compose-ui" } +ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview", version.ref = "compose-ui" } +uiautomator = { group = "androidx.test.uiautomator", name = "uiautomator", version.ref = "uiautomator" } work-runtime-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "work" } work-testing = { group = "androidx.work", name = "work-testing", version.ref = "work" } -workflow = { group = "org.smartregister", name = "workflow", version.ref = "workflow" } +workflow = { group = "org.smartregister", name = "workflow", version.ref = "fhir-sdk-workflow" } xercesImpl = { group = "xerces", name = "xercesImpl", version.ref = "xercesImpl" } -play-services-tasks = { group = "com.google.android.gms", name = "play-services-tasks", version.ref = "playServicesTasks" } -gms-play-services-location = { group = "com.google.android.gms", name = "play-services-location", version.ref = "google-playServicesLocation" } -uiautomator = { group = "androidx.test.uiautomator", name = "uiautomator", version.ref = "uiautomator" } -rules = { group = "androidx.test", name = "rules", version.ref = "rules" } +androidx-fragment-compose = { group = "androidx.fragment", name = "fragment-compose", version.ref = "androidFragmentCompose" } + [plugins] -org-jetbrains-kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "jetbrains-kotlin-jvm" } -org-jetbrains-dokka = { id = "org.jetbrains.dokka", version.ref = "jetbrains" } -kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin-serialization" } -org-owasp-dependencycheck = { id = "org.owasp.dependencycheck", version.ref = "owasp" } -dagger-hilt-android= { id = "com.google.dagger.hilt.android", version.ref = "dagger-hilt" } -com-diffplug-spotless = { id = "com.diffplug.spotless", version.ref = "spotlessPluginGradle" } +android-junit5 = {id="de.mannodermaus.android-junit5", version.ref="androidJunit5"} androidx-navigation-safeargs = { id = "androidx.navigation.safeargs.kotlin", version.ref = "navigation" } +com-diffplug-spotless = { id = "com.diffplug.spotless", version.ref = "spotlessPluginGradle" } +dagger-hilt-android= { id = "com.google.dagger.hilt.android", version.ref = "dagger-hilt" } +kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin-serialization" } kt3k-coveralls = { id = "com.github.kt3k.coveralls", version.ref = "kt3k-coveralls-ver" } -android-junit5 = {id="de.mannodermaus.android-junit5", version.ref="androidJunit5"} +org-jetbrains-dokka = { id = "org.jetbrains.dokka", version.ref = "jetbrains" } +org-jetbrains-kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } +org-owasp-dependencycheck = { id = "org.owasp.dependencycheck", version.ref = "owasp" } [bundles] -retrofit2 = ["retrofit", "retrofit-mock", "retrofit2-kotlinx-serialization-converter"] -paging = ["paging-compose", "paging-runtime-ktx"] -materialicons = ["compose-material-icons-extended", "compose-material-icons-core"] -datastore-kt = ["datastore-preferences", "datastore"] -lifecycle = ["lifecycle-viewmodel-ktx", "lifecycle-viewmodel-compose", "lifecycle-livedata-ktx"] -okhttp3 = ["okhttp-logging-interceptor", "okhttp"] accompanist = ["accompanist-placeholder", "accompanist-flowlayout"] -klint = ["ktlint-cli-ruleset", "ktlint-rule-engine-core"] +cameraX = ["androidx-camera-camera2", "androidx-camera-extensions", "androidx-camera-lifecycle", "androidx-camera-mlkit-vision", "androidx-camera-view"] compose = ["activity-compose","activity-ktx", "ui", "ui-tooling-preview", "constraintlayout-compose", "foundation","runtime-livedata"] -navigation = ["navigation-compose", "navigation-fragment-ktx", "navigation-ui-ktx"] -junit-test = ["junit-ktx", "junit"] -coroutines = ["kotlinx-coroutines-core", "kotlinx-coroutines-android"] compose-ui-test = ["ui-test-junit4","ui-test-manifest"] +coroutines = ["kotlinx-coroutines-core", "kotlinx-coroutines-android"] +datastore-kt = ["datastore-preferences", "datastore"] junit-jupiter-runtime = ["junit-jupiter-engine","junit-vintage-engine"] +junit-test = ["junit-ktx", "junit"] +klint = ["ktlint-cli-ruleset", "ktlint-rule-engine-core"] +lifecycle = ["lifecycle-viewmodel-ktx", "lifecycle-viewmodel-compose", "lifecycle-livedata-ktx"] +materialicons = ["compose-material-icons-extended", "compose-material-icons-core"] +navigation = ["navigation-compose", "navigation-fragment-ktx", "navigation-ui-ktx"] +okhttp3 = ["okhttp-logging-interceptor", "okhttp"] +paging = ["paging-compose", "paging-runtime-ktx"] +retrofit2 = ["retrofit", "retrofit-mock", "retrofit2-kotlinx-serialization-converter"] diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar index 7a3265ee94c..d64cd491770 100644 Binary files a/android/gradle/wrapper/gradle-wrapper.jar and b/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties index 66ec8b608d3..1af9e0930b8 100644 --- a/android/gradle/wrapper/gradle-wrapper.properties +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,7 @@ distributionBase=GRADLE_USER_HOME -distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-bin.zip distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip +networkTimeout=10000 +validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/android/gradlew b/android/gradlew index cccdd3d517f..1aa94a42690 100755 --- a/android/gradlew +++ b/android/gradlew @@ -1,78 +1,127 @@ -#!/usr/bin/env sh +#!/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 UN*X -## +# +# 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 -PRG="$0" -# Need this for relative symlinks. -while [ -h "$PRG" ] ; do - ls=`ls -ld "$PRG"` - link=`expr "$ls" : '.*-> \(.*\)$'` - if expr "$link" : '/.*' > /dev/null; then - PRG="$link" - else - PRG=`dirname "$PRG"`"/$link" - fi +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 -SAVED="`pwd`" -cd "`dirname \"$PRG\"`/" >/dev/null -APP_HOME="`pwd -P`" -cd "$SAVED" >/dev/null - -APP_NAME="Gradle" -APP_BASE_NAME=`basename "$0"` -# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -DEFAULT_JVM_OPTS="" +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. -MAX_FD="maximum" +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 - ;; - MINGW* ) - msys=true - ;; - NONSTOP* ) - nonstop=true - ;; +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" + JAVACMD=$JAVA_HOME/jre/sh/java else - JAVACMD="$JAVA_HOME/bin/java" + JAVACMD=$JAVA_HOME/bin/java fi if [ ! -x "$JAVACMD" ] ; then die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME @@ -81,92 +130,120 @@ 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. + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + 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 fi # Increase the maximum file descriptors if we can. -if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then - MAX_FD_LIMIT=`ulimit -H -n` - if [ $? -eq 0 ] ; then - if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then - MAX_FD="$MAX_FD_LIMIT" - fi - ulimit -n $MAX_FD - if [ $? -ne 0 ] ; then - warn "Could not set maximum file descriptor limit: $MAX_FD" - fi - else - warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" - fi +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=SC2039,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=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac fi -# For Darwin, add options to specify how the application appears in the dock -if $darwin; then - GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" -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" ) -# For Cygwin, switch paths to Windows format before running java -if $cygwin ; then - APP_HOME=`cygpath --path --mixed "$APP_HOME"` - CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` - JAVACMD=`cygpath --unix "$JAVACMD"` - - # We build the pattern for arguments to be converted via cygpath - ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` - SEP="" - for dir in $ROOTDIRSRAW ; do - ROOTDIRS="$ROOTDIRS$SEP$dir" - SEP="|" - done - OURCYGPATTERN="(^($ROOTDIRS))" - # Add a user-defined pattern to the cygpath arguments - if [ "$GRADLE_CYGPATTERN" != "" ] ; then - OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" - fi # Now convert the arguments - kludge to limit ourselves to /bin/sh - i=0 - for arg in "$@" ; do - CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` - CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option - - if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition - eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` - else - eval `echo args$i`="\"$arg\"" + 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 - i=$((i+1)) + # 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 - case $i in - (0) set -- ;; - (1) set -- "$args0" ;; - (2) set -- "$args0" "$args1" ;; - (3) set -- "$args0" "$args1" "$args2" ;; - (4) set -- "$args0" "$args1" "$args2" "$args3" ;; - (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; - (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; - (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; - (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; - (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; - esac fi -# Escape application args -save () { - for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done - echo " " -} -APP_ARGS=$(save "$@") - -# Collect all arguments for the java command, following the shell quoting and substitution rules -eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" -# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong -if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then - cd "$(dirname "$0")" +# 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"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +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/android/gradlew.bat b/android/gradlew.bat index e95643d6a2c..6689b85beec 100644 --- a/android/gradlew.bat +++ b/android/gradlew.bat @@ -1,4 +1,20 @@ -@if "%DEBUG%" == "" @echo off +@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 @@ -9,19 +25,23 @@ if "%OS%"=="Windows_NT" setlocal set DIRNAME=%~dp0 -if "%DIRNAME%" == "" set DIRNAME=. +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= +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%" == "0" goto init +if %ERRORLEVEL% equ 0 goto execute echo. echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. @@ -35,7 +55,7 @@ goto fail set JAVA_HOME=%JAVA_HOME:"=% set JAVA_EXE=%JAVA_HOME%/bin/java.exe -if exist "%JAVA_EXE%" goto init +if exist "%JAVA_EXE%" goto execute echo. echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% @@ -45,38 +65,26 @@ echo location of your Java installation. goto fail -:init -@rem Get command-line arguments, handling Windows variants - -if not "%OS%" == "Windows_NT" goto win9xME_args - -:win9xME_args -@rem Slurp the command line arguments. -set CMD_LINE_ARGS= -set _SKIP=2 - -:win9xME_args_slurp -if "x%~1" == "x" goto execute - -set CMD_LINE_ARGS=%* - :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 %CMD_LINE_ARGS% +"%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%"=="0" goto mainEnd +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! -if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 -exit /b 1 +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 diff --git a/android/quest/build.gradle.kts b/android/quest/build.gradle.kts index 70550605d9b..0a2841c66a8 100644 --- a/android/quest/build.gradle.kts +++ b/android/quest/build.gradle.kts @@ -11,7 +11,7 @@ import org.json.JSONObject plugins { `jacoco-report` `project-properties` - `ktlint` + ktlint id("com.android.application") id("kotlin-android") id("kotlin-kapt") @@ -27,7 +27,7 @@ plugins { sonar { properties { property("sonar.projectKey", "fhircore") - property("sonar.kotlin.source.version", libs.kotlin) + property("sonar.kotlin.source.version", libs.versions.kotlin) property( "sonar.androidLint.reportPaths", "${project.layout.buildDirectory.get()}/reports/lint-results-opensrpDebug.xml", @@ -61,6 +61,7 @@ android { defaultConfig { applicationId = BuildConfigs.applicationId minSdk = BuildConfigs.minSdk + targetSdk = BuildConfigs.targetSdk versionCode = BuildConfigs.versionCode versionName = BuildConfigs.versionName multiDexEnabled = true @@ -99,15 +100,19 @@ android { } buildTypes { - getByName("debug") { enableUnitTestCoverage = true } + getByName("debug") { + enableUnitTestCoverage = BuildConfigs.enableUnitTestCoverage + enableAndroidTestCoverage = BuildConfigs.enableAndroidTestCoverage + } + + create("debugNonProxy") { initWith(getByName("debug")) } + create("benchmark") { signingConfig = signingConfigs.getByName("debug") matchingFallbacks += listOf("debug") isDebuggable = true } - create("debugNonProxy") { initWith(getByName("debug")) } - getByName("release") { isMinifyEnabled = false proguardFiles( @@ -316,11 +321,18 @@ android { manifestPlaceholders["appLabel"] = "PSI WFA" } - create("eusm") { + create("eusmMg") { dimension = "apps" - applicationIdSuffix = ".eusm" - versionNameSuffix = "-eusm" - manifestPlaceholders["appLabel"] = "EUSM" + applicationIdSuffix = ".eusmMg" + versionNameSuffix = "-eusmMg" + manifestPlaceholders["appLabel"] = "EUSM Madagascar" + } + + create("eusmBi") { + dimension = "apps" + applicationIdSuffix = ".eusmBi" + versionNameSuffix = "-eusmBi" + manifestPlaceholders["appLabel"] = "EUSM Burundi" } create("demoEir") { @@ -330,11 +342,18 @@ android { manifestPlaceholders["appLabel"] = "OpenSRP EIR" } - create("vamosJuntos") { + create("contigo") { dimension = "apps" - applicationIdSuffix = ".vamosJuntos" - versionNameSuffix = "-vamosJuntos" - manifestPlaceholders["appLabel"] = "Vamos Juntos" + applicationIdSuffix = ".contigo" + versionNameSuffix = "-contigo" + manifestPlaceholders["appLabel"] = "Contigo" + } + + create("minsaEir") { + dimension = "apps" + applicationIdSuffix = ".minsaEir" + versionNameSuffix = "-minsaEir" + manifestPlaceholders["appLabel"] = "Minsa EIR" } } @@ -406,12 +425,30 @@ tasks.withType { testLogging { events = setOf(TestLogEvent.FAILED) } minHeapSize = "4608m" maxHeapSize = "4608m" - maxParallelForks = (Runtime.getRuntime().availableProcessors() / 2).takeIf { it > 0 } ?: 1 + addTestListener( + object : TestListener { + override fun beforeSuite(p0: TestDescriptor?) {} + + override fun afterSuite(p0: TestDescriptor?, p1: TestResult?) {} + + override fun beforeTest(p0: TestDescriptor?) { + logger.lifecycle("Running test: $p0") + } + + override fun afterTest(p0: TestDescriptor?, p1: TestResult?) { + logger.lifecycle("Done executing: $p0") + } + }, + ) + + // maxParallelForks = (Runtime.getRuntime().availableProcessors() / 2).takeIf { it > 0 } ?: 1 + configure { isIncludeNoLocationClasses = true } } configurations { all { exclude(group = "xpp3") } } dependencies { + implementation(libs.gms.play.services.location) coreLibraryDesugaring(libs.core.desugar) // Application dependencies @@ -422,7 +459,9 @@ dependencies { implementation(libs.material) implementation(libs.dagger.hilt.android) implementation(libs.hilt.work) - implementation(libs.play.services.location) + implementation(libs.mlkit.barcode.scanning) + implementation(libs.androidx.fragment.compose) + implementation(libs.bundles.cameraX) // Annotation processors kapt(libs.hilt.compiler) diff --git a/android/quest/src/androidTest/java/org/smartregister/fhircore/quest/integration/Faker.kt b/android/quest/src/androidTest/java/org/smartregister/fhircore/quest/integration/Faker.kt index 13506369973..aa34696bade 100644 --- a/android/quest/src/androidTest/java/org/smartregister/fhircore/quest/integration/Faker.kt +++ b/android/quest/src/androidTest/java/org/smartregister/fhircore/quest/integration/Faker.kt @@ -21,6 +21,7 @@ import androidx.test.platform.app.InstrumentationRegistry import com.google.android.fhir.FhirEngine import com.google.android.fhir.LocalChange import com.google.android.fhir.SearchResult +import com.google.android.fhir.db.LocalChangeResourceReference import com.google.android.fhir.search.Search import com.google.android.fhir.sync.ConflictResolver import com.google.android.fhir.sync.upload.SyncUploadProgress @@ -103,12 +104,19 @@ object Faker { override suspend fun syncUpload( uploadStrategy: UploadStrategy, - upload: suspend (List) -> Flow, + upload: + suspend (List, List) -> Flow< + UploadRequestResult, + >, ): Flow { return flowOf() } override suspend fun update(vararg resource: Resource) {} + + override suspend fun withTransaction(block: suspend FhirEngine.() -> Unit) { + TODO("Not yet implemented") + } } val fhirResourceService = diff --git a/android/quest/src/androidTest/java/org/smartregister/fhircore/quest/integration/ui/login/LoginScreenTest.kt b/android/quest/src/androidTest/java/org/smartregister/fhircore/quest/integration/ui/login/LoginScreenTest.kt index 93f4927368b..8fffbdcace5 100644 --- a/android/quest/src/androidTest/java/org/smartregister/fhircore/quest/integration/ui/login/LoginScreenTest.kt +++ b/android/quest/src/androidTest/java/org/smartregister/fhircore/quest/integration/ui/login/LoginScreenTest.kt @@ -21,8 +21,10 @@ import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.junit4.createComposeRule 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.test.platform.app.InstrumentationRegistry +import junit.framework.TestCase.assertTrue import kotlinx.coroutines.ExperimentalCoroutinesApi import org.junit.Rule import org.junit.Test @@ -60,7 +62,7 @@ class LoginScreenTest { ApplicationConfiguration( appTitle = "My app", appId = "app/debug", - loginConfig = LoginConfig(showLogo = true), + loginConfig = LoginConfig(showLogo = true, supervisorContactNumber = "123-456-7890"), ) private val context = InstrumentationRegistry.getInstrumentation().targetContext @@ -91,10 +93,87 @@ class LoginScreenTest { @Test fun testForgotPasswordDialog() { - composeRule.setContent { ForgotPasswordDialog(forgotPassword = {}, onDismissDialog = {}) } + composeRule.setContent { + ForgotPasswordDialog( + forgotPassword = {}, + supervisorContactNumber = applicationConfiguration.loginConfig.supervisorContactNumber, + onDismissDialog = {}, + ) + } composeRule.onNodeWithTag(PASSWORD_FORGOT_DIALOG).assertExists() } + @Test + fun testForgotPasswordDialog_DisplayedCorrectly() { + composeRule.setContent { + ForgotPasswordDialog( + supervisorContactNumber = applicationConfiguration.loginConfig.supervisorContactNumber, + forgotPassword = {}, + onDismissDialog = {}, + ) + } + assertDialogContent() + } + + private fun assertDialogContent() { + composeRule.onNodeWithTag(PASSWORD_FORGOT_DIALOG).assertExists().assertIsDisplayed() + composeRule + .onNodeWithText(context.getString(R.string.forgot_password_title)) + .assertIsDisplayed() + composeRule.onNodeWithText(context.getString(R.string.call_supervisor)).assertIsDisplayed() + composeRule + .onNodeWithText(applicationConfiguration.loginConfig.supervisorContactNumber.toString()) + .assertIsDisplayed() + composeRule.onNodeWithText(context.getString(R.string.cancel)).assertIsDisplayed() + composeRule.onNodeWithText(context.getString(R.string.dial_number)).assertIsDisplayed() + } + + @Test + fun testForgotPasswordDialog_CancelButton_Click() { + var dismissDialogClicked = false + + composeRule.setContent { + ForgotPasswordDialog( + supervisorContactNumber = applicationConfiguration.loginConfig.supervisorContactNumber, + forgotPassword = {}, + onDismissDialog = { dismissDialogClicked = true }, + ) + } + val cancelText = context.getString(R.string.cancel) + composeRule.onNodeWithText(cancelText).performClick() + assertTrue(dismissDialogClicked) + } + + @Test + fun testForgotPasswordDialog_DisplaysCorrectContactNumber() { + composeRule.setContent { + ForgotPasswordDialog( + supervisorContactNumber = applicationConfiguration.loginConfig.supervisorContactNumber, + forgotPassword = {}, + onDismissDialog = {}, + ) + } + val contactNumber = applicationConfiguration.loginConfig.supervisorContactNumber + composeRule.onNodeWithText(contactNumber.toString()).assertIsDisplayed() + } + + @Test + fun testForgotPasswordDialog_DialNumberButton_Click() { + var forgotPasswordClicked = false + var dismissDialogClicked = false + composeRule.setContent { + ForgotPasswordDialog( + supervisorContactNumber = applicationConfiguration.loginConfig.supervisorContactNumber, + forgotPassword = { forgotPasswordClicked = true }, + onDismissDialog = { dismissDialogClicked = true }, + ) + } + val dialNumber = context.getString(R.string.dial_number) + composeRule.onNodeWithText(dialNumber).performClick() + assert(dismissDialogClicked) + assert(forgotPasswordClicked) + } + @Test fun testOnDoneKeyboardActionPerformsLoginButtonClicked() { listenerObjectSpy.attemptRemoteLogin() diff --git a/android/quest/src/androidTest/java/org/smartregister/fhircore/quest/integration/ui/main/components/AppDrawerTest.kt b/android/quest/src/androidTest/java/org/smartregister/fhircore/quest/integration/ui/main/components/AppDrawerTest.kt index 04995b2aee1..9f11816f26b 100644 --- a/android/quest/src/androidTest/java/org/smartregister/fhircore/quest/integration/ui/main/components/AppDrawerTest.kt +++ b/android/quest/src/androidTest/java/org/smartregister/fhircore/quest/integration/ui/main/components/AppDrawerTest.kt @@ -16,6 +16,8 @@ package org.smartregister.fhircore.quest.integration.ui.main.components +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember import androidx.compose.ui.test.assertCountEquals import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.junit4.createComposeRule @@ -38,8 +40,6 @@ import org.smartregister.fhircore.quest.ui.main.components.AppDrawer import org.smartregister.fhircore.quest.ui.main.components.MENU_BUTTON_ICON_TEST_TAG import org.smartregister.fhircore.quest.ui.main.components.MENU_BUTTON_TEST_TAG import org.smartregister.fhircore.quest.ui.main.components.MENU_BUTTON_TEXT_TEST_TAG -import org.smartregister.fhircore.quest.ui.main.components.NAV_BOTTOM_SECTION_MAIN_BOX_TEST_TAG -import org.smartregister.fhircore.quest.ui.main.components.NAV_BOTTOM_SECTION_SIDE_MENU_ITEM_TEST_TAG import org.smartregister.fhircore.quest.ui.main.components.NAV_TOP_SECTION_TEST_TAG import org.smartregister.fhircore.quest.ui.main.components.SIDE_MENU_ITEM_INNER_ROW_TEST_TAG import org.smartregister.fhircore.quest.ui.main.components.SIDE_MENU_ITEM_MAIN_ROW_TEST_TAG @@ -110,10 +110,10 @@ class AppDrawerTest { setContent("") composeTestRule .onAllNodesWithTag(SIDE_MENU_ITEM_MAIN_ROW_TEST_TAG, useUnmergedTree = true) - .assertCountEquals(3) + .assertCountEquals(4) composeTestRule .onAllNodesWithTag(SIDE_MENU_ITEM_INNER_ROW_TEST_TAG, useUnmergedTree = true) - .assertCountEquals(3) + .assertCountEquals(4) composeTestRule .onAllNodesWithTag(SIDE_MENU_ITEM_TEXT_TEST_TAG, useUnmergedTree = true) .assertCountEquals(5) @@ -126,14 +126,6 @@ class AppDrawerTest { .onNodeWithText("Manual Sync", useUnmergedTree = true) .assertExists() .assertIsDisplayed() - composeTestRule - .onNodeWithTag(NAV_BOTTOM_SECTION_SIDE_MENU_ITEM_TEST_TAG) - .assertExists() - .assertIsDisplayed() - composeTestRule - .onNodeWithTag(NAV_BOTTOM_SECTION_MAIN_BOX_TEST_TAG) - .assertExists() - .assertIsDisplayed() } @Test @@ -190,6 +182,9 @@ class AppDrawerTest { openDrawer = {}, onSideMenuClick = onClickListener, appVersionPair = Pair(1, "0.0.1"), + onCountUnSyncedResources = {}, + unSyncedResourceCount = remember { mutableIntStateOf(0) }, + decodeImage = null, ) } } diff --git a/android/quest/src/androidTest/java/org/smartregister/fhircore/quest/integration/ui/main/components/TopScreenSectionTest.kt b/android/quest/src/androidTest/java/org/smartregister/fhircore/quest/integration/ui/main/components/TopScreenSectionTest.kt index 8268bbc44cc..b15b59abf15 100644 --- a/android/quest/src/androidTest/java/org/smartregister/fhircore/quest/integration/ui/main/components/TopScreenSectionTest.kt +++ b/android/quest/src/androidTest/java/org/smartregister/fhircore/quest/integration/ui/main/components/TopScreenSectionTest.kt @@ -16,41 +16,44 @@ package org.smartregister.fhircore.quest.integration.ui.main.components +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick -import androidx.navigation.NavController -import io.mockk.mockk +import androidx.navigation.testing.TestNavHostController import org.junit.Assert import org.junit.Rule import org.junit.Test import org.smartregister.fhircore.quest.ui.main.components.LEADING_ICON_TEST_TAG import org.smartregister.fhircore.quest.ui.main.components.OUTLINED_BOX_TEST_TAG +import org.smartregister.fhircore.quest.ui.main.components.SEARCH_FIELD_TEST_TAG import org.smartregister.fhircore.quest.ui.main.components.TITLE_ROW_TEST_TAG import org.smartregister.fhircore.quest.ui.main.components.TOP_ROW_ICON_TEST_TAG import org.smartregister.fhircore.quest.ui.main.components.TOP_ROW_TEXT_TEST_TAG import org.smartregister.fhircore.quest.ui.main.components.TRAILING_ICON_BUTTON_TEST_TAG import org.smartregister.fhircore.quest.ui.main.components.TRAILING_ICON_TEST_TAG +import org.smartregister.fhircore.quest.ui.main.components.TRAILING_QR_SCAN_ICON_BUTTON_TEST_TAG import org.smartregister.fhircore.quest.ui.main.components.TopScreenSection +import org.smartregister.fhircore.quest.ui.shared.models.SearchQuery class TopScreenSectionTest { - private val listener: (String) -> Unit = {} + private val listener: (SearchQuery, Boolean) -> Unit = { _, _ -> } @get:Rule val composeTestRule = createComposeRule() - private val navController: NavController = mockk(relaxUnitFun = true) @Test fun testTopScreenSectionRendersTitleRowCorrectly() { composeTestRule.setContent { TopScreenSection( title = "All Clients", - searchText = "search text", + searchQuery = SearchQuery("search text"), onSearchTextChanged = listener, - navController = navController, + navController = TestNavHostController(LocalContext.current), isSearchBarVisible = true, onClick = {}, + decodeImage = null, ) } @@ -77,11 +80,12 @@ class TopScreenSectionTest { composeTestRule.setContent { TopScreenSection( title = "All Clients", - searchText = "search text", + searchQuery = SearchQuery("search text"), onSearchTextChanged = listener, - navController = navController, + navController = TestNavHostController(LocalContext.current), isSearchBarVisible = true, onClick = {}, + decodeImage = null, ) } @@ -103,6 +107,50 @@ class TopScreenSectionTest { .assertIsDisplayed() } + @Test + fun testTopScreenSectionRendersPlaceholderCorrectly() { + composeTestRule.setContent { + TopScreenSection( + title = "All Clients", + searchQuery = SearchQuery(""), + onSearchTextChanged = listener, + navController = TestNavHostController(LocalContext.current), + isSearchBarVisible = true, + placeholderColor = null, + searchPlaceholder = "Custom placeholder", + onClick = {}, + decodeImage = null, + ) + } + + composeTestRule + .onNodeWithTag(SEARCH_FIELD_TEST_TAG, useUnmergedTree = true) + .assertExists() + .assertIsDisplayed() + } + + @Test + fun testTopScreenSectionRendersPlaceholderColorCorrectly() { + composeTestRule.setContent { + TopScreenSection( + title = "All Clients", + searchQuery = SearchQuery(""), + onSearchTextChanged = listener, + navController = TestNavHostController(LocalContext.current), + isSearchBarVisible = true, + placeholderColor = "#FF0000", + searchPlaceholder = "Custom placeholder", + onClick = {}, + decodeImage = null, + ) + } + + composeTestRule + .onNodeWithTag(SEARCH_FIELD_TEST_TAG, useUnmergedTree = true) + .assertExists() + .assertIsDisplayed() + } + @Test fun testThatTrailingIconClickCallsTheListener() { var clicked = false @@ -110,11 +158,12 @@ class TopScreenSectionTest { composeTestRule.setContent { TopScreenSection( title = "All Clients", - searchText = "search text", - onSearchTextChanged = { clicked = true }, - navController = navController, + searchQuery = SearchQuery("search text"), + onSearchTextChanged = { _, _ -> clicked = true }, + navController = TestNavHostController(LocalContext.current), isSearchBarVisible = true, onClick = {}, + decodeImage = null, ) } @@ -123,4 +172,36 @@ class TopScreenSectionTest { trailingIcon.performClick() Assert.assertTrue(clicked) } + + @Test + fun thatTopScreenSectionHideQrCodeIconWhenShowSearchByQrCodeIsTrueAndSearchQueryIsNotBlank() { + composeTestRule.setContent { + TopScreenSection( + title = "All Clients", + searchQuery = SearchQuery("search text"), + showSearchByQrCode = true, + navController = TestNavHostController(LocalContext.current), + isSearchBarVisible = true, + onClick = {}, + decodeImage = null, + ) + } + composeTestRule.onNodeWithTag(TRAILING_QR_SCAN_ICON_BUTTON_TEST_TAG).assertDoesNotExist() + } + + @Test + fun thatTopScreenSectionShowsQrCodeIconWhenShowSearchByQrCodeIsTrueAndSearchQueryIsBlank() { + composeTestRule.setContent { + TopScreenSection( + title = "All Clients", + searchQuery = SearchQuery(""), + showSearchByQrCode = true, + navController = TestNavHostController(LocalContext.current), + isSearchBarVisible = true, + onClick = {}, + decodeImage = null, + ) + } + composeTestRule.onNodeWithTag(TRAILING_QR_SCAN_ICON_BUTTON_TEST_TAG).assertIsDisplayed() + } } diff --git a/android/quest/src/androidTest/java/org/smartregister/fhircore/quest/integration/ui/pin/PinLoginScreenKtTest.kt b/android/quest/src/androidTest/java/org/smartregister/fhircore/quest/integration/ui/pin/PinLoginScreenKtTest.kt index a855e22f45f..d993c907fef 100644 --- a/android/quest/src/androidTest/java/org/smartregister/fhircore/quest/integration/ui/pin/PinLoginScreenKtTest.kt +++ b/android/quest/src/androidTest/java/org/smartregister/fhircore/quest/integration/ui/pin/PinLoginScreenKtTest.kt @@ -25,10 +25,14 @@ import androidx.compose.ui.test.onAllNodesWithText import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick +import org.junit.Ignore import org.junit.Rule import org.junit.Test +import org.smartregister.fhircore.engine.configuration.app.ApplicationConfiguration +import org.smartregister.fhircore.engine.configuration.app.LoginConfig import org.smartregister.fhircore.engine.ui.components.PIN_CELL_TEST_TAG import org.smartregister.fhircore.quest.ui.pin.CIRCULAR_PROGRESS_INDICATOR +import org.smartregister.fhircore.quest.ui.pin.ForgotPinDialog import org.smartregister.fhircore.quest.ui.pin.PIN_LOGO_IMAGE import org.smartregister.fhircore.quest.ui.pin.PinLoginPage import org.smartregister.fhircore.quest.ui.pin.PinUiState @@ -36,11 +40,43 @@ import org.smartregister.fhircore.quest.ui.pin.PinUiState class PinLoginScreenKtTest { @get:Rule(order = 1) val composeRule = createComposeRule() + private val applicationConfiguration = + ApplicationConfiguration( + appTitle = "My app", + appId = "app/debug", + loginConfig = LoginConfig(showLogo = true, supervisorContactNumber = "123-456-7890"), + ) + + @Test + fun testForgotPasswordDialog_DisplaysCorrectContactNumber() { + // Set the content for the test + composeRule.setContent { + ForgotPinDialog( + supervisorContactNumber = applicationConfiguration.loginConfig.supervisorContactNumber, + forgotPin = {}, + onDismissDialog = {}, + ) + } + + // Retrieve the contact number from the context + val contactNumber = applicationConfiguration.loginConfig.supervisorContactNumber + + // Assert that the contact number is displayed correctly in the dialog + composeRule.onNodeWithText(contactNumber.toString()).assertIsDisplayed() + } + @Test fun testThatPinSetupPageIsLaunched() { composeRule.setContent { PinLoginPage( + applicationConfiguration = + ApplicationConfiguration( + appId = "appId", + configType = "application", + appTitle = "FHIRCore App", + ), onSetPin = {}, + showProgressBar = false, showError = false, onMenuLoginClicked = {}, forgotPin = {}, @@ -66,11 +102,19 @@ class PinLoginScreenKtTest { .assertIsDisplayed() } + @Ignore("This test is currently ignored") @Test fun testThatEnterPinPageIsLaunched() { composeRule.setContent { PinLoginPage( + applicationConfiguration = + ApplicationConfiguration( + appId = "appId", + configType = "application", + appTitle = "FHIRCore App", + ), onSetPin = {}, + showProgressBar = false, showError = false, onMenuLoginClicked = {}, forgotPin = {}, @@ -86,16 +130,17 @@ class PinLoginScreenKtTest { onPinEntered = { _: CharArray, _: (Boolean) -> Unit -> }, ) } + composeRule.onNodeWithText("MOH eCBIS", ignoreCase = true).assertExists().assertIsDisplayed() composeRule .onNodeWithText("Enter PIN for ecbis", ignoreCase = true) .assertExists() .assertIsDisplayed() composeRule.onAllNodesWithTag(PIN_CELL_TEST_TAG).assertCountEquals(4) - val forgotPinNode = composeRule.onNodeWithText("Forgot PIN?", ignoreCase = true) + + val forgotPinNode = composeRule.onNodeWithTag("FORGOT_PIN_TEXT") forgotPinNode.assertExists().assertIsDisplayed().assertHasClickAction() - // Clicking forgot pin should launch dialog forgotPinNode.performClick() composeRule.onNodeWithText("CANCEL").assertIsDisplayed().assertHasClickAction() composeRule.onNodeWithText("DIAL NUMBER").assertIsDisplayed().assertHasClickAction() @@ -106,7 +151,14 @@ class PinLoginScreenKtTest { val errorMessage = "Incorrect PIN. Please try again." composeRule.setContent { PinLoginPage( + applicationConfiguration = + ApplicationConfiguration( + appId = "appId", + configType = "application", + appTitle = "FHIRCore App", + ), onSetPin = {}, + showProgressBar = false, showError = true, onMenuLoginClicked = {}, forgotPin = {}, @@ -117,7 +169,6 @@ class PinLoginScreenKtTest { setupPin = false, pinLength = 4, showLogo = true, - showProgressBar = true, ), onShowPinError = {}, onPinEntered = { _: CharArray, _: (Boolean) -> Unit -> }, @@ -130,7 +181,14 @@ class PinLoginScreenKtTest { fun testThatPinSetupPageShowsCircularProgressIndicator() { composeRule.setContent { PinLoginPage( + applicationConfiguration = + ApplicationConfiguration( + appId = "appId", + configType = "application", + appTitle = "FHIRCore App", + ), onSetPin = {}, + showProgressBar = true, showError = false, onMenuLoginClicked = {}, forgotPin = {}, @@ -141,7 +199,6 @@ class PinLoginScreenKtTest { setupPin = true, pinLength = 4, showLogo = false, - showProgressBar = true, ), onShowPinError = {}, onPinEntered = { _: CharArray, _: (Boolean) -> Unit -> }, @@ -158,7 +215,14 @@ class PinLoginScreenKtTest { val pinStateMessage = "Provider will use this PIN to login" composeRule.setContent { PinLoginPage( + applicationConfiguration = + ApplicationConfiguration( + appId = "appId", + configType = "application", + appTitle = "FHIRCore App", + ), onSetPin = {}, + showProgressBar = false, showError = false, onMenuLoginClicked = {}, forgotPin = {}, @@ -169,7 +233,6 @@ class PinLoginScreenKtTest { setupPin = false, pinLength = 1, showLogo = false, - showProgressBar = true, ), onShowPinError = {}, onPinEntered = { _: CharArray, _: (Boolean) -> Unit -> }, diff --git a/android/quest/src/androidTest/java/org/smartregister/fhircore/quest/integration/ui/profile/ProfileScreenTest.kt b/android/quest/src/androidTest/java/org/smartregister/fhircore/quest/integration/ui/profile/ProfileScreenTest.kt index 92d6d474177..5fe85fdad59 100644 --- a/android/quest/src/androidTest/java/org/smartregister/fhircore/quest/integration/ui/profile/ProfileScreenTest.kt +++ b/android/quest/src/androidTest/java/org/smartregister/fhircore/quest/integration/ui/profile/ProfileScreenTest.kt @@ -75,8 +75,9 @@ class ProfileScreenTest { ProfileScreen( navController = rememberNavController(), profileUiState = profileUiState, - onEvent = {}, snackStateFlow = snackBarStateFlow, + onEvent = {}, + decodeImage = null, ) } } diff --git a/android/quest/src/androidTest/java/org/smartregister/fhircore/quest/integration/ui/profile/components/MemberProfileBottomSheetViewTest.kt b/android/quest/src/androidTest/java/org/smartregister/fhircore/quest/integration/ui/profile/components/MemberProfileBottomSheetViewTest.kt index 035be7a9461..3cc6f2a24fe 100644 --- a/android/quest/src/androidTest/java/org/smartregister/fhircore/quest/integration/ui/profile/components/MemberProfileBottomSheetViewTest.kt +++ b/android/quest/src/androidTest/java/org/smartregister/fhircore/quest/integration/ui/profile/components/MemberProfileBottomSheetViewTest.kt @@ -50,6 +50,7 @@ class MemberProfileBottomSheetViewTest { buttonProperties = buttonProperties, onViewProfile = { /*Do nothing*/}, resourceData = ResourceData("id", ResourceType.Patient, emptyMap()), + decodeImage = null, ) } } diff --git a/android/quest/src/androidTest/java/org/smartregister/fhircore/quest/integration/ui/register/RegisterScreenTest.kt b/android/quest/src/androidTest/java/org/smartregister/fhircore/quest/integration/ui/register/RegisterScreenTest.kt index 00ffb2b8344..4f06616a7f3 100644 --- a/android/quest/src/androidTest/java/org/smartregister/fhircore/quest/integration/ui/register/RegisterScreenTest.kt +++ b/android/quest/src/androidTest/java/org/smartregister/fhircore/quest/integration/ui/register/RegisterScreenTest.kt @@ -16,6 +16,7 @@ package org.smartregister.fhircore.quest.integration.ui.register +import android.app.Application import androidx.compose.runtime.mutableStateOf import androidx.compose.ui.Modifier import androidx.compose.ui.test.assertCountEquals @@ -24,32 +25,55 @@ import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onAllNodesWithTag import androidx.compose.ui.test.onChildAt import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performTouchInput import androidx.compose.ui.test.swipeDown import androidx.compose.ui.test.swipeUp import androidx.navigation.compose.rememberNavController +import androidx.paging.CombinedLoadStates +import androidx.paging.LoadState import androidx.paging.PagingData import androidx.paging.compose.LazyPagingItems import androidx.paging.compose.collectAsLazyPagingItems +import androidx.test.core.app.ApplicationProvider +import com.google.android.fhir.sync.CurrentSyncJobStatus +import com.google.android.fhir.sync.SyncJobStatus +import com.google.android.fhir.sync.SyncOperation import dagger.hilt.android.testing.HiltAndroidTest +import io.mockk.every import io.mockk.mockk +import java.time.OffsetDateTime import kotlinx.coroutines.flow.flowOf import org.hl7.fhir.r4.model.ResourceType +import org.junit.Assert import org.junit.Rule import org.junit.Test import org.smartregister.fhircore.engine.configuration.ConfigType import org.smartregister.fhircore.engine.configuration.ConfigurationRegistry +import org.smartregister.fhircore.engine.configuration.navigation.NavigationBottomSheetRegisterConfig +import org.smartregister.fhircore.engine.configuration.navigation.NavigationConfiguration +import org.smartregister.fhircore.engine.configuration.navigation.NavigationMenuConfig import org.smartregister.fhircore.engine.configuration.register.NoResultsConfig +import org.smartregister.fhircore.engine.configuration.register.RegisterConfiguration +import org.smartregister.fhircore.engine.configuration.register.RegisterContentConfig +import org.smartregister.fhircore.engine.configuration.workflow.ActionTrigger +import org.smartregister.fhircore.engine.domain.model.ActionConfig +import org.smartregister.fhircore.engine.domain.model.Language import org.smartregister.fhircore.engine.domain.model.ResourceData import org.smartregister.fhircore.quest.integration.Faker +import org.smartregister.fhircore.quest.ui.main.appMainUiStateOf import org.smartregister.fhircore.quest.ui.register.FAB_BUTTON_REGISTER_TEST_TAG import org.smartregister.fhircore.quest.ui.register.FIRST_TIME_SYNC_DIALOG import org.smartregister.fhircore.quest.ui.register.NO_REGISTER_VIEW_COLUMN_TEST_TAG import org.smartregister.fhircore.quest.ui.register.NoRegisterDataView import org.smartregister.fhircore.quest.ui.register.REGISTER_CARD_TEST_TAG import org.smartregister.fhircore.quest.ui.register.RegisterScreen +import org.smartregister.fhircore.quest.ui.register.RegisterUiCountState import org.smartregister.fhircore.quest.ui.register.RegisterUiState import org.smartregister.fhircore.quest.ui.register.TOP_REGISTER_SCREEN_TEST_TAG +import org.smartregister.fhircore.quest.ui.shared.components.SYNC_PROGRESS_INDICATOR_TEST_TAG +import org.smartregister.fhircore.quest.ui.shared.models.AppDrawerUIState +import org.smartregister.fhircore.quest.ui.shared.models.SearchQuery @HiltAndroidTest class RegisterScreenTest { @@ -57,6 +81,43 @@ class RegisterScreenTest { private val noResults = NoResultsConfig() + private val applicationContext = ApplicationProvider.getApplicationContext() + + private val navigationConfiguration = + NavigationConfiguration( + appId = "appId", + configType = ConfigType.Navigation.name, + staticMenu = listOf(), + clientRegisters = + listOf( + NavigationMenuConfig(id = "id3", visible = true, display = "Register 1"), + NavigationMenuConfig(id = "id4", visible = false, display = "Register 2"), + ), + bottomSheetRegisters = + NavigationBottomSheetRegisterConfig( + visible = true, + display = "My Register", + registers = + listOf(NavigationMenuConfig(id = "id2", visible = true, display = "Title My Register")), + ), + menuActionButton = + NavigationMenuConfig(id = "id1", visible = true, display = "Register Household"), + ) + + private val appUiState = + appMainUiStateOf( + appTitle = "MOH VTS", + username = "Demo", + lastSyncTime = "05:30 PM, Mar 3", + currentLanguage = "English", + languages = listOf(Language("en", "English"), Language("sw", "Swahili")), + navigationConfiguration = + navigationConfiguration.copy( + bottomSheetRegisters = + navigationConfiguration.bottomSheetRegisters?.copy(display = "Random name"), + ), + ) + @Test fun testFloatingActionButtonIsDisplayed() { val configurationRegistry: ConfigurationRegistry = Faker.buildTestConfigurationRegistry() @@ -67,14 +128,11 @@ class RegisterScreenTest { registerConfiguration = configurationRegistry.retrieveConfiguration(ConfigType.Register, "householdRegister"), registerId = "register101", - totalRecordsCount = 1, - filteredRecordsCount = 0, - pagesCount = 0, progressPercentage = flowOf(0), isSyncUpload = flowOf(false), - params = emptyMap(), + params = emptyList(), ) - val searchText = mutableStateOf("") + val searchText = mutableStateOf(SearchQuery.emptyText) val currentPage = mutableStateOf(0) composeTestRule.setContent { @@ -87,16 +145,92 @@ class RegisterScreenTest { openDrawer = {}, onEvent = {}, registerUiState = registerUiState, - searchText = searchText, + registerUiCountState = + RegisterUiCountState( + totalRecordsCount = 1, + filteredRecordsCount = 0, + pagesCount = 0, + ), + onAppMainEvent = {}, + searchQuery = searchText, currentPage = currentPage, pagingItems = pagingItems, navController = rememberNavController(), + decodeImage = null, ) } composeTestRule.waitUntil(5_000) { true } composeTestRule.onAllNodesWithTag(FAB_BUTTON_REGISTER_TEST_TAG, useUnmergedTree = true) } + @Test + fun testRegisterScreenWithPlaceholderColor() { + val configurationRegistry: ConfigurationRegistry = Faker.buildTestConfigurationRegistry() + val registerUiState = + RegisterUiState( + screenTitle = "Register101", + isFirstTimeSync = false, + registerConfiguration = + configurationRegistry + .retrieveConfiguration(ConfigType.Register, "householdRegister") + .copy( + searchBar = + RegisterContentConfig( + visible = true, + display = "Search", + placeholderColor = "#FF0000", + ), + ), + registerId = "register101", + progressPercentage = flowOf(0), + isSyncUpload = flowOf(false), + params = emptyList(), + ) + val searchText = mutableStateOf(SearchQuery.emptyText) + val currentPage = mutableStateOf(0) + + composeTestRule.setContent { + val data = listOf(ResourceData("1", ResourceType.Patient, emptyMap())) + val pagingItems = flowOf(PagingData.from(data)).collectAsLazyPagingItems() + + RegisterScreen( + modifier = Modifier, + openDrawer = {}, + onEvent = {}, + registerUiState = registerUiState, + onAppMainEvent = {}, + searchQuery = searchText, + currentPage = currentPage, + pagingItems = pagingItems, + navController = rememberNavController(), + decodeImage = null, + registerUiCountState = + RegisterUiCountState( + totalRecordsCount = 1, + filteredRecordsCount = 0, + pagesCount = 0, + ), + ) + } + + // Verify that all nodes with the TOP_REGISTER_SCREEN_TEST_TAG exist + composeTestRule + .onAllNodesWithTag(TOP_REGISTER_SCREEN_TEST_TAG, useUnmergedTree = true) + .assertCountEquals(7) + + // Verify that the search text exists with correct placeholder + composeTestRule + .onNodeWithText("Search", useUnmergedTree = true) + .assertExists() + .assertIsDisplayed() + + // Verify that the screen title is displayed + composeTestRule + .onNodeWithText("Register101", useUnmergedTree = true) + .assertExists() + .assertIsDisplayed() + } + @Test fun testRegisterCardListIsRendered() { val configurationRegistry: ConfigurationRegistry = Faker.buildTestConfigurationRegistry() @@ -107,14 +241,11 @@ class RegisterScreenTest { registerConfiguration = configurationRegistry.retrieveConfiguration(ConfigType.Register, "householdRegister"), registerId = "register101", - totalRecordsCount = 1, - filteredRecordsCount = 0, - pagesCount = 1, progressPercentage = flowOf(0), isSyncUpload = flowOf(false), - params = emptyMap(), + params = emptyList(), ) - val searchText = mutableStateOf("") + val searchText = mutableStateOf(SearchQuery.emptyText) val currentPage = mutableStateOf(0) composeTestRule.setContent { @@ -127,10 +258,18 @@ class RegisterScreenTest { openDrawer = {}, onEvent = {}, registerUiState = registerUiState, - searchText = searchText, + registerUiCountState = + RegisterUiCountState( + totalRecordsCount = 1, + filteredRecordsCount = 0, + pagesCount = 1, + ), + onAppMainEvent = {}, + searchQuery = searchText, currentPage = currentPage, pagingItems = pagingItems, navController = rememberNavController(), + decodeImage = null, ) } @@ -150,14 +289,18 @@ class RegisterScreenTest { registerConfiguration = configurationRegistry.retrieveConfiguration(ConfigType.Register, "householdRegister"), registerId = "register101", + progressPercentage = flowOf(0), + isSyncUpload = flowOf(false), + params = emptyList(), + ) + + val registerUiCountState = + RegisterUiCountState( totalRecordsCount = 1, filteredRecordsCount = 0, pagesCount = 1, - progressPercentage = flowOf(0), - isSyncUpload = flowOf(false), - params = emptyMap(), ) - val searchText = mutableStateOf("") + val searchText = mutableStateOf(SearchQuery.emptyText) val currentPage = mutableStateOf(0) composeTestRule.setContent { @@ -170,10 +313,13 @@ class RegisterScreenTest { openDrawer = {}, onEvent = {}, registerUiState = registerUiState, - searchText = searchText, + registerUiCountState = registerUiCountState, + onAppMainEvent = {}, + searchQuery = searchText, currentPage = currentPage, pagingItems = pagingItems, navController = rememberNavController(), + decodeImage = null, ) } @@ -193,16 +339,21 @@ class RegisterScreenTest { registerConfiguration = configurationRegistry.retrieveConfiguration(ConfigType.Register, "childRegister"), registerId = "register101", - totalRecordsCount = 0, - filteredRecordsCount = 0, - pagesCount = 1, progressPercentage = flowOf(0), isSyncUpload = flowOf(false), - params = emptyMap(), + params = emptyList(), ) - val searchText = mutableStateOf("") + val searchText = mutableStateOf(SearchQuery.emptyText) val currentPage = mutableStateOf(0) val pagingItems = mockk>().apply {} + val combinedLoadState: CombinedLoadStates = mockk() + val loadState: LoadState = mockk() + + every { pagingItems.itemCount } returns 0 + every { combinedLoadState.refresh } returns loadState + every { combinedLoadState.append } returns loadState + every { loadState.endOfPaginationReached } returns true + every { pagingItems.loadState } returns combinedLoadState composeTestRule.setContent { RegisterScreen( @@ -210,10 +361,18 @@ class RegisterScreenTest { openDrawer = {}, onEvent = {}, registerUiState = registerUiState, - searchText = searchText, + registerUiCountState = + RegisterUiCountState( + totalRecordsCount = 0, + filteredRecordsCount = 0, + pagesCount = 1, + ), + onAppMainEvent = {}, + searchQuery = searchText, currentPage = currentPage, pagingItems = pagingItems, navController = rememberNavController(), + decodeImage = null, ) } composeTestRule.onNodeWithTag(FIRST_TIME_SYNC_DIALOG, useUnmergedTree = true) @@ -229,14 +388,11 @@ class RegisterScreenTest { registerConfiguration = configurationRegistry.retrieveConfiguration(ConfigType.Register, "householdRegister"), registerId = "register101", - totalRecordsCount = 1, - filteredRecordsCount = 0, - pagesCount = 0, progressPercentage = flowOf(0), isSyncUpload = flowOf(false), - params = emptyMap(), + params = emptyList(), ) - val searchText = mutableStateOf("") + val searchText = mutableStateOf(SearchQuery.emptyText) val currentPage = mutableStateOf(0) composeTestRule.setContent { @@ -249,16 +405,74 @@ class RegisterScreenTest { openDrawer = {}, onEvent = {}, registerUiState = registerUiState, - searchText = searchText, + registerUiCountState = + RegisterUiCountState( + totalRecordsCount = 1, + filteredRecordsCount = 0, + pagesCount = 0, + ), + onAppMainEvent = {}, + searchQuery = searchText, currentPage = currentPage, pagingItems = pagingItems, navController = rememberNavController(), + decodeImage = null, ) } composeTestRule.waitUntil(5_000) { true } composeTestRule.onNodeWithTag(TOP_REGISTER_SCREEN_TEST_TAG, useUnmergedTree = true) } + @Test + fun testThatTopScreenRenderShowsQrCode() { + val configurationRegistry: ConfigurationRegistry = Faker.buildTestConfigurationRegistry() + val registerUiState = + RegisterUiState( + screenTitle = "Register101", + isFirstTimeSync = false, + registerConfiguration = + configurationRegistry + .retrieveConfiguration(ConfigType.Register, "householdRegister") + .copy( + onSearchByQrSingleResultActions = + listOf(ActionConfig(trigger = ActionTrigger.ON_SEARCH_SINGLE_RESULT)), + ), + registerId = "register101", + progressPercentage = flowOf(0), + isSyncUpload = flowOf(false), + params = emptyList(), + ) + val searchText = mutableStateOf(SearchQuery.emptyText) + val currentPage = mutableStateOf(0) + + composeTestRule.setContent { + val data = listOf(ResourceData("1", ResourceType.Patient, emptyMap())) + + val pagingItems = flowOf(PagingData.from(data)).collectAsLazyPagingItems() + + RegisterScreen( + modifier = Modifier, + openDrawer = {}, + onEvent = {}, + registerUiState = registerUiState, + registerUiCountState = + RegisterUiCountState( + totalRecordsCount = 1, + filteredRecordsCount = 0, + pagesCount = 0, + ), + onAppMainEvent = {}, + searchQuery = searchText, + currentPage = currentPage, + pagingItems = pagingItems, + navController = rememberNavController(), + decodeImage = null, + ) + } + + Assert.assertEquals(true, registerUiState.registerConfiguration?.showSearchByQrCode) + } + @Test fun testNoRegisterDataViewDisplaysNoTestTag() { composeTestRule.setContent { @@ -294,4 +508,177 @@ class RegisterScreenTest { .onChildAt(1) .assertExists() } + + @Test + fun testSyncInProgress() { + val configurationRegistry: ConfigurationRegistry = Faker.buildTestConfigurationRegistry() + val registerUiState = + RegisterUiState( + screenTitle = "Register101", + isFirstTimeSync = false, + registerConfiguration = + configurationRegistry.retrieveConfiguration(ConfigType.Register, "householdRegister"), + registerId = "register101", + progressPercentage = flowOf(50), + isSyncUpload = flowOf(true), + currentSyncJobStatus = + flowOf( + CurrentSyncJobStatus.Running( + SyncJobStatus.InProgress( + syncOperation = SyncOperation.UPLOAD, + ), + ), + ), + params = emptyList(), + ) + val searchText = mutableStateOf(SearchQuery.emptyText) + val currentPage = mutableStateOf(0) + + composeTestRule.setContent { + val data = listOf(ResourceData("1", ResourceType.Patient, emptyMap())) + val pagingItems = flowOf(PagingData.from(data)).collectAsLazyPagingItems() + RegisterScreen( + modifier = Modifier, + openDrawer = {}, + onEvent = {}, + registerUiState = registerUiState, + registerUiCountState = + RegisterUiCountState( + totalRecordsCount = 1, + filteredRecordsCount = 0, + pagesCount = 0, + ), + appDrawerUIState = + AppDrawerUIState( + currentSyncJobStatus = + CurrentSyncJobStatus.Running( + SyncJobStatus.InProgress( + syncOperation = SyncOperation.UPLOAD, + ), + ), + ), + onAppMainEvent = {}, + searchQuery = searchText, + currentPage = currentPage, + pagingItems = pagingItems, + navController = rememberNavController(), + decodeImage = null, + ) + } + + composeTestRule + .onNodeWithTag(SYNC_PROGRESS_INDICATOR_TEST_TAG, useUnmergedTree = true) + .assertExists() + .assertIsDisplayed() + } + + @Test + fun testSyncStatusShowsWhenSucceeded() { + val configurationRegistry: ConfigurationRegistry = Faker.buildTestConfigurationRegistry() + val registerUiState = + RegisterUiState( + screenTitle = "Register101", + isFirstTimeSync = false, + registerConfiguration = + configurationRegistry.retrieveConfiguration(ConfigType.Register, "householdRegister"), + registerId = "register101", + progressPercentage = flowOf(100), + isSyncUpload = flowOf(false), + currentSyncJobStatus = flowOf(CurrentSyncJobStatus.Succeeded(OffsetDateTime.now())), + params = emptyList(), + ) + val searchText = mutableStateOf(SearchQuery.emptyText) + val currentPage = mutableStateOf(0) + + composeTestRule.setContent { + val data = listOf(ResourceData("1", ResourceType.Patient, emptyMap())) + val pagingItems = flowOf(PagingData.from(data)).collectAsLazyPagingItems() + + RegisterScreen( + modifier = Modifier, + openDrawer = {}, + onEvent = {}, + registerUiState = registerUiState, + registerUiCountState = + RegisterUiCountState( + totalRecordsCount = 1, + filteredRecordsCount = 0, + pagesCount = 0, + ), + appDrawerUIState = + AppDrawerUIState( + currentSyncJobStatus = CurrentSyncJobStatus.Succeeded(OffsetDateTime.now()), + ), + onAppMainEvent = {}, + searchQuery = searchText, + currentPage = currentPage, + pagingItems = pagingItems, + navController = rememberNavController(), + decodeImage = null, + ) + } + + composeTestRule + .onNodeWithText( + applicationContext.getString(org.smartregister.fhircore.engine.R.string.sync_complete), + useUnmergedTree = true, + ) + .assertExists() + .assertIsDisplayed() + } + + @Test + fun testSyncStatusShowsWhenFailed() { + val configurationRegistry: ConfigurationRegistry = Faker.buildTestConfigurationRegistry() + val registerUiState = + RegisterUiState( + screenTitle = "Register101", + isFirstTimeSync = false, + registerConfiguration = + configurationRegistry.retrieveConfiguration(ConfigType.Register, "householdRegister"), + registerId = "register101", + progressPercentage = flowOf(100), + isSyncUpload = flowOf(false), + currentSyncJobStatus = flowOf(CurrentSyncJobStatus.Succeeded(OffsetDateTime.now())), + params = emptyList(), + ) + val searchText = mutableStateOf(SearchQuery.emptyText) + val currentPage = mutableStateOf(0) + + composeTestRule.setContent { + val data = listOf(ResourceData("1", ResourceType.Patient, emptyMap())) + val pagingItems = flowOf(PagingData.from(data)).collectAsLazyPagingItems() + + RegisterScreen( + modifier = Modifier, + openDrawer = {}, + onEvent = {}, + registerUiState = registerUiState, + registerUiCountState = + RegisterUiCountState( + totalRecordsCount = 1, + filteredRecordsCount = 0, + pagesCount = 0, + ), + appDrawerUIState = + AppDrawerUIState( + currentSyncJobStatus = CurrentSyncJobStatus.Failed(OffsetDateTime.now()), + ), + onAppMainEvent = {}, + searchQuery = searchText, + currentPage = currentPage, + pagingItems = pagingItems, + navController = rememberNavController(), + decodeImage = null, + ) + } + + composeTestRule + .onNodeWithText( + applicationContext.getString(org.smartregister.fhircore.engine.R.string.sync_error), + useUnmergedTree = true, + ) + .assertExists() + .assertIsDisplayed() + } } diff --git a/android/quest/src/androidTest/java/org/smartregister/fhircore/quest/integration/ui/register/components/RegisterCardListTest.kt b/android/quest/src/androidTest/java/org/smartregister/fhircore/quest/integration/ui/register/components/RegisterCardListTest.kt index 42cab5ea823..65531bc9548 100644 --- a/android/quest/src/androidTest/java/org/smartregister/fhircore/quest/integration/ui/register/components/RegisterCardListTest.kt +++ b/android/quest/src/androidTest/java/org/smartregister/fhircore/quest/integration/ui/register/components/RegisterCardListTest.kt @@ -21,7 +21,6 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.test.assert import androidx.compose.ui.test.assertCountEquals -import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.hasText import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onChildren @@ -38,6 +37,7 @@ import org.junit.Test import org.smartregister.fhircore.engine.configuration.register.RegisterCardConfig import org.smartregister.fhircore.engine.configuration.view.CompoundTextProperties import org.smartregister.fhircore.engine.domain.model.ResourceData +import org.smartregister.fhircore.quest.ui.register.RegisterUiCountState import org.smartregister.fhircore.quest.ui.register.RegisterUiState import org.smartregister.fhircore.quest.ui.register.components.REGISTER_CARD_LIST_TEST_TAG import org.smartregister.fhircore.quest.ui.register.components.RegisterCardList @@ -59,7 +59,10 @@ class RegisterCardListTest { lazyListState = rememberLazyListState(), onEvent = {}, registerUiState = RegisterUiState(), + registerUiCountState = RegisterUiCountState(), currentPage = mutableStateOf(1), + onSearchByQrSingleResultAction = {}, + decodeImage = null, ) } @@ -83,18 +86,20 @@ class RegisterCardListTest { lazyListState = rememberLazyListState(), onEvent = {}, registerUiState = RegisterUiState(), + registerUiCountState = RegisterUiCountState(), currentPage = mutableStateOf(1), + onSearchByQrSingleResultAction = {}, + decodeImage = null, ) } - composeTestRule.onNodeWithTag(REGISTER_CARD_LIST_TEST_TAG).onChildren().assertCountEquals(3) + composeTestRule.onNodeWithTag(REGISTER_CARD_LIST_TEST_TAG).onChildren().assertCountEquals(2) composeTestRule .onNodeWithTag(REGISTER_CARD_LIST_TEST_TAG) .onChildren() .onFirst() .assert(hasText("Patient 1")) - .assertIsDisplayed() } @Test @@ -114,12 +119,15 @@ class RegisterCardListTest { lazyListState = rememberLazyListState(), onEvent = {}, registerUiState = RegisterUiState(), + registerUiCountState = RegisterUiCountState(), currentPage = mutableStateOf(1), showPagination = true, + onSearchByQrSingleResultAction = {}, + decodeImage = null, ) } - composeTestRule.onNodeWithTag(REGISTER_CARD_LIST_TEST_TAG).onChildren().assertCountEquals(4) + composeTestRule.onNodeWithTag(REGISTER_CARD_LIST_TEST_TAG).onChildren().assertCountEquals(3) composeTestRule .onNodeWithTag(REGISTER_CARD_LIST_TEST_TAG) @@ -140,7 +148,10 @@ class RegisterCardListTest { lazyListState = rememberLazyListState(), onEvent = {}, registerUiState = RegisterUiState(), + registerUiCountState = RegisterUiCountState(), currentPage = mutableStateOf(1), + onSearchByQrSingleResultAction = {}, + decodeImage = null, ) } diff --git a/android/quest/src/androidTest/java/org/smartregister/fhircore/quest/integration/ui/shared/components/ActionableButtonTest.kt b/android/quest/src/androidTest/java/org/smartregister/fhircore/quest/integration/ui/shared/components/ActionableButtonTest.kt index 8a84deab04d..33995a38288 100644 --- a/android/quest/src/androidTest/java/org/smartregister/fhircore/quest/integration/ui/shared/components/ActionableButtonTest.kt +++ b/android/quest/src/androidTest/java/org/smartregister/fhircore/quest/integration/ui/shared/components/ActionableButtonTest.kt @@ -123,6 +123,7 @@ class ActionableButtonTest { ), resourceData = ResourceData("id", ResourceType.Patient, computedValuesMap), navController = TestNavHostController(LocalContext.current), + decodeImage = null, ) } } diff --git a/android/quest/src/androidTest/java/org/smartregister/fhircore/quest/integration/ui/shared/components/CardViewTest.kt b/android/quest/src/androidTest/java/org/smartregister/fhircore/quest/integration/ui/shared/components/CardViewTest.kt index e7eca61d45f..33ebed83fe7 100644 --- a/android/quest/src/androidTest/java/org/smartregister/fhircore/quest/integration/ui/shared/components/CardViewTest.kt +++ b/android/quest/src/androidTest/java/org/smartregister/fhircore/quest/integration/ui/shared/components/CardViewTest.kt @@ -44,6 +44,7 @@ class CardViewTest { viewProperties = viewProperties, resourceData = resourceData, navController = TestNavHostController(LocalContext.current), + decodeImage = null, ) } composeTestRule.onNodeWithText("IMMUNIZATIONS").assertIsDisplayed() diff --git a/android/quest/src/androidTest/java/org/smartregister/fhircore/quest/integration/ui/shared/components/CompoundTextTest.kt b/android/quest/src/androidTest/java/org/smartregister/fhircore/quest/integration/ui/shared/components/CompoundTextTest.kt index c3bf43c61aa..9ca6820e8d5 100644 --- a/android/quest/src/androidTest/java/org/smartregister/fhircore/quest/integration/ui/shared/components/CompoundTextTest.kt +++ b/android/quest/src/androidTest/java/org/smartregister/fhircore/quest/integration/ui/shared/components/CompoundTextTest.kt @@ -45,9 +45,12 @@ class CompoundTextTest { primaryTextColor = "#000000", primaryTextFontWeight = TextFontWeight.SEMI_BOLD, padding = 16, + primaryTextBackgroundColor = "#FFA500", + textInnerPadding = 4, ), resourceData = ResourceData("id", ResourceType.Patient, emptyMap(), emptyMap()), navController = TestNavHostController(LocalContext.current), + decodeImage = null, ) } composeTestRule.onNodeWithText("Full Name, Age").assertExists().assertIsDisplayed() @@ -66,9 +69,11 @@ class CompoundTextTest { separator = ".", secondaryTextBackgroundColor = "#FFA500", fontSize = 18.0f, + textInnerPadding = 4, ), resourceData = ResourceData("id", ResourceType.Patient, emptyMap(), emptyMap()), navController = TestNavHostController(LocalContext.current), + decodeImage = null, ) } composeTestRule.onNodeWithText("Yesterday").assertExists().assertIsDisplayed() diff --git a/android/quest/src/androidTest/java/org/smartregister/fhircore/quest/integration/ui/shared/components/ExtendedFabTest.kt b/android/quest/src/androidTest/java/org/smartregister/fhircore/quest/integration/ui/shared/components/ExtendedFabTest.kt index d850e2e8c76..ff7e86c534e 100644 --- a/android/quest/src/androidTest/java/org/smartregister/fhircore/quest/integration/ui/shared/components/ExtendedFabTest.kt +++ b/android/quest/src/androidTest/java/org/smartregister/fhircore/quest/integration/ui/shared/components/ExtendedFabTest.kt @@ -63,6 +63,7 @@ class ExtendedFabTest { resourceData = ResourceData("id", ResourceType.Patient, emptyMap()), navController = TestNavHostController(LocalContext.current), lazyListState = null, + decodeImage = null, ) } } @@ -90,6 +91,7 @@ class ExtendedFabTest { resourceData = null, navController = TestNavHostController(LocalContext.current), lazyListState = null, + decodeImage = null, ) } composeRule @@ -127,8 +129,8 @@ class ExtendedFabTest { @Test fun testFloatingButtonWhenAnimateIsFalse() { + composeRule.mainClock.autoAdvance = false composeRule.setContent { - composeRule.mainClock.autoAdvance = false ExtendedFab( fabActions = listOf( @@ -150,6 +152,7 @@ class ExtendedFabTest { resourceData = ResourceData("id", ResourceType.Patient, emptyMap()), navController = TestNavHostController(LocalContext.current), lazyListState = null, + decodeImage = null, ) } composeRule.run { @@ -165,8 +168,8 @@ class ExtendedFabTest { @Test fun testFloatingButtonWhenAnimateIsTrue() { + composeRule.mainClock.autoAdvance = false composeRule.setContent { - composeRule.mainClock.autoAdvance = false ExtendedFab( fabActions = listOf( @@ -188,6 +191,7 @@ class ExtendedFabTest { resourceData = ResourceData("id", ResourceType.Patient, emptyMap()), navController = TestNavHostController(LocalContext.current), lazyListState = null, + decodeImage = null, ) } composeRule.run { diff --git a/android/quest/src/androidTest/java/org/smartregister/fhircore/quest/integration/ui/shared/components/ServiceCardTest.kt b/android/quest/src/androidTest/java/org/smartregister/fhircore/quest/integration/ui/shared/components/ServiceCardTest.kt index 32bcd09ae96..ffa1f77c189 100644 --- a/android/quest/src/androidTest/java/org/smartregister/fhircore/quest/integration/ui/shared/components/ServiceCardTest.kt +++ b/android/quest/src/androidTest/java/org/smartregister/fhircore/quest/integration/ui/shared/components/ServiceCardTest.kt @@ -46,6 +46,7 @@ class ServiceCardTest { serviceCardProperties = initTestServiceCardProperties(), resourceData = resourceData, navController = TestNavHostController(LocalContext.current), + decodeImage = null, ) } composeRule @@ -62,6 +63,7 @@ class ServiceCardTest { initTestServiceCardProperties(serviceStatus = ServiceStatus.OVERDUE.name, text = "1"), resourceData = resourceData, navController = TestNavHostController(LocalContext.current), + decodeImage = null, ) } composeRule.onNodeWithText("1", useUnmergedTree = true).assertExists().assertIsDisplayed() @@ -74,6 +76,7 @@ class ServiceCardTest { serviceCardProperties = initTestServiceCardProperties(visible = "false"), resourceData = resourceData, navController = TestNavHostController(LocalContext.current), + decodeImage = null, ) } composeRule.onNodeWithText("Next visit 09-10-2022", useUnmergedTree = true).assertDoesNotExist() @@ -86,6 +89,7 @@ class ServiceCardTest { serviceCardProperties = initTestServiceCardProperties(showVerticalDivider = false), resourceData = resourceData, navController = TestNavHostController(LocalContext.current), + decodeImage = null, ) } composeRule.onNodeWithTag(DIVIDER_TEST_TAG).assertDoesNotExist() diff --git a/android/quest/src/androidTest/java/org/smartregister/fhircore/quest/integration/ui/shared/components/StackViewTest.kt b/android/quest/src/androidTest/java/org/smartregister/fhircore/quest/integration/ui/shared/components/StackViewTest.kt index d203e9f434b..13e40fc3f4f 100644 --- a/android/quest/src/androidTest/java/org/smartregister/fhircore/quest/integration/ui/shared/components/StackViewTest.kt +++ b/android/quest/src/androidTest/java/org/smartregister/fhircore/quest/integration/ui/shared/components/StackViewTest.kt @@ -41,6 +41,7 @@ class StackViewTest { stackViewProperties = stackViewProperties, resourceData = ResourceData("", ResourceType.Patient, emptyMap()), navController = rememberNavController(), + decodeImage = null, ) } composeTestRule.onNodeWithTag(STACK_VIEW_TEST_TAG).assertExists() diff --git a/android/quest/src/androidTest/java/org/smartregister/fhircore/quest/integration/ui/shared/components/ViewGeneratorTest.kt b/android/quest/src/androidTest/java/org/smartregister/fhircore/quest/integration/ui/shared/components/ViewGeneratorTest.kt index d33cc7b89ac..60b73e7995f 100644 --- a/android/quest/src/androidTest/java/org/smartregister/fhircore/quest/integration/ui/shared/components/ViewGeneratorTest.kt +++ b/android/quest/src/androidTest/java/org/smartregister/fhircore/quest/integration/ui/shared/components/ViewGeneratorTest.kt @@ -17,6 +17,7 @@ package org.smartregister.fhircore.quest.integration.ui.shared.components import android.graphics.Bitmap +import androidx.compose.runtime.mutableStateMapOf import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.junit4.createComposeRule @@ -88,6 +89,7 @@ class ViewGeneratorTest { ), resourceData = resourceData, navController = TestNavHostController(LocalContext.current), + decodeImage = null, ) } composeRule.onNodeWithText("Upcoming household service").assertExists().assertIsDisplayed() @@ -113,6 +115,7 @@ class ViewGeneratorTest { ), resourceData = ResourceData("id", ResourceType.Patient, emptyMap()), navController = TestNavHostController(LocalContext.current), + decodeImage = null, ) } composeRule @@ -139,6 +142,7 @@ class ViewGeneratorTest { ), resourceData = resourceData, navController = TestNavHostController(LocalContext.current), + decodeImage = null, ) } composeRule @@ -171,6 +175,7 @@ class ViewGeneratorTest { ), resourceData = resourceData, navController = TestNavHostController(LocalContext.current), + decodeImage = null, ) } composeRule @@ -203,6 +208,7 @@ class ViewGeneratorTest { ), resourceData = resourceData, navController = TestNavHostController(LocalContext.current), + decodeImage = null, ) } composeRule.onNodeWithTag(COLUMN_DIVIDER_TEST_TAG).assertExists() @@ -256,6 +262,7 @@ class ViewGeneratorTest { ), resourceData = resourceData, navController = TestNavHostController(LocalContext.current), + decodeImage = null, ) } composeRule.onNodeWithText("Richard Brown, M, 29", useUnmergedTree = true).assertDoesNotExist() @@ -283,6 +290,7 @@ class ViewGeneratorTest { ), resourceData = resourceData, navController = TestNavHostController(LocalContext.current), + decodeImage = null, ) } composeRule @@ -315,6 +323,7 @@ class ViewGeneratorTest { ), resourceData = resourceData, navController = TestNavHostController(LocalContext.current), + decodeImage = null, ) } composeRule @@ -341,6 +350,7 @@ class ViewGeneratorTest { ), resourceData = resourceData, navController = TestNavHostController(LocalContext.current), + decodeImage = null, ) } composeRule.onNodeWithText("Sex").assertIsDisplayed() @@ -358,6 +368,7 @@ class ViewGeneratorTest { ), resourceData = resourceData, navController = TestNavHostController(LocalContext.current), + decodeImage = null, ) } composeRule @@ -369,17 +380,20 @@ class ViewGeneratorTest { @Test fun testImageIsRenderedFromDecodedBitmap() { composeRule.setContent { + val decodedImageMap = mutableStateMapOf() + decodedImageMap["testImageReference"] = Bitmap.createBitmap(100, 16, Bitmap.Config.ARGB_8888) GenerateView( properties = ImageProperties( imageConfig = ImageConfig( ICON_TYPE_REMOTE, - decodedBitmap = Bitmap.createBitmap(100, 16, Bitmap.Config.ARGB_8888), + reference = "testImageReference", ), ), resourceData = resourceData, navController = TestNavHostController(LocalContext.current), + decodeImage = { reference -> decodedImageMap[reference] }, ) } composeRule @@ -406,6 +420,7 @@ class ViewGeneratorTest { ), resourceData = resourceData, navController = TestNavHostController(LocalContext.current), + decodeImage = null, ) } composeRule diff --git a/android/quest/src/androidTest/java/org/smartregister/fhircore/quest/integration/ui/usersetting/UserSettingInsightScreenTest.kt b/android/quest/src/androidTest/java/org/smartregister/fhircore/quest/integration/ui/usersetting/UserSettingInsightScreenTest.kt index 7e639d58da4..b476d3c0d7d 100644 --- a/android/quest/src/androidTest/java/org/smartregister/fhircore/quest/integration/ui/usersetting/UserSettingInsightScreenTest.kt +++ b/android/quest/src/androidTest/java/org/smartregister/fhircore/quest/integration/ui/usersetting/UserSettingInsightScreenTest.kt @@ -29,6 +29,7 @@ import org.junit.After import org.junit.Before import org.junit.Rule import org.junit.Test +import org.smartregister.fhircore.engine.util.extension.DEFAULT_FORMAT_SDF_DD_MM_YYYY import org.smartregister.fhircore.quest.ui.usersetting.INSIGHT_UNSYNCED_DATA import org.smartregister.fhircore.quest.ui.usersetting.UserSettingInsightScreen @@ -118,6 +119,7 @@ class UserSettingInsightScreenTest { unsyncedResourcesFlow = unsyncedResourcesFlow, navController = rememberNavController(), onRefreshRequest = {}, + dateFormat = DEFAULT_FORMAT_SDF_DD_MM_YYYY, ) } this.activity = activity diff --git a/android/quest/src/androidTest/java/org/smartregister/fhircore/quest/integration/ui/usersetting/UserSettingScreenTest.kt b/android/quest/src/androidTest/java/org/smartregister/fhircore/quest/integration/ui/usersetting/UserSettingScreenTest.kt index 17b7933bd87..e4152e75ab7 100644 --- a/android/quest/src/androidTest/java/org/smartregister/fhircore/quest/integration/ui/usersetting/UserSettingScreenTest.kt +++ b/android/quest/src/androidTest/java/org/smartregister/fhircore/quest/integration/ui/usersetting/UserSettingScreenTest.kt @@ -199,6 +199,7 @@ class UserSettingScreenTest { isDebugVariant = isDebugVariant, onEvent = {}, mainNavController = rememberNavController(), + dataMigrationVersion = "0", lastSyncTime = "05:30 PM, Mar 3", showProgressIndicatorFlow = MutableStateFlow(false), enableManualSync = showManualSync, diff --git a/android/quest/src/vamosJuntos/res/drawable/ic_app_logo.png b/android/quest/src/contigo/res/drawable/ic_app_logo.png similarity index 100% rename from android/quest/src/vamosJuntos/res/drawable/ic_app_logo.png rename to android/quest/src/contigo/res/drawable/ic_app_logo.png diff --git a/android/quest/src/contigo/res/drawable/ic_launcher.png b/android/quest/src/contigo/res/drawable/ic_launcher.png new file mode 100644 index 00000000000..70e8b104ad2 Binary files /dev/null and b/android/quest/src/contigo/res/drawable/ic_launcher.png differ diff --git a/android/quest/src/contigo/res/values-es/strings.xml b/android/quest/src/contigo/res/values-es/strings.xml new file mode 100644 index 00000000000..d3d3cc3bed4 --- /dev/null +++ b/android/quest/src/contigo/res/values-es/strings.xml @@ -0,0 +1,408 @@ + + + + Sincronización manual + Idioma + Cerrar sesión como + Mostrar vencido + Buscar nombre o ID + Buscar nombre + Sincronizar datos + ESCANEAR CÓDIGO DE BARRAS + Sin resultados + Lo sentimos, no pudimos encontrar el cliente con su nombre o ID + Registrar a un nuevo paciente + Aplicación Fhir + Logotipo de la aplicación + Desarrollado por + Versión de la aplicación %1$d(%2$s) + Versión de migración de datos %1$s + Última sincronización %1$s + INICIAR + No se pudieron verificar las credenciales del servidor. Comprueba tu conexión a Internet + No se pudo obtener información del usuario del servidor. Comprueba tu conexión a Internet + Seleccionar idioma + Cerrar sesión para %1$s + Sincronización completa + Sincronización + Sincronizando + Sincronizando + Sincronización iniciada… + Error de sincronización. Verifique la conexión a Internet o vuelva a intentarlo más tarde + Sincronización completada con errores. Reintentando… + La sincronización falló porque las credenciales de autenticación no son válidas. + No se pudieron recuperar las credenciales del servidor. Por favor verifique su conexión a Internet. + Inténtalo de nuevo + Vacunado + Atrasado + Vacuna %1$d \n%2$s + "Esquema de vacunación completo" + "No puedo recibir otra dosis. Ya estoy completamente vacunado" + Registro\nVacuna + Fecha de última visita %1$s + Fecha de última visita %1$s + Guardar + Error de inicio de sesión: %1$s + SIGUIENTE + ANTERIOR + Página %1$d de %2$d + %1$d RESULTADO(S) + Sin resultados + Lo sentimos, no podemos encontrar a la persona con el nombre o ID proporcionado + Hombre + Mujer + Otro + Desconocido + Sincronización fallida + Reintentar sincronización + Sincronización en curso + Cargando + Mensaje de error al cerrar sesión: %1$s + No se puede cerrar sesión: ya se ha cerrado sesión o el dispositivo está desconectado. + No se pudo cargar la configuración. Inténtalo de nuevo más tarde + No se pudo cargar la configuración. Por favor revisa tu conexión a Internet + Error al conectarse al servidor. Por favor contacte al administrador del sistema + Error al cargar el formulario + Error encontrado al no poder guardar el formulario + Procesando datos. Por favor espera + ¿Estás seguro de que quieres volver? + ¿Estás seguro de que deseas descartar las respuestas? + Descartar cambios + Descartar + Guardar borrador parcial + Cancelar + + Los detalles proporcionados tienen errores de validación. Resolver errores y enviar de nuevo + Validación fallida + Aceptar + Nombre de usuario + Contraseña + Olvidé mi contraseña + ¡Olvidé mi contraseña! + Más + Llame a su supervisor al %1$s + MARCAR NÚMERO + Registrarse + Visitas + Informes + Perfil + Configuración + Seleccionar registro + Marca + Clientes + Cerrar sesión + ID de aplicación + por ejemplo, ecbis, quest, cha + CARGAR CONFIGURACIONES + APLICACIÓN FHIRCORE + Recordar la aplicación + Listo + Reemplazar la foto + Editar + Error al cargar las configuraciones de la aplicación %1$s + eCBIS + el nombre de usuario o la contraseña no son válidos + ID de formulario adjunto no válido + Establecer PIN + CHA utilizará este PIN para iniciar sesión + Ingrese el PIN para %1$s + PIN incorrecto, inténtelo de nuevo + Iniciar sesión + ¿Olvidaste tu PIN? + Por favor, póngase en contacto con su supervisor. + % + Cerrar sesión. Por favor espera… + La sesión ha caducado y debes iniciar sesión nuevamente + Sexo + Edad + fecha de nacimiento + ID: %1$s + VER TODO + FORMULARIOS + ANTECEDENTES MÉDICOS + INMUNIZACIONES FALTANTES + TARJETA DE SERVICIO + Otros pacientes + Seleccionar ubicación + RESPUESTAS (%1$s) + Intenté iniciar sesión con un proveedor diferente + Por favor espera… + Restablecer datos + Transferir datos + ¡Restablecer base de datos! + Restablecer la base de datos borrará todos los registros de su dispositivo. Esta acción no se puede deshacer. + Restableciendo aplicación… + https://smartregister.org/care-team-tag-id + https://smartregister.org/location-tag-id + https://smartregister.org/organization-tag-id + https://smartregister.org/practitioner-tag-id + https://smartregister.org/ related-entity-location-tag-id + Ubicación de entidad relacionada + Equipo de profesionales en salud + Ubicación del personal de salud + Organización del personal + Personal de salud + Año + Mes + Semana(s) + Día(s) + Inicializando configuración … + por ejemplo, JohnDoe + %1$d%% + Algo salió mal… + Perspectivas + "Solicitar ayuda" + "Mapas sin conexión" + Descartar + Información del usuario + Información de la tarea + Información de la aplicación + Información del dispositivo + Actualizar + Recursos no sincronizados + Todos los recursos sincronizados + Estadísticas sincronizadas + Todos los datos sincronizados + Se requiere configuración de usuario. Habilite su conexión a Internet + Seleccionar mes + Usuario + Equipo + Localidad + Equipo(Organización del personal) + Equipo de profesionales en salud + Ubicación + Código de versión de la aplicación + Fecha de compilación + Fabricante + Versión de la aplicación + Versión del sistema operativo + Fecha + Dispositivo + OK + AÑADIR + Migración de datos iniciada desde la versión %1$d + Datos de la aplicación migrados a la versión %1$d + Sin información + archivo + + + Volver a la lista de los pacientes + Resultado de la prueba del sistemaa + + Editar información del paciente + + Pacientes + Configuración + ¡No se puede encontrar el cuestionario principal! + + Cargando formularios... + Cargando respuestas... + No se encontraron resultados de la prueba + RESULTADOS DE LA PRUEBA + Última prueba: %1$s + 2,4 km + # + Tareas + Familia + Visita de rutina + Visita prenatal + En riesgo + Sin tareas + AGREGAR MIEMBRO + ¿Qué desea hacer? + + + Ver perfil + Información de la familia + Cambiar jefe de familia + Cambiar cuidador principal + Actividad de la familia + Ver encuentros pasados + Eliminar registro de la familia + Familia %1$s + Eliminar esta persona + Información individual + Ver familia + Registrar a un niño enfermo + + + Sincronización de dispositivo a dispositivo + Pacientes de consulta prenatal + Todos los pacientes + Pacientes de consulta postnatal + Lista de pacientes + Rastreo telefónico + Rastreo de domicilio + Citas + Pacientes de planificación familiar + Familias + Niños/Niñas + + + Si eliminas a %1$s, se eliminará todo su historial médico de tu dispositivo. Esta acción no se puede deshacer. + + + %1$s se eliminará de forma permanente de esta familia. Esta acción no se puede deshacer. + Eliminar paciente + + + Rango de fechas + Cambiar + Todos + Individual + Paciente + Asunto + GENERAR INFORME + SELECCIONAR PACIENTE + SELECCIONAR ASUNTO + Fecha de inicio + Fecha de finalización + + + No se encontraron miembros de familia elegibles para el jefe de familia. + Seleccionar un nuevo jefe de familia + Asignar nuevo jefe de familia + ¿Está seguro de que desea cancelar esta operación? + + + Si abandona el sitio antes de guardar, se perderán sus modificaciones o cambios + %1$s vencen el %2$s + %1$s vencen hoy + %1$s vencidos hace %2$s + Registrar como consulta prenatal + Resultado del embarazo + Registros + Sin ubicación establecida + Establecer ubicación para sincronizar datos y cargar puntos de servicio + Establecer ubicación + + %1$s (%2$s) + Vence el %1$s + + Destino del fragmento de GeoWidget + + No se pudieron extraer los recursos para %1$s + Falta StructureMap para Questionnaire, QuestionnaireResponse guardado + Se extrajeron correctamente los recursos para %1$s + Entidad administradora reasignada correctamente + No se pudieron obtener los detalles del usuario + No se encontró el cuestionario. Sincronizar todos los cuestionarios para solucionarlo + No hay visitas + Respuesta al cuestionario no válida + https://smartregister.org/app-version + Versión de la aplicación + Falta el tipo de tema en el cuestionario. Proporcione Questionnaire.subjectType para solucionarlo. + Se requiere QuestionnaireConfig, pero no está disponible. + Error al completar algunos campos del cuestionario. Respuesta de cuestionario no válida. + Procesando datos del cuestionario… + Cargando cuestionario… + Borrar todo + + Acceso a la ubicación denegado: para capturar las coordenadas GPS, habilite los permisos de ubicación en la configuración de su dispositivo. + Los servicios de ubicación están deshabilitados. ¿Desea habilitarlos? + Enlace %1$s copiado correctamente + + El recurso base para GeoWidgetConfiguration DEBE ser Ubicación + No se proporcionaron configuraciones para la barra de búsqueda + No se encontró ninguna ubicación que coincida con el texto \"%1$s\" + + + + + + + + Siguiente + Anterior + Atrás + Editar + Revisar respuestas + Revisar + Enviar + @android:string/cancel + + + Errores encontrados + Corrige las siguientes preguntas: + • %s + Enviar de todos modos + Corregir preguntas + + + ¿Quieres salir del cuestionario? + + + + "Sin respuesta" + Ayuda + + + No + + + + - + + + Otro + Ingresar opción personalizada + Agregar otra respuesta + Seleccione todo lo que corresponda + Guardar + Cancelar + + + Fecha + Hora + Seleccionar fecha + Seleccionar hora + + + Tomar foto + Vista previa de foto + Vista previa del icono de archivo + Subir foto + Subir audio + Subir vídeo + Subir documento + Subir archivo + Eliminar + Error : El tamaño de la imagen es mayor que %1$s MB + Error: El tamaño del archivo es mayor que %1$s MB + Error en la carga + Error: formato multimedia incorrecto + Subido + Imagen cargada + Archivo subido + Video subido + Audio subido + Imagen eliminada + Archivo eliminado + Video eliminado + Audio eliminado + + + Agregar elemento + + + + + Falta respuesta para el campo obligatorio. + El valor mínimo permitido es:%1$s + El valor máximo permitido es:%1$s + El número mínimo de caracteres que están permitidos en la respuesta es: %1$s + El número máximo de decimales que están permitidos en la respuesta es: %1$s + La respuesta no coincide con la expresión regular: %1$s + Utilice únicamente (.) entre dos números. No se admiten otros caracteres especiales. + El formato de fecha debe ser %1$s (por ejemplo, %2$s ) + El número debe estar entre %1$s y %2 $s + Número no válido + Opcional + Obligatorio + Requerido\n + \u0020\u002a + %1$s. %2$s + Agregar %1$s + + + diff --git a/android/quest/src/eusm/res/drawable/ic_app_logo.png b/android/quest/src/eusmBi/res/drawable/ic_app_logo.png similarity index 100% rename from android/quest/src/eusm/res/drawable/ic_app_logo.png rename to android/quest/src/eusmBi/res/drawable/ic_app_logo.png diff --git a/android/quest/src/eusm/res/drawable/ic_launcher.png b/android/quest/src/eusmBi/res/drawable/ic_launcher.png similarity index 100% rename from android/quest/src/eusm/res/drawable/ic_launcher.png rename to android/quest/src/eusmBi/res/drawable/ic_launcher.png diff --git a/android/quest/src/eusmMg/res/drawable/ic_app_logo.png b/android/quest/src/eusmMg/res/drawable/ic_app_logo.png new file mode 100644 index 00000000000..c054c39f8ed Binary files /dev/null and b/android/quest/src/eusmMg/res/drawable/ic_app_logo.png differ diff --git a/android/quest/src/eusmMg/res/drawable/ic_launcher.png b/android/quest/src/eusmMg/res/drawable/ic_launcher.png new file mode 100644 index 00000000000..8da44beb41f Binary files /dev/null and b/android/quest/src/eusmMg/res/drawable/ic_launcher.png differ diff --git a/android/quest/src/gizEir/assets/fhircore_style.json b/android/quest/src/gizEir/assets/fhircore_style.json new file mode 100644 index 00000000000..7fee80043ed --- /dev/null +++ b/android/quest/src/gizEir/assets/fhircore_style.json @@ -0,0 +1,12475 @@ +{ + "version": 8, + "name": "Quest Streets Style", + "metadata": { + "mapbox:origin": "streets-v10", + "mapbox:autocomposite": true, + "mapbox:type": "default", + "mapbox:sdk-support": { + "js": "0.49.0", + "android": "6.5.0", + "ios": "4.4.0" + }, + "mapbox:groups": { + "1444934828655.3389": { + "name": "Aeroways", + "collapsed": true + }, + "1444933322393.2852": { + "name": "POI labels (scalerank 1)", + "collapsed": true + }, + "1444855786460.0557": { + "name": "Roads", + "collapsed": true + }, + "1444933575858.6992": { + "name": "Highway shields", + "collapsed": true + }, + "1444934295202.7542": { + "name": "Admin boundaries", + "collapsed": true + }, + "1444856151690.9143": { + "name": "State labels", + "collapsed": true + }, + "1444933721429.3076": { + "name": "Road labels", + "collapsed": true + }, + "1444933358918.2366": { + "name": "POI labels (scalerank 2)", + "collapsed": true + }, + "1444933808272.805": { + "name": "Water labels", + "collapsed": true + }, + "1444933372896.5967": { + "name": "POI labels (scalerank 3)", + "collapsed": true + }, + "1444855799204.86": { + "name": "Bridges", + "collapsed": true + }, + "1444856087950.3635": { + "name": "Marine labels", + "collapsed": true + }, + "1456969573402.7817": { + "name": "Hillshading", + "collapsed": true + }, + "1444862510685.128": { + "name": "City labels", + "collapsed": true + }, + "1444855769305.6016": { + "name": "Tunnels", + "collapsed": true + }, + "1456970288113.8113": { + "name": "Landcover", + "collapsed": true + }, + "1444856144497.7825": { + "name": "Country labels", + "collapsed": true + }, + "1444933456003.5437": { + "name": "POI labels (scalerank 4)", + "collapsed": true + } + } + }, + "center": [ + 36.80826008319855, + -1.301070677485388 + ], + "zoom": 16.99, + "sources": { + "select-data": { + "type": "geojson", + "data": { + "type": "FeatureCollection", + "features": [] + } + }, + "composite": { + "url": "mapbox://mapbox.mapbox-terrain-v2,mapbox.mapbox-streets-v7", + "type": "vector" + }, + "quest-data-set": { + "type": "geojson", + "data": { + "type": "FeatureCollection", + "features": [] + } + } + }, + "sprite": "mapbox://sprites/ona/cjosuw10y0gqw2smcjwq627bl", + "glyphs": "mapbox://fonts/ona/{fontstack}/{range}.pbf", + "layers": [ + { + "id": "background", + "type": "background", + "layout": {}, + "paint": { + "background-color": { + "base": 1, + "stops": [ + [ + 11, + "hsl(35, 32%, 91%)" + ], + [ + 13, + "hsl(35, 12%, 89%)" + ] + ] + } + } + }, + { + "id": "landcover_snow", + "type": "fill", + "source": "composite", + "source-layer": "landcover", + "filter": [ + "==", + "class", + "snow" + ], + "layout": {}, + "paint": { + "fill-color": "hsl(0, 0%, 100%)", + "fill-opacity": 0.2, + "fill-antialias": false + }, + "metadata": { + "mapbox:group": "1456970288113.8113" + } + }, + { + "id": "landcover_wood", + "type": "fill", + "source": "composite", + "source-layer": "landcover", + "filter": [ + "==", + "class", + "wood" + ], + "layout": {}, + "paint": { + "fill-color": "hsl(75, 62%, 81%)", + "fill-opacity": { + "base": 1.5, + "stops": [ + [ + 2, + 0.3 + ], + [ + 7, + 0 + ] + ] + }, + "fill-antialias": false + }, + "metadata": { + "mapbox:group": "1456970288113.8113" + }, + "maxzoom": 14 + }, + { + "id": "landcover_scrub", + "type": "fill", + "source": "composite", + "source-layer": "landcover", + "filter": [ + "==", + "class", + "scrub" + ], + "layout": {}, + "paint": { + "fill-color": "hsl(75, 62%, 81%)", + "fill-opacity": { + "base": 1.5, + "stops": [ + [ + 2, + 0.3 + ], + [ + 7, + 0 + ] + ] + }, + "fill-antialias": false + }, + "metadata": { + "mapbox:group": "1456970288113.8113" + }, + "maxzoom": 14 + }, + { + "id": "landcover_grass", + "type": "fill", + "source": "composite", + "source-layer": "landcover", + "layout": {}, + "paint": { + "fill-color": "hsl(75, 62%, 81%)", + "fill-opacity": { + "base": 1.5, + "stops": [ + [ + 2, + 0.3 + ], + [ + 7, + 0 + ] + ] + }, + "fill-antialias": false + }, + "metadata": { + "mapbox:group": "1456970288113.8113" + }, + "maxzoom": 14, + "filter": [ + "==", + "class", + "grass" + ] + }, + { + "id": "landcover_crop", + "type": "fill", + "source": "composite", + "source-layer": "landcover", + "filter": [ + "==", + "class", + "crop" + ], + "layout": {}, + "paint": { + "fill-color": "hsl(75, 62%, 81%)", + "fill-opacity": { + "base": 1.5, + "stops": [ + [ + 2, + 0.3 + ], + [ + 7, + 0 + ] + ] + }, + "fill-antialias": false + }, + "metadata": { + "mapbox:group": "1456970288113.8113" + }, + "maxzoom": 14 + }, + { + "id": "national_park", + "type": "fill", + "source": "composite", + "source-layer": "landuse_overlay", + "filter": [ + "==", + "class", + "national_park" + ], + "layout": {}, + "paint": { + "fill-color": "hsl(100, 58%, 76%)", + "fill-opacity": { + "base": 1, + "stops": [ + [ + 5, + 0 + ], + [ + 6, + 0.5 + ] + ] + } + } + }, + { + "id": "hospital", + "type": "fill", + "source": "composite", + "source-layer": "landuse", + "filter": [ + "==", + "class", + "hospital" + ], + "layout": {}, + "paint": { + "fill-color": { + "base": 1, + "stops": [ + [ + 15.5, + "hsl(340, 37%, 87%)" + ], + [ + 16, + "hsl(340, 63%, 89%)" + ] + ] + } + } + }, + { + "id": "school", + "type": "fill", + "source": "composite", + "source-layer": "landuse", + "filter": [ + "==", + "class", + "school" + ], + "layout": {}, + "paint": { + "fill-color": { + "base": 1, + "stops": [ + [ + 15.5, + "hsl(50, 47%, 81%)" + ], + [ + 16, + "hsl(50, 63%, 84%)" + ] + ] + } + } + }, + { + "id": "park", + "type": "fill", + "source": "composite", + "source-layer": "landuse", + "filter": [ + "==", + "class", + "park" + ], + "layout": {}, + "paint": { + "fill-color": "hsl(100, 58%, 76%)", + "fill-opacity": { + "base": 1, + "stops": [ + [ + 5, + 0 + ], + [ + 6, + 1 + ] + ] + } + } + }, + { + "id": "pitch", + "type": "fill", + "source": "composite", + "source-layer": "landuse", + "filter": [ + "==", + "class", + "pitch" + ], + "layout": {}, + "paint": { + "fill-color": "hsl(100, 57%, 72%)" + } + }, + { + "id": "pitch-line", + "type": "line", + "source": "composite", + "source-layer": "landuse", + "filter": [ + "==", + "class", + "pitch" + ], + "layout": { + "line-join": "miter" + }, + "paint": { + "line-color": "hsl(75, 57%, 84%)" + }, + "minzoom": 15 + }, + { + "id": "cemetery", + "type": "fill", + "source": "composite", + "source-layer": "landuse", + "filter": [ + "==", + "class", + "cemetery" + ], + "layout": {}, + "paint": { + "fill-color": "hsl(75, 37%, 81%)" + } + }, + { + "id": "industrial", + "type": "fill", + "source": "composite", + "source-layer": "landuse", + "filter": [ + "==", + "class", + "industrial" + ], + "layout": {}, + "paint": { + "fill-color": { + "base": 1, + "stops": [ + [ + 15.5, + "hsl(230, 15%, 86%)" + ], + [ + 16, + "hsl(230, 29%, 89%)" + ] + ] + } + } + }, + { + "id": "sand", + "type": "fill", + "source": "composite", + "source-layer": "landuse", + "filter": [ + "==", + "class", + "sand" + ], + "layout": {}, + "paint": { + "fill-color": "hsl(60, 46%, 87%)" + } + }, + { + "id": "hillshade_highlight_bright", + "type": "fill", + "source": "composite", + "source-layer": "hillshade", + "filter": [ + "==", + "level", + 94 + ], + "layout": {}, + "paint": { + "fill-color": "hsl(0, 0%, 100%)", + "fill-opacity": { + "stops": [ + [ + 14, + 0.12 + ], + [ + 16, + 0 + ] + ] + }, + "fill-antialias": false + }, + "metadata": { + "mapbox:group": "1456969573402.7817" + }, + "maxzoom": 16 + }, + { + "id": "hillshade_highlight_med", + "type": "fill", + "source": "composite", + "source-layer": "hillshade", + "filter": [ + "==", + "level", + 90 + ], + "layout": {}, + "paint": { + "fill-color": "hsl(0, 0%, 100%)", + "fill-opacity": { + "stops": [ + [ + 14, + 0.12 + ], + [ + 16, + 0 + ] + ] + }, + "fill-antialias": false + }, + "metadata": { + "mapbox:group": "1456969573402.7817" + }, + "maxzoom": 16 + }, + { + "id": "hillshade_shadow_faint", + "type": "fill", + "source": "composite", + "source-layer": "hillshade", + "filter": [ + "==", + "level", + 89 + ], + "layout": {}, + "paint": { + "fill-color": "hsl(56, 59%, 22%)", + "fill-opacity": { + "stops": [ + [ + 14, + 0.05 + ], + [ + 16, + 0 + ] + ] + }, + "fill-antialias": false + }, + "metadata": { + "mapbox:group": "1456969573402.7817" + }, + "maxzoom": 16 + }, + { + "id": "hillshade_shadow_med", + "type": "fill", + "source": "composite", + "source-layer": "hillshade", + "filter": [ + "==", + "level", + 78 + ], + "layout": {}, + "paint": { + "fill-color": "hsl(56, 59%, 22%)", + "fill-opacity": { + "stops": [ + [ + 14, + 0.05 + ], + [ + 16, + 0 + ] + ] + }, + "fill-antialias": false + }, + "metadata": { + "mapbox:group": "1456969573402.7817" + }, + "maxzoom": 16 + }, + { + "id": "hillshade_shadow_dark", + "type": "fill", + "source": "composite", + "source-layer": "hillshade", + "maxzoom": 16, + "filter": [ + "==", + "level", + 67 + ], + "layout": {}, + "paint": { + "fill-color": "hsl(56, 59%, 22%)", + "fill-opacity": { + "stops": [ + [ + 14, + 0.06 + ], + [ + 16, + 0 + ] + ] + }, + "fill-antialias": false + }, + "metadata": { + "mapbox:group": "1456969573402.7817" + } + }, + { + "id": "hillshade_shadow_extreme", + "type": "fill", + "source": "composite", + "source-layer": "hillshade", + "maxzoom": 16, + "filter": [ + "==", + "level", + 56 + ], + "layout": {}, + "paint": { + "fill-color": "hsl(56, 59%, 22%)", + "fill-opacity": { + "stops": [ + [ + 14, + 0.06 + ], + [ + 16, + 0 + ] + ] + }, + "fill-antialias": false + }, + "metadata": { + "mapbox:group": "1456969573402.7817" + } + }, + { + "id": "waterway-river-canal", + "type": "line", + "source": "composite", + "source-layer": "waterway", + "minzoom": 8, + "filter": [ + "in", + "class", + "canal", + "river" + ], + "layout": { + "line-cap": { + "base": 1, + "stops": [ + [ + 0, + "butt" + ], + [ + 11, + "round" + ] + ] + }, + "line-join": "round" + }, + "paint": { + "line-color": "hsl(205, 87%, 76%)", + "line-width": { + "base": 1.3, + "stops": [ + [ + 8.5, + 0.1 + ], + [ + 20, + 8 + ] + ] + }, + "line-opacity": { + "base": 1, + "stops": [ + [ + 8, + 0 + ], + [ + 8.5, + 1 + ] + ] + } + } + }, + { + "id": "waterway-small", + "type": "line", + "source": "composite", + "source-layer": "waterway", + "minzoom": 13, + "filter": [ + "!in", + "class", + "canal", + "river" + ], + "layout": { + "line-join": "round", + "line-cap": "round" + }, + "paint": { + "line-color": "hsl(205, 87%, 76%)", + "line-width": { + "base": 1.35, + "stops": [ + [ + 13.5, + 0.1 + ], + [ + 20, + 3 + ] + ] + }, + "line-opacity": { + "base": 1, + "stops": [ + [ + 13, + 0 + ], + [ + 13.5, + 1 + ] + ] + } + } + }, + { + "id": "water-shadow", + "type": "fill", + "source": "composite", + "source-layer": "water", + "layout": {}, + "paint": { + "fill-color": "hsl(215, 84%, 69%)", + "fill-translate": { + "base": 1.2, + "stops": [ + [ + 7, + [ + 0, + 0 + ] + ], + [ + 16, + [ + -1, + -1 + ] + ] + ] + }, + "fill-translate-anchor": "viewport", + "fill-opacity": 1 + } + }, + { + "id": "water", + "type": "fill", + "source": "composite", + "source-layer": "water", + "layout": {}, + "paint": { + "fill-color": "hsl(196, 80%, 70%)" + } + }, + { + "id": "barrier_line-land-polygon", + "type": "fill", + "source": "composite", + "layout": {}, + "paint": { + "fill-color": "hsl(35, 12%, 89%)" + }, + "source-layer": "barrier_line", + "filter": [ + "all", + [ + "==", + "$type", + "Polygon" + ], + [ + "==", + "class", + "land" + ] + ] + }, + { + "id": "barrier_line-land-line", + "type": "line", + "source": "composite", + "source-layer": "barrier_line", + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "==", + "class", + "land" + ] + ], + "layout": { + "line-cap": "round" + }, + "paint": { + "line-width": { + "base": 1.99, + "stops": [ + [ + 14, + 0.75 + ], + [ + 20, + 40 + ] + ] + }, + "line-color": "hsl(35, 12%, 89%)" + } + }, + { + "id": "aeroway-polygon", + "type": "fill", + "metadata": { + "mapbox:group": "1444934828655.3389" + }, + "source": "composite", + "source-layer": "aeroway", + "minzoom": 11, + "filter": [ + "all", + [ + "!=", + "type", + "apron" + ], + [ + "==", + "$type", + "Polygon" + ] + ], + "layout": {}, + "paint": { + "fill-color": { + "base": 1, + "stops": [ + [ + 15, + "hsl(230, 23%, 82%)" + ], + [ + 16, + "hsl(230, 37%, 84%)" + ] + ] + }, + "fill-opacity": { + "base": 1, + "stops": [ + [ + 11, + 0 + ], + [ + 11.5, + 1 + ] + ] + } + } + }, + { + "id": "aeroway-runway", + "type": "line", + "metadata": { + "mapbox:group": "1444934828655.3389" + }, + "source": "composite", + "source-layer": "aeroway", + "minzoom": 9, + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "==", + "type", + "runway" + ] + ], + "layout": {}, + "paint": { + "line-color": { + "base": 1, + "stops": [ + [ + 15, + "hsl(230, 23%, 82%)" + ], + [ + 16, + "hsl(230, 37%, 84%)" + ] + ] + }, + "line-width": { + "base": 1.5, + "stops": [ + [ + 9, + 1 + ], + [ + 18, + 80 + ] + ] + } + } + }, + { + "id": "aeroway-taxiway", + "type": "line", + "metadata": { + "mapbox:group": "1444934828655.3389" + }, + "source": "composite", + "source-layer": "aeroway", + "minzoom": 9, + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "==", + "type", + "taxiway" + ] + ], + "layout": {}, + "paint": { + "line-color": { + "base": 1, + "stops": [ + [ + 15, + "hsl(230, 23%, 82%)" + ], + [ + 16, + "hsl(230, 37%, 84%)" + ] + ] + }, + "line-width": { + "base": 1.5, + "stops": [ + [ + 10, + 0.5 + ], + [ + 18, + 20 + ] + ] + } + } + }, + { + "id": "building-line", + "type": "line", + "source": "composite", + "source-layer": "building", + "minzoom": 15, + "filter": [ + "all", + [ + "!=", + "type", + "building:part" + ], + [ + "==", + "underground", + "false" + ] + ], + "layout": {}, + "paint": { + "line-color": "hsl(35, 6%, 79%)", + "line-width": { + "base": 1.5, + "stops": [ + [ + 15, + 0.75 + ], + [ + 20, + 3 + ] + ] + }, + "line-opacity": { + "base": 1, + "stops": [ + [ + 15.5, + 0 + ], + [ + 16, + 1 + ] + ] + } + } + }, + { + "id": "building", + "type": "fill", + "source": "composite", + "source-layer": "building", + "minzoom": 15, + "filter": [ + "all", + [ + "!=", + "type", + "building:part" + ], + [ + "==", + "underground", + "false" + ] + ], + "layout": {}, + "paint": { + "fill-color": { + "base": 1, + "stops": [ + [ + 15, + "hsl(35, 11%, 88%)" + ], + [ + 16, + "hsl(35, 8%, 85%)" + ] + ] + }, + "fill-opacity": { + "base": 1, + "stops": [ + [ + 15.5, + 0 + ], + [ + 16, + 1 + ] + ] + }, + "fill-outline-color": "hsl(35, 6%, 79%)" + } + }, + { + "id": "tunnel-street-low", + "type": "line", + "metadata": { + "mapbox:group": "1444855769305.6016" + }, + "source": "composite", + "source-layer": "road", + "minzoom": 11, + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "all", + [ + "==", + "class", + "street" + ], + [ + "==", + "structure", + "tunnel" + ] + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-width": { + "base": 1.5, + "stops": [ + [ + 12.5, + 0.5 + ], + [ + 14, + 2 + ], + [ + 18, + 18 + ] + ] + }, + "line-color": "hsl(0, 0%, 100%)", + "line-opacity": { + "stops": [ + [ + 11.5, + 0 + ], + [ + 12, + 1 + ], + [ + 14, + 1 + ], + [ + 14.01, + 0 + ] + ] + } + } + }, + { + "id": "tunnel-street_limited-low", + "type": "line", + "metadata": { + "mapbox:group": "1444855769305.6016" + }, + "source": "composite", + "source-layer": "road", + "minzoom": 11, + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "all", + [ + "==", + "class", + "street_limited" + ], + [ + "==", + "structure", + "tunnel" + ] + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-width": { + "base": 1.5, + "stops": [ + [ + 12.5, + 0.5 + ], + [ + 14, + 2 + ], + [ + 18, + 18 + ] + ] + }, + "line-color": "hsl(0, 0%, 100%)", + "line-opacity": { + "stops": [ + [ + 11.5, + 0 + ], + [ + 12, + 1 + ], + [ + 14, + 1 + ], + [ + 14.01, + 0 + ] + ] + } + } + }, + { + "id": "tunnel-service-link-track-case", + "type": "line", + "metadata": { + "mapbox:group": "1444855769305.6016" + }, + "source": "composite", + "source-layer": "road", + "minzoom": 14, + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "all", + [ + "!=", + "type", + "trunk_link" + ], + [ + "==", + "structure", + "tunnel" + ], + [ + "in", + "class", + "link", + "service", + "track" + ] + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-width": { + "base": 1.5, + "stops": [ + [ + 12, + 0.75 + ], + [ + 20, + 2 + ] + ] + }, + "line-color": "hsl(230, 19%, 75%)", + "line-gap-width": { + "base": 1.5, + "stops": [ + [ + 14, + 0.5 + ], + [ + 18, + 12 + ] + ] + }, + "line-dasharray": [ + 3, + 3 + ] + } + }, + { + "id": "tunnel-street_limited-case", + "type": "line", + "metadata": { + "mapbox:group": "1444855769305.6016" + }, + "source": "composite", + "source-layer": "road", + "minzoom": 11, + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "all", + [ + "==", + "class", + "street_limited" + ], + [ + "==", + "structure", + "tunnel" + ] + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-width": { + "base": 1.5, + "stops": [ + [ + 12, + 0.75 + ], + [ + 20, + 2 + ] + ] + }, + "line-color": "hsl(230, 19%, 75%)", + "line-gap-width": { + "base": 1.5, + "stops": [ + [ + 13, + 0 + ], + [ + 14, + 2 + ], + [ + 18, + 18 + ] + ] + }, + "line-dasharray": [ + 3, + 3 + ], + "line-opacity": { + "base": 1, + "stops": [ + [ + 13.99, + 0 + ], + [ + 14, + 1 + ] + ] + } + } + }, + { + "id": "tunnel-street-case", + "type": "line", + "metadata": { + "mapbox:group": "1444855769305.6016" + }, + "source": "composite", + "source-layer": "road", + "minzoom": 11, + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "all", + [ + "==", + "class", + "street" + ], + [ + "==", + "structure", + "tunnel" + ] + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-width": { + "base": 1.5, + "stops": [ + [ + 12, + 0.75 + ], + [ + 20, + 2 + ] + ] + }, + "line-color": "hsl(230, 19%, 75%)", + "line-gap-width": { + "base": 1.5, + "stops": [ + [ + 13, + 0 + ], + [ + 14, + 2 + ], + [ + 18, + 18 + ] + ] + }, + "line-dasharray": [ + 3, + 3 + ], + "line-opacity": { + "base": 1, + "stops": [ + [ + 13.99, + 0 + ], + [ + 14, + 1 + ] + ] + } + } + }, + { + "id": "tunnel-secondary-tertiary-case", + "type": "line", + "metadata": { + "mapbox:group": "1444855769305.6016" + }, + "source": "composite", + "source-layer": "road", + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "all", + [ + "==", + "structure", + "tunnel" + ], + [ + "in", + "class", + "secondary", + "tertiary" + ] + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-width": { + "base": 1.2, + "stops": [ + [ + 10, + 0.75 + ], + [ + 18, + 2 + ] + ] + }, + "line-dasharray": [ + 3, + 3 + ], + "line-gap-width": { + "base": 1.5, + "stops": [ + [ + 8.5, + 0.5 + ], + [ + 10, + 0.75 + ], + [ + 18, + 26 + ] + ] + }, + "line-color": "hsl(230, 19%, 75%)" + } + }, + { + "id": "tunnel-primary-case", + "type": "line", + "metadata": { + "mapbox:group": "1444855769305.6016" + }, + "source": "composite", + "source-layer": "road", + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "all", + [ + "==", + "class", + "primary" + ], + [ + "==", + "structure", + "tunnel" + ] + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-width": { + "base": 1.5, + "stops": [ + [ + 10, + 1 + ], + [ + 16, + 2 + ] + ] + }, + "line-dasharray": [ + 3, + 3 + ], + "line-gap-width": { + "base": 1.5, + "stops": [ + [ + 5, + 0.75 + ], + [ + 18, + 32 + ] + ] + }, + "line-color": "hsl(230, 19%, 75%)" + } + }, + { + "id": "tunnel-trunk_link-case", + "type": "line", + "metadata": { + "mapbox:group": "1444855769305.6016" + }, + "source": "composite", + "source-layer": "road", + "minzoom": 13, + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "all", + [ + "==", + "structure", + "tunnel" + ], + [ + "==", + "type", + "trunk_link" + ] + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-width": { + "base": 1.5, + "stops": [ + [ + 12, + 0.75 + ], + [ + 20, + 2 + ] + ] + }, + "line-color": "hsl(0, 0%, 100%)", + "line-gap-width": { + "base": 1.5, + "stops": [ + [ + 12, + 0.5 + ], + [ + 14, + 2 + ], + [ + 18, + 18 + ] + ] + }, + "line-dasharray": [ + 3, + 3 + ] + } + }, + { + "id": "tunnel-motorway_link-case", + "type": "line", + "metadata": { + "mapbox:group": "1444855769305.6016" + }, + "source": "composite", + "source-layer": "road", + "minzoom": 13, + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "all", + [ + "==", + "class", + "motorway_link" + ], + [ + "==", + "structure", + "tunnel" + ] + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-width": { + "base": 1.5, + "stops": [ + [ + 12, + 0.75 + ], + [ + 20, + 2 + ] + ] + }, + "line-color": "hsl(0, 0%, 100%)", + "line-gap-width": { + "base": 1.5, + "stops": [ + [ + 12, + 0.5 + ], + [ + 14, + 2 + ], + [ + 18, + 18 + ] + ] + }, + "line-dasharray": [ + 3, + 3 + ] + } + }, + { + "id": "tunnel-trunk-case", + "type": "line", + "metadata": { + "mapbox:group": "1444855769305.6016" + }, + "source": "composite", + "source-layer": "road", + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "all", + [ + "==", + "structure", + "tunnel" + ], + [ + "==", + "type", + "trunk" + ] + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-width": { + "base": 1.5, + "stops": [ + [ + 10, + 1 + ], + [ + 16, + 2 + ] + ] + }, + "line-color": "hsl(0, 0%, 100%)", + "line-gap-width": { + "base": 1.5, + "stops": [ + [ + 5, + 0.75 + ], + [ + 18, + 32 + ] + ] + }, + "line-opacity": 1, + "line-dasharray": [ + 3, + 3 + ] + } + }, + { + "id": "tunnel-motorway-case", + "type": "line", + "metadata": { + "mapbox:group": "1444855769305.6016" + }, + "source": "composite", + "source-layer": "road", + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "all", + [ + "==", + "class", + "motorway" + ], + [ + "==", + "structure", + "tunnel" + ] + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-width": { + "base": 1.5, + "stops": [ + [ + 10, + 1 + ], + [ + 16, + 2 + ] + ] + }, + "line-color": "hsl(0, 0%, 100%)", + "line-gap-width": { + "base": 1.5, + "stops": [ + [ + 5, + 0.75 + ], + [ + 18, + 32 + ] + ] + }, + "line-opacity": 1, + "line-dasharray": [ + 3, + 3 + ] + } + }, + { + "id": "tunnel-construction", + "type": "line", + "metadata": { + "mapbox:group": "1444855769305.6016" + }, + "source": "composite", + "source-layer": "road", + "minzoom": 14, + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "all", + [ + "==", + "class", + "construction" + ], + [ + "==", + "structure", + "tunnel" + ] + ] + ], + "layout": { + "line-join": "miter" + }, + "paint": { + "line-width": { + "base": 1.5, + "stops": [ + [ + 12.5, + 0.5 + ], + [ + 14, + 2 + ], + [ + 18, + 18 + ] + ] + }, + "line-color": "hsl(230, 24%, 87%)", + "line-opacity": { + "base": 1, + "stops": [ + [ + 13.99, + 0 + ], + [ + 14, + 1 + ] + ] + }, + "line-dasharray": { + "base": 1, + "stops": [ + [ + 14, + [ + 0.4, + 0.8 + ] + ], + [ + 15, + [ + 0.3, + 0.6 + ] + ], + [ + 16, + [ + 0.2, + 0.3 + ] + ], + [ + 17, + [ + 0.2, + 0.25 + ] + ], + [ + 18, + [ + 0.15, + 0.15 + ] + ] + ] + } + } + }, + { + "id": "tunnel-path", + "type": "line", + "metadata": { + "mapbox:group": "1444855769305.6016" + }, + "source": "composite", + "source-layer": "road", + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "all", + [ + "!=", + "type", + "steps" + ], + [ + "==", + "class", + "path" + ], + [ + "==", + "structure", + "tunnel" + ] + ] + ], + "layout": { + "line-join": "round" + }, + "paint": { + "line-width": { + "base": 1.5, + "stops": [ + [ + 15, + 1 + ], + [ + 18, + 4 + ] + ] + }, + "line-dasharray": { + "base": 1, + "stops": [ + [ + 14, + [ + 1, + 0 + ] + ], + [ + 15, + [ + 1.75, + 1 + ] + ], + [ + 16, + [ + 1, + 0.75 + ] + ], + [ + 17, + [ + 1, + 0.5 + ] + ] + ] + }, + "line-color": "hsl(35, 26%, 95%)", + "line-opacity": { + "base": 1, + "stops": [ + [ + 14, + 0 + ], + [ + 14.25, + 1 + ] + ] + } + } + }, + { + "id": "tunnel-steps", + "type": "line", + "metadata": { + "mapbox:group": "1444855769305.6016" + }, + "source": "composite", + "source-layer": "road", + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "all", + [ + "==", + "structure", + "tunnel" + ], + [ + "==", + "type", + "steps" + ] + ] + ], + "layout": { + "line-join": "round" + }, + "paint": { + "line-width": { + "base": 1.5, + "stops": [ + [ + 15, + 1 + ], + [ + 16, + 1.6 + ], + [ + 18, + 6 + ] + ] + }, + "line-color": "hsl(35, 26%, 95%)", + "line-dasharray": { + "base": 1, + "stops": [ + [ + 14, + [ + 1, + 0 + ] + ], + [ + 15, + [ + 1.75, + 1 + ] + ], + [ + 16, + [ + 1, + 0.75 + ] + ], + [ + 17, + [ + 0.3, + 0.3 + ] + ] + ] + }, + "line-opacity": { + "base": 1, + "stops": [ + [ + 14, + 0 + ], + [ + 14.25, + 1 + ] + ] + } + } + }, + { + "id": "tunnel-trunk_link", + "type": "line", + "metadata": { + "mapbox:group": "1444855769305.6016" + }, + "source": "composite", + "source-layer": "road", + "minzoom": 13, + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "all", + [ + "==", + "structure", + "tunnel" + ], + [ + "==", + "type", + "trunk_link" + ] + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-width": { + "base": 1.5, + "stops": [ + [ + 12, + 0.5 + ], + [ + 14, + 2 + ], + [ + 18, + 18 + ] + ] + }, + "line-color": "hsl(46, 77%, 78%)", + "line-opacity": 1, + "line-dasharray": [ + 1, + 0 + ] + } + }, + { + "id": "tunnel-motorway_link", + "type": "line", + "metadata": { + "mapbox:group": "1444855769305.6016" + }, + "source": "composite", + "source-layer": "road", + "minzoom": 13, + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "all", + [ + "==", + "class", + "motorway_link" + ], + [ + "==", + "structure", + "tunnel" + ] + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-width": { + "base": 1.5, + "stops": [ + [ + 12, + 0.5 + ], + [ + 14, + 2 + ], + [ + 18, + 18 + ] + ] + }, + "line-color": "hsl(26, 100%, 78%)", + "line-opacity": 1, + "line-dasharray": [ + 1, + 0 + ] + } + }, + { + "id": "tunnel-pedestrian", + "type": "line", + "metadata": { + "mapbox:group": "1444855769305.6016" + }, + "source": "composite", + "source-layer": "road", + "minzoom": 13, + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "all", + [ + "==", + "class", + "pedestrian" + ], + [ + "==", + "structure", + "tunnel" + ] + ] + ], + "layout": { + "line-join": "round" + }, + "paint": { + "line-width": { + "base": 1.5, + "stops": [ + [ + 14, + 0.5 + ], + [ + 18, + 12 + ] + ] + }, + "line-color": "hsl(0, 0%, 100%)", + "line-opacity": 1, + "line-dasharray": { + "base": 1, + "stops": [ + [ + 14, + [ + 1, + 0 + ] + ], + [ + 15, + [ + 1.5, + 0.4 + ] + ], + [ + 16, + [ + 1, + 0.2 + ] + ] + ] + } + } + }, + { + "id": "tunnel-service-link-track", + "type": "line", + "metadata": { + "mapbox:group": "1444855769305.6016" + }, + "source": "composite", + "source-layer": "road", + "minzoom": 14, + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "all", + [ + "!=", + "type", + "trunk_link" + ], + [ + "==", + "structure", + "tunnel" + ], + [ + "in", + "class", + "link", + "service", + "track" + ] + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-width": { + "base": 1.5, + "stops": [ + [ + 14, + 0.5 + ], + [ + 18, + 12 + ] + ] + }, + "line-color": "hsl(0, 0%, 100%)", + "line-dasharray": [ + 1, + 0 + ] + } + }, + { + "id": "tunnel-street_limited", + "type": "line", + "metadata": { + "mapbox:group": "1444855769305.6016" + }, + "source": "composite", + "source-layer": "road", + "minzoom": 11, + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "all", + [ + "==", + "class", + "street_limited" + ], + [ + "==", + "structure", + "tunnel" + ] + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-width": { + "base": 1.5, + "stops": [ + [ + 12.5, + 0.5 + ], + [ + 14, + 2 + ], + [ + 18, + 18 + ] + ] + }, + "line-color": "hsl(35, 14%, 93%)", + "line-opacity": { + "base": 1, + "stops": [ + [ + 13.99, + 0 + ], + [ + 14, + 1 + ] + ] + } + } + }, + { + "id": "tunnel-street", + "type": "line", + "metadata": { + "mapbox:group": "1444855769305.6016" + }, + "source": "composite", + "source-layer": "road", + "minzoom": 11, + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "all", + [ + "==", + "class", + "street" + ], + [ + "==", + "structure", + "tunnel" + ] + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-width": { + "base": 1.5, + "stops": [ + [ + 12.5, + 0.5 + ], + [ + 14, + 2 + ], + [ + 18, + 18 + ] + ] + }, + "line-color": "hsl(0, 0%, 100%)", + "line-opacity": { + "base": 1, + "stops": [ + [ + 13.99, + 0 + ], + [ + 14, + 1 + ] + ] + } + } + }, + { + "id": "tunnel-secondary-tertiary", + "type": "line", + "metadata": { + "mapbox:group": "1444855769305.6016" + }, + "source": "composite", + "source-layer": "road", + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "all", + [ + "==", + "structure", + "tunnel" + ], + [ + "in", + "class", + "secondary", + "tertiary" + ] + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-width": { + "base": 1.5, + "stops": [ + [ + 8.5, + 0.5 + ], + [ + 10, + 0.75 + ], + [ + 18, + 26 + ] + ] + }, + "line-color": "hsl(0, 0%, 100%)", + "line-opacity": 1, + "line-dasharray": [ + 1, + 0 + ], + "line-blur": 0 + } + }, + { + "id": "tunnel-primary", + "type": "line", + "metadata": { + "mapbox:group": "1444855769305.6016" + }, + "source": "composite", + "source-layer": "road", + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "all", + [ + "==", + "class", + "primary" + ], + [ + "==", + "structure", + "tunnel" + ] + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-width": { + "base": 1.5, + "stops": [ + [ + 5, + 0.75 + ], + [ + 18, + 32 + ] + ] + }, + "line-color": "hsl(0, 0%, 100%)", + "line-opacity": 1, + "line-dasharray": [ + 1, + 0 + ], + "line-blur": 0 + } + }, + { + "id": "tunnel-oneway-arrows-blue-minor", + "type": "symbol", + "metadata": { + "mapbox:group": "1444855769305.6016" + }, + "source": "composite", + "source-layer": "road", + "minzoom": 16, + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "all", + [ + "!=", + "type", + "trunk_link" + ], + [ + "==", + "oneway", + "true" + ], + [ + "==", + "structure", + "tunnel" + ], + [ + "in", + "class", + "link", + "path", + "pedestrian", + "service", + "track" + ] + ] + ], + "layout": { + "symbol-placement": "line", + "icon-image": { + "base": 1, + "stops": [ + [ + 17, + "oneway-small" + ], + [ + 18, + "oneway-large" + ] + ] + }, + "symbol-spacing": 200, + "icon-padding": 2 + }, + "paint": {} + }, + { + "id": "tunnel-oneway-arrows-blue-major", + "type": "symbol", + "metadata": { + "mapbox:group": "1444855769305.6016" + }, + "source": "composite", + "source-layer": "road", + "minzoom": 15, + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "all", + [ + "!=", + "type", + "trunk_link" + ], + [ + "==", + "oneway", + "true" + ], + [ + "==", + "structure", + "tunnel" + ], + [ + "in", + "class", + "primary", + "secondary", + "street", + "street_limited", + "tertiary" + ] + ] + ], + "layout": { + "symbol-placement": "line", + "icon-image": { + "base": 1, + "stops": [ + [ + 16, + "oneway-small" + ], + [ + 17, + "oneway-large" + ] + ] + }, + "symbol-spacing": 200, + "icon-padding": 2 + }, + "paint": {} + }, + { + "id": "tunnel-trunk", + "type": "line", + "metadata": { + "mapbox:group": "1444855769305.6016" + }, + "source": "composite", + "source-layer": "road", + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "all", + [ + "==", + "class", + "trunk" + ], + [ + "==", + "structure", + "tunnel" + ] + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-width": { + "base": 1.5, + "stops": [ + [ + 5, + 0.75 + ], + [ + 18, + 32 + ] + ] + }, + "line-color": "hsl(46, 77%, 78%)" + } + }, + { + "id": "tunnel-motorway", + "type": "line", + "metadata": { + "mapbox:group": "1444855769305.6016" + }, + "source": "composite", + "source-layer": "road", + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "all", + [ + "==", + "class", + "motorway" + ], + [ + "==", + "structure", + "tunnel" + ] + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-width": { + "base": 1.5, + "stops": [ + [ + 5, + 0.75 + ], + [ + 18, + 32 + ] + ] + }, + "line-dasharray": [ + 1, + 0 + ], + "line-opacity": 1, + "line-color": "hsl(26, 100%, 78%)", + "line-blur": 0 + } + }, + { + "id": "tunnel-oneway-arrows-white", + "type": "symbol", + "metadata": { + "mapbox:group": "1444855769305.6016" + }, + "source": "composite", + "source-layer": "road", + "minzoom": 16, + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "all", + [ + "!in", + "type", + "primary_link", + "secondary_link", + "tertiary_link" + ], + [ + "==", + "oneway", + "true" + ], + [ + "==", + "structure", + "tunnel" + ], + [ + "in", + "class", + "link", + "motorway", + "motorway_link", + "trunk" + ] + ] + ], + "layout": { + "symbol-placement": "line", + "icon-image": { + "base": 1, + "stops": [ + [ + 16, + "oneway-white-small" + ], + [ + 17, + "oneway-white-large" + ] + ] + }, + "symbol-spacing": 200, + "icon-padding": 2 + }, + "paint": {} + }, + { + "id": "ferry", + "type": "line", + "source": "composite", + "source-layer": "road", + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "==", + "type", + "ferry" + ] + ], + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": { + "base": 1, + "stops": [ + [ + 15, + "hsl(205, 73%, 63%)" + ], + [ + 17, + "hsl(230, 73%, 63%)" + ] + ] + }, + "line-opacity": 1, + "line-width": { + "base": 1.5, + "stops": [ + [ + 14, + 0.5 + ], + [ + 20, + 1 + ] + ] + }, + "line-dasharray": { + "base": 1, + "stops": [ + [ + 12, + [ + 1, + 0 + ] + ], + [ + 13, + [ + 12, + 4 + ] + ] + ] + } + } + }, + { + "id": "ferry_auto", + "type": "line", + "source": "composite", + "source-layer": "road", + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "==", + "type", + "ferry_auto" + ] + ], + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": { + "base": 1, + "stops": [ + [ + 15, + "hsl(205, 73%, 63%)" + ], + [ + 17, + "hsl(230, 73%, 63%)" + ] + ] + }, + "line-opacity": 1, + "line-width": { + "base": 1.5, + "stops": [ + [ + 14, + 0.5 + ], + [ + 20, + 1 + ] + ] + } + } + }, + { + "id": "road-path-bg", + "type": "line", + "metadata": { + "mapbox:group": "1444855786460.0557" + }, + "source": "composite", + "source-layer": "road", + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "all", + [ + "!in", + "structure", + "bridge", + "tunnel" + ], + [ + "!in", + "type", + "crossing", + "sidewalk", + "steps" + ], + [ + "==", + "class", + "path" + ] + ] + ], + "layout": { + "line-join": "round" + }, + "paint": { + "line-width": { + "base": 1.5, + "stops": [ + [ + 15, + 2 + ], + [ + 18, + 7 + ] + ] + }, + "line-dasharray": [ + 1, + 0 + ], + "line-color": "hsl(230, 17%, 82%)", + "line-blur": 0, + "line-opacity": { + "base": 1, + "stops": [ + [ + 14, + 0 + ], + [ + 14.25, + 0.75 + ] + ] + } + } + }, + { + "id": "road-steps-bg", + "type": "line", + "metadata": { + "mapbox:group": "1444855786460.0557" + }, + "source": "composite", + "source-layer": "road", + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "all", + [ + "!in", + "structure", + "bridge", + "tunnel" + ], + [ + "==", + "type", + "steps" + ] + ] + ], + "layout": { + "line-join": "round" + }, + "paint": { + "line-width": { + "base": 1.5, + "stops": [ + [ + 15, + 2 + ], + [ + 17, + 4.6 + ], + [ + 18, + 7 + ] + ] + }, + "line-color": "hsl(230, 17%, 82%)", + "line-dasharray": [ + 1, + 0 + ], + "line-opacity": { + "base": 1, + "stops": [ + [ + 14, + 0 + ], + [ + 14.25, + 0.75 + ] + ] + } + } + }, + { + "id": "road-sidewalk-bg", + "type": "line", + "metadata": { + "mapbox:group": "1444855786460.0557" + }, + "source": "composite", + "source-layer": "road", + "minzoom": 16, + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "all", + [ + "!in", + "structure", + "bridge", + "tunnel" + ], + [ + "in", + "type", + "crossing", + "sidewalk" + ] + ] + ], + "layout": { + "line-join": "round" + }, + "paint": { + "line-width": { + "base": 1.5, + "stops": [ + [ + 15, + 2 + ], + [ + 18, + 7 + ] + ] + }, + "line-dasharray": [ + 1, + 0 + ], + "line-color": "hsl(230, 17%, 82%)", + "line-blur": 0, + "line-opacity": { + "base": 1, + "stops": [ + [ + 16, + 0 + ], + [ + 16.25, + 0.75 + ] + ] + } + } + }, + { + "id": "turning-features-outline", + "type": "symbol", + "metadata": { + "mapbox:group": "1444855786460.0557" + }, + "source": "composite", + "source-layer": "road", + "minzoom": 15, + "filter": [ + "all", + [ + "==", + "$type", + "Point" + ], + [ + "in", + "class", + "turning_circle", + "turning_loop" + ] + ], + "layout": { + "icon-image": "turning-circle-outline", + "icon-size": { + "base": 1.5, + "stops": [ + [ + 14, + 0.122 + ], + [ + 18, + 0.969 + ], + [ + 20, + 1 + ] + ] + }, + "icon-allow-overlap": true, + "icon-ignore-placement": true, + "icon-padding": 0, + "icon-rotation-alignment": "map" + }, + "paint": {} + }, + { + "id": "road-pedestrian-case", + "type": "line", + "metadata": { + "mapbox:group": "1444855786460.0557" + }, + "source": "composite", + "source-layer": "road", + "minzoom": 12, + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "all", + [ + "==", + "class", + "pedestrian" + ], + [ + "==", + "structure", + "none" + ] + ] + ], + "layout": { + "line-join": "round" + }, + "paint": { + "line-width": { + "base": 1.5, + "stops": [ + [ + 14, + 2 + ], + [ + 18, + 14.5 + ] + ] + }, + "line-color": "hsl(230, 24%, 87%)", + "line-gap-width": 0, + "line-opacity": { + "base": 1, + "stops": [ + [ + 13.99, + 0 + ], + [ + 14, + 1 + ] + ] + } + } + }, + { + "id": "road-street-low", + "type": "line", + "metadata": { + "mapbox:group": "1444855786460.0557" + }, + "source": "composite", + "source-layer": "road", + "minzoom": 11, + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "all", + [ + "==", + "class", + "street" + ], + [ + "==", + "structure", + "none" + ] + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-width": { + "base": 1.5, + "stops": [ + [ + 12.5, + 0.5 + ], + [ + 14, + 2 + ], + [ + 18, + 18 + ] + ] + }, + "line-color": "hsl(0, 0%, 100%)", + "line-opacity": { + "stops": [ + [ + 11, + 0 + ], + [ + 11.25, + 1 + ], + [ + 14, + 1 + ], + [ + 14.01, + 0 + ] + ] + } + } + }, + { + "id": "road-street_limited-low", + "type": "line", + "metadata": { + "mapbox:group": "1444855786460.0557" + }, + "source": "composite", + "source-layer": "road", + "minzoom": 11, + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "all", + [ + "==", + "class", + "street_limited" + ], + [ + "==", + "structure", + "none" + ] + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-width": { + "base": 1.5, + "stops": [ + [ + 12.5, + 0.5 + ], + [ + 14, + 2 + ], + [ + 18, + 18 + ] + ] + }, + "line-color": "hsl(0, 0%, 100%)", + "line-opacity": { + "stops": [ + [ + 11, + 0 + ], + [ + 11.25, + 1 + ], + [ + 14, + 1 + ], + [ + 14.01, + 0 + ] + ] + } + } + }, + { + "id": "road-service-link-track-case", + "type": "line", + "metadata": { + "mapbox:group": "1444855786460.0557" + }, + "source": "composite", + "source-layer": "road", + "minzoom": 14, + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "all", + [ + "!=", + "type", + "trunk_link" + ], + [ + "!in", + "structure", + "bridge", + "tunnel" + ], + [ + "in", + "class", + "link", + "service", + "track" + ] + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-width": { + "base": 1.5, + "stops": [ + [ + 12, + 0.75 + ], + [ + 20, + 2 + ] + ] + }, + "line-color": "hsl(230, 24%, 87%)", + "line-gap-width": { + "base": 1.5, + "stops": [ + [ + 14, + 0.5 + ], + [ + 18, + 12 + ] + ] + } + } + }, + { + "id": "road-street_limited-case", + "type": "line", + "metadata": { + "mapbox:group": "1444855786460.0557" + }, + "source": "composite", + "source-layer": "road", + "minzoom": 11, + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "all", + [ + "==", + "class", + "street_limited" + ], + [ + "==", + "structure", + "none" + ] + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-width": { + "base": 1.5, + "stops": [ + [ + 12, + 0.75 + ], + [ + 20, + 2 + ] + ] + }, + "line-color": "hsl(230, 24%, 87%)", + "line-gap-width": { + "base": 1.5, + "stops": [ + [ + 13, + 0 + ], + [ + 14, + 2 + ], + [ + 18, + 18 + ] + ] + }, + "line-opacity": { + "base": 1, + "stops": [ + [ + 13.99, + 0 + ], + [ + 14, + 1 + ] + ] + } + } + }, + { + "id": "road-street-case", + "type": "line", + "metadata": { + "mapbox:group": "1444855786460.0557" + }, + "source": "composite", + "source-layer": "road", + "minzoom": 11, + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "all", + [ + "==", + "class", + "street" + ], + [ + "==", + "structure", + "none" + ] + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-width": { + "base": 1.5, + "stops": [ + [ + 12, + 0.75 + ], + [ + 20, + 2 + ] + ] + }, + "line-color": "hsl(230, 24%, 87%)", + "line-gap-width": { + "base": 1.5, + "stops": [ + [ + 13, + 0 + ], + [ + 14, + 2 + ], + [ + 18, + 18 + ] + ] + }, + "line-opacity": { + "base": 1, + "stops": [ + [ + 13.99, + 0 + ], + [ + 14, + 1 + ] + ] + } + } + }, + { + "id": "road-secondary-tertiary-case", + "type": "line", + "metadata": { + "mapbox:group": "1444855786460.0557" + }, + "source": "composite", + "source-layer": "road", + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "all", + [ + "!in", + "structure", + "bridge", + "tunnel" + ], + [ + "in", + "class", + "secondary", + "tertiary" + ] + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-width": { + "base": 1.2, + "stops": [ + [ + 10, + 0.75 + ], + [ + 18, + 2 + ] + ] + }, + "line-color": "hsl(230, 24%, 87%)", + "line-gap-width": { + "base": 1.5, + "stops": [ + [ + 8.5, + 0.5 + ], + [ + 10, + 0.75 + ], + [ + 18, + 26 + ] + ] + }, + "line-opacity": { + "base": 1, + "stops": [ + [ + 9.99, + 0 + ], + [ + 10, + 1 + ] + ] + } + } + }, + { + "id": "road-primary-case", + "type": "line", + "metadata": { + "mapbox:group": "1444855786460.0557" + }, + "source": "composite", + "source-layer": "road", + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "all", + [ + "!in", + "structure", + "bridge", + "tunnel" + ], + [ + "==", + "class", + "primary" + ] + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-width": { + "base": 1.5, + "stops": [ + [ + 10, + 1 + ], + [ + 16, + 2 + ] + ] + }, + "line-color": "hsl(230, 24%, 87%)", + "line-gap-width": { + "base": 1.5, + "stops": [ + [ + 5, + 0.75 + ], + [ + 18, + 32 + ] + ] + }, + "line-opacity": { + "base": 1, + "stops": [ + [ + 9.99, + 0 + ], + [ + 10, + 1 + ] + ] + } + } + }, + { + "id": "road-motorway_link-case", + "type": "line", + "metadata": { + "mapbox:group": "1444855786460.0557" + }, + "source": "composite", + "source-layer": "road", + "minzoom": 10, + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "all", + [ + "!in", + "structure", + "bridge", + "tunnel" + ], + [ + "==", + "class", + "motorway_link" + ] + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-width": { + "base": 1.5, + "stops": [ + [ + 12, + 0.75 + ], + [ + 20, + 2 + ] + ] + }, + "line-color": "hsl(0, 0%, 100%)", + "line-gap-width": { + "base": 1.5, + "stops": [ + [ + 12, + 0.5 + ], + [ + 14, + 2 + ], + [ + 18, + 18 + ] + ] + }, + "line-opacity": { + "base": 1, + "stops": [ + [ + 10.99, + 0 + ], + [ + 11, + 1 + ] + ] + } + } + }, + { + "id": "road-trunk_link-case", + "type": "line", + "metadata": { + "mapbox:group": "1444855786460.0557" + }, + "source": "composite", + "source-layer": "road", + "minzoom": 11, + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "all", + [ + "!in", + "structure", + "bridge", + "tunnel" + ], + [ + "==", + "type", + "trunk_link" + ] + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-width": { + "base": 1.5, + "stops": [ + [ + 12, + 0.75 + ], + [ + 20, + 2 + ] + ] + }, + "line-color": "hsl(0, 0%, 100%)", + "line-gap-width": { + "base": 1.5, + "stops": [ + [ + 12, + 0.5 + ], + [ + 14, + 2 + ], + [ + 18, + 18 + ] + ] + }, + "line-opacity": { + "base": 1, + "stops": [ + [ + 10.99, + 0 + ], + [ + 11, + 1 + ] + ] + } + } + }, + { + "id": "road-trunk-case", + "type": "line", + "metadata": { + "mapbox:group": "1444855786460.0557" + }, + "source": "composite", + "source-layer": "road", + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "all", + [ + "!in", + "structure", + "bridge", + "tunnel" + ], + [ + "==", + "class", + "trunk" + ] + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-width": { + "base": 1.5, + "stops": [ + [ + 10, + 1 + ], + [ + 16, + 2 + ] + ] + }, + "line-color": "hsl(0, 0%, 100%)", + "line-gap-width": { + "base": 1.5, + "stops": [ + [ + 5, + 0.75 + ], + [ + 18, + 32 + ] + ] + }, + "line-opacity": { + "base": 1, + "stops": [ + [ + 6, + 0 + ], + [ + 6.1, + 1 + ] + ] + } + } + }, + { + "id": "road-motorway-case", + "type": "line", + "metadata": { + "mapbox:group": "1444855786460.0557" + }, + "source": "composite", + "source-layer": "road", + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "all", + [ + "!in", + "structure", + "bridge", + "tunnel" + ], + [ + "==", + "class", + "motorway" + ] + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-width": { + "base": 1.5, + "stops": [ + [ + 10, + 1 + ], + [ + 16, + 2 + ] + ] + }, + "line-color": "hsl(0, 0%, 100%)", + "line-gap-width": { + "base": 1.5, + "stops": [ + [ + 5, + 0.75 + ], + [ + 18, + 32 + ] + ] + } + } + }, + { + "id": "road-construction", + "type": "line", + "metadata": { + "mapbox:group": "1444855786460.0557" + }, + "source": "composite", + "source-layer": "road", + "minzoom": 14, + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "all", + [ + "==", + "class", + "construction" + ], + [ + "==", + "structure", + "none" + ] + ] + ], + "layout": { + "line-join": "miter" + }, + "paint": { + "line-width": { + "base": 1.5, + "stops": [ + [ + 12.5, + 0.5 + ], + [ + 14, + 2 + ], + [ + 18, + 18 + ] + ] + }, + "line-color": "hsl(230, 24%, 87%)", + "line-opacity": { + "base": 1, + "stops": [ + [ + 13.99, + 0 + ], + [ + 14, + 1 + ] + ] + }, + "line-dasharray": { + "base": 1, + "stops": [ + [ + 14, + [ + 0.4, + 0.8 + ] + ], + [ + 15, + [ + 0.3, + 0.6 + ] + ], + [ + 16, + [ + 0.2, + 0.3 + ] + ], + [ + 17, + [ + 0.2, + 0.25 + ] + ], + [ + 18, + [ + 0.15, + 0.15 + ] + ] + ] + } + } + }, + { + "id": "road-sidewalks", + "type": "line", + "metadata": { + "mapbox:group": "1444855786460.0557" + }, + "source": "composite", + "source-layer": "road", + "minzoom": 16, + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "all", + [ + "!in", + "structure", + "bridge", + "tunnel" + ], + [ + "in", + "type", + "crossing", + "sidewalk" + ] + ] + ], + "layout": { + "line-join": "round" + }, + "paint": { + "line-width": { + "base": 1.5, + "stops": [ + [ + 15, + 1 + ], + [ + 18, + 4 + ] + ] + }, + "line-color": "hsl(0, 0%, 100%)", + "line-dasharray": { + "base": 1, + "stops": [ + [ + 14, + [ + 1, + 0 + ] + ], + [ + 15, + [ + 1.75, + 1 + ] + ], + [ + 16, + [ + 1, + 0.75 + ] + ], + [ + 17, + [ + 1, + 0.5 + ] + ] + ] + }, + "line-opacity": { + "base": 1, + "stops": [ + [ + 16, + 0 + ], + [ + 16.25, + 1 + ] + ] + } + } + }, + { + "id": "road-path", + "type": "line", + "metadata": { + "mapbox:group": "1444855786460.0557" + }, + "source": "composite", + "source-layer": "road", + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "all", + [ + "!in", + "structure", + "bridge", + "tunnel" + ], + [ + "!in", + "type", + "crossing", + "sidewalk", + "steps" + ], + [ + "==", + "class", + "path" + ] + ] + ], + "layout": { + "line-join": "round" + }, + "paint": { + "line-width": { + "base": 1.5, + "stops": [ + [ + 15, + 1 + ], + [ + 18, + 4 + ] + ] + }, + "line-color": "hsl(0, 0%, 100%)", + "line-dasharray": { + "base": 1, + "stops": [ + [ + 14, + [ + 1, + 0 + ] + ], + [ + 15, + [ + 1.75, + 1 + ] + ], + [ + 16, + [ + 1, + 0.75 + ] + ], + [ + 17, + [ + 1, + 0.5 + ] + ] + ] + }, + "line-opacity": { + "base": 1, + "stops": [ + [ + 14, + 0 + ], + [ + 14.25, + 1 + ] + ] + } + } + }, + { + "id": "road-steps", + "type": "line", + "metadata": { + "mapbox:group": "1444855786460.0557" + }, + "source": "composite", + "source-layer": "road", + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "all", + [ + "!in", + "structure", + "bridge", + "tunnel" + ], + [ + "==", + "type", + "steps" + ] + ] + ], + "layout": { + "line-join": "round" + }, + "paint": { + "line-width": { + "base": 1.5, + "stops": [ + [ + 15, + 1 + ], + [ + 16, + 1.6 + ], + [ + 18, + 6 + ] + ] + }, + "line-color": "hsl(0, 0%, 100%)", + "line-dasharray": { + "base": 1, + "stops": [ + [ + 14, + [ + 1, + 0 + ] + ], + [ + 15, + [ + 1.75, + 1 + ] + ], + [ + 16, + [ + 1, + 0.75 + ] + ], + [ + 17, + [ + 0.3, + 0.3 + ] + ] + ] + }, + "line-opacity": { + "base": 1, + "stops": [ + [ + 14, + 0 + ], + [ + 14.25, + 1 + ] + ] + } + } + }, + { + "id": "road-trunk_link", + "type": "line", + "metadata": { + "mapbox:group": "1444855786460.0557" + }, + "source": "composite", + "source-layer": "road", + "minzoom": 11, + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "all", + [ + "!in", + "structure", + "bridge", + "tunnel" + ], + [ + "==", + "type", + "trunk_link" + ] + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-width": { + "base": 1.5, + "stops": [ + [ + 12, + 0.5 + ], + [ + 14, + 2 + ], + [ + 18, + 18 + ] + ] + }, + "line-color": "hsl(46, 85%, 67%)", + "line-opacity": 1 + } + }, + { + "id": "road-motorway_link", + "type": "line", + "metadata": { + "mapbox:group": "1444855786460.0557" + }, + "source": "composite", + "source-layer": "road", + "minzoom": 10, + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "all", + [ + "!in", + "structure", + "bridge", + "tunnel" + ], + [ + "==", + "class", + "motorway_link" + ] + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-width": { + "base": 1.5, + "stops": [ + [ + 12, + 0.5 + ], + [ + 14, + 2 + ], + [ + 18, + 18 + ] + ] + }, + "line-color": "hsl(26, 100%, 68%)", + "line-opacity": 1 + } + }, + { + "id": "road-pedestrian", + "type": "line", + "metadata": { + "mapbox:group": "1444855786460.0557" + }, + "source": "composite", + "source-layer": "road", + "minzoom": 12, + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "all", + [ + "==", + "class", + "pedestrian" + ], + [ + "==", + "structure", + "none" + ] + ] + ], + "layout": { + "line-join": "round" + }, + "paint": { + "line-width": { + "base": 1.5, + "stops": [ + [ + 14, + 0.5 + ], + [ + 18, + 12 + ] + ] + }, + "line-color": "hsl(0, 0%, 100%)", + "line-opacity": 1, + "line-dasharray": { + "base": 1, + "stops": [ + [ + 14, + [ + 1, + 0 + ] + ], + [ + 15, + [ + 1.5, + 0.4 + ] + ], + [ + 16, + [ + 1, + 0.2 + ] + ] + ] + } + } + }, + { + "id": "road-pedestrian-polygon-fill", + "type": "fill", + "metadata": { + "mapbox:group": "1444855786460.0557" + }, + "source": "composite", + "source-layer": "road", + "minzoom": 12, + "filter": [ + "all", + [ + "==", + "$type", + "Polygon" + ], + [ + "all", + [ + "==", + "structure", + "none" + ], + [ + "in", + "class", + "path", + "pedestrian" + ] + ] + ], + "layout": {}, + "paint": { + "fill-color": { + "base": 1, + "stops": [ + [ + 16, + "hsl(230, 16%, 94%)" + ], + [ + 16.25, + "hsl(230, 50%, 98%)" + ] + ] + }, + "fill-outline-color": "hsl(230, 26%, 88%)", + "fill-opacity": 1 + } + }, + { + "id": "road-pedestrian-polygon-pattern", + "type": "fill", + "metadata": { + "mapbox:group": "1444855786460.0557" + }, + "source": "composite", + "source-layer": "road", + "minzoom": 12, + "filter": [ + "all", + [ + "==", + "$type", + "Polygon" + ], + [ + "all", + [ + "==", + "structure", + "none" + ], + [ + "in", + "class", + "path", + "pedestrian" + ] + ] + ], + "layout": {}, + "paint": { + "fill-color": "hsl(0, 0%, 100%)", + "fill-outline-color": "hsl(35, 10%, 83%)", + "fill-pattern": "pedestrian-polygon", + "fill-opacity": { + "base": 1, + "stops": [ + [ + 16, + 0 + ], + [ + 16.25, + 1 + ] + ] + } + } + }, + { + "id": "road-polygon", + "type": "fill", + "metadata": { + "mapbox:group": "1444855786460.0557" + }, + "source": "composite", + "source-layer": "road", + "minzoom": 12, + "filter": [ + "all", + [ + "==", + "$type", + "Polygon" + ], + [ + "all", + [ + "!in", + "class", + "motorway", + "path", + "pedestrian", + "trunk" + ], + [ + "!in", + "structure", + "bridge", + "tunnel" + ] + ] + ], + "layout": {}, + "paint": { + "fill-color": "hsl(0, 0%, 100%)", + "fill-outline-color": "#d6d9e6" + } + }, + { + "id": "road-service-link-track", + "type": "line", + "metadata": { + "mapbox:group": "1444855786460.0557" + }, + "source": "composite", + "source-layer": "road", + "minzoom": 14, + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "all", + [ + "!=", + "type", + "trunk_link" + ], + [ + "!in", + "structure", + "bridge", + "tunnel" + ], + [ + "in", + "class", + "link", + "service", + "track" + ] + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-width": { + "base": 1.5, + "stops": [ + [ + 14, + 0.5 + ], + [ + 18, + 12 + ] + ] + }, + "line-color": "hsl(0, 0%, 100%)" + } + }, + { + "id": "road-street_limited", + "type": "line", + "metadata": { + "mapbox:group": "1444855786460.0557" + }, + "source": "composite", + "source-layer": "road", + "minzoom": 11, + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "all", + [ + "==", + "class", + "street_limited" + ], + [ + "==", + "structure", + "none" + ] + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-width": { + "base": 1.5, + "stops": [ + [ + 12.5, + 0.5 + ], + [ + 14, + 2 + ], + [ + 18, + 18 + ] + ] + }, + "line-color": "hsl(35, 14%, 93%)", + "line-opacity": { + "base": 1, + "stops": [ + [ + 13.99, + 0 + ], + [ + 14, + 1 + ] + ] + } + } + }, + { + "id": "road-street", + "type": "line", + "metadata": { + "mapbox:group": "1444855786460.0557" + }, + "source": "composite", + "source-layer": "road", + "minzoom": 11, + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "all", + [ + "==", + "class", + "street" + ], + [ + "==", + "structure", + "none" + ] + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-width": { + "base": 1.5, + "stops": [ + [ + 12.5, + 0.5 + ], + [ + 14, + 2 + ], + [ + 18, + 18 + ] + ] + }, + "line-color": "hsl(0, 0%, 100%)", + "line-opacity": { + "base": 1, + "stops": [ + [ + 13.99, + 0 + ], + [ + 14, + 1 + ] + ] + } + } + }, + { + "id": "road-secondary-tertiary", + "type": "line", + "metadata": { + "mapbox:group": "1444855786460.0557" + }, + "source": "composite", + "source-layer": "road", + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "all", + [ + "!in", + "structure", + "bridge", + "tunnel" + ], + [ + "in", + "class", + "secondary", + "tertiary" + ] + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-width": { + "base": 1.5, + "stops": [ + [ + 8.5, + 0.5 + ], + [ + 10, + 0.75 + ], + [ + 18, + 26 + ] + ] + }, + "line-color": { + "base": 1, + "stops": [ + [ + 5, + "hsl(35, 32%, 91%)" + ], + [ + 8, + "hsl(0, 0%, 100%)" + ] + ] + }, + "line-opacity": { + "base": 1.2, + "stops": [ + [ + 5, + 0 + ], + [ + 5.5, + 1 + ] + ] + } + } + }, + { + "id": "road-primary", + "type": "line", + "metadata": { + "mapbox:group": "1444855786460.0557" + }, + "source": "composite", + "source-layer": "road", + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "all", + [ + "!in", + "structure", + "bridge", + "tunnel" + ], + [ + "==", + "class", + "primary" + ] + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-width": { + "base": 1.5, + "stops": [ + [ + 5, + 0.75 + ], + [ + 18, + 32 + ] + ] + }, + "line-color": { + "base": 1, + "stops": [ + [ + 5, + "hsl(35, 32%, 91%)" + ], + [ + 7, + "hsl(0, 0%, 100%)" + ] + ] + }, + "line-opacity": 1 + } + }, + { + "id": "road-oneway-arrows-blue-minor", + "type": "symbol", + "metadata": { + "mapbox:group": "1444855786460.0557" + }, + "source": "composite", + "source-layer": "road", + "minzoom": 16, + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "all", + [ + "!=", + "type", + "trunk_link" + ], + [ + "!in", + "structure", + "bridge", + "tunnel" + ], + [ + "==", + "oneway", + "true" + ], + [ + "in", + "class", + "link", + "path", + "pedestrian", + "service", + "track" + ] + ] + ], + "layout": { + "symbol-placement": "line", + "icon-image": { + "base": 1, + "stops": [ + [ + 17, + "oneway-small" + ], + [ + 18, + "oneway-large" + ] + ] + }, + "icon-rotation-alignment": "map", + "icon-padding": 2, + "symbol-spacing": 200 + }, + "paint": {} + }, + { + "id": "road-oneway-arrows-blue-major", + "type": "symbol", + "metadata": { + "mapbox:group": "1444855786460.0557" + }, + "source": "composite", + "source-layer": "road", + "minzoom": 15, + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "all", + [ + "!=", + "type", + "trunk_link" + ], + [ + "!in", + "structure", + "bridge", + "tunnel" + ], + [ + "==", + "oneway", + "true" + ], + [ + "in", + "class", + "primary", + "secondary", + "street", + "street_limited", + "tertiary" + ] + ] + ], + "layout": { + "symbol-placement": "line", + "icon-image": { + "base": 1, + "stops": [ + [ + 16, + "oneway-small" + ], + [ + 17, + "oneway-large" + ] + ] + }, + "icon-rotation-alignment": "map", + "icon-padding": 2, + "symbol-spacing": 200 + }, + "paint": {} + }, + { + "id": "road-trunk", + "type": "line", + "metadata": { + "mapbox:group": "1444855786460.0557" + }, + "source": "composite", + "source-layer": "road", + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "all", + [ + "!in", + "structure", + "bridge", + "tunnel" + ], + [ + "==", + "class", + "trunk" + ] + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-width": { + "base": 1.5, + "stops": [ + [ + 5, + 0.75 + ], + [ + 18, + 32 + ] + ] + }, + "line-color": { + "base": 1, + "stops": [ + [ + 6, + "hsl(0, 0%, 100%)" + ], + [ + 6.1, + "hsl(46, 80%, 60%)" + ], + [ + 9, + "hsl(46, 85%, 67%)" + ] + ] + } + } + }, + { + "id": "road-motorway", + "type": "line", + "metadata": { + "mapbox:group": "1444855786460.0557" + }, + "source": "composite", + "source-layer": "road", + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "all", + [ + "!in", + "structure", + "bridge", + "tunnel" + ], + [ + "==", + "class", + "motorway" + ] + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-width": { + "base": 1.5, + "stops": [ + [ + 5, + 0.75 + ], + [ + 18, + 32 + ] + ] + }, + "line-color": { + "base": 1, + "stops": [ + [ + 8, + "hsl(26, 87%, 62%)" + ], + [ + 9, + "hsl(26, 100%, 68%)" + ] + ] + } + } + }, + { + "id": "road-rail", + "type": "line", + "metadata": { + "mapbox:group": "1444855786460.0557" + }, + "source": "composite", + "source-layer": "road", + "minzoom": 13, + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "all", + [ + "!in", + "structure", + "bridge", + "tunnel" + ], + [ + "in", + "class", + "major_rail", + "minor_rail" + ] + ] + ], + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": { + "stops": [ + [ + 13, + "hsl(50, 17%, 82%)" + ], + [ + 16, + "hsl(230, 10%, 74%)" + ] + ] + }, + "line-width": { + "base": 1.5, + "stops": [ + [ + 14, + 0.5 + ], + [ + 20, + 1 + ] + ] + } + } + }, + { + "id": "road-rail-tracks", + "type": "line", + "metadata": { + "mapbox:group": "1444855786460.0557" + }, + "source": "composite", + "source-layer": "road", + "minzoom": 13, + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "all", + [ + "!in", + "structure", + "bridge", + "tunnel" + ], + [ + "in", + "class", + "major_rail", + "minor_rail" + ] + ] + ], + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": { + "stops": [ + [ + 13, + "hsl(50, 17%, 82%)" + ], + [ + 16, + "hsl(230, 10%, 74%)" + ] + ] + }, + "line-width": { + "base": 1.5, + "stops": [ + [ + 14, + 4 + ], + [ + 20, + 8 + ] + ] + }, + "line-dasharray": [ + 0.1, + 15 + ], + "line-opacity": { + "base": 1, + "stops": [ + [ + 13.75, + 0 + ], + [ + 14, + 1 + ] + ] + } + } + }, + { + "id": "level-crossings", + "type": "symbol", + "metadata": { + "mapbox:group": "1444855786460.0557" + }, + "source": "composite", + "source-layer": "road", + "minzoom": 16, + "filter": [ + "all", + [ + "==", + "$type", + "Point" + ], + [ + "==", + "class", + "level_crossing" + ] + ], + "layout": { + "icon-size": 1, + "icon-image": "level-crossing", + "icon-allow-overlap": true + }, + "paint": {} + }, + { + "id": "road-oneway-arrows-white", + "type": "symbol", + "metadata": { + "mapbox:group": "1444855786460.0557" + }, + "source": "composite", + "source-layer": "road", + "minzoom": 16, + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "all", + [ + "!in", + "structure", + "bridge", + "tunnel" + ], + [ + "!in", + "type", + "primary_link", + "secondary_link", + "tertiary_link" + ], + [ + "==", + "oneway", + "true" + ], + [ + "in", + "class", + "link", + "motorway", + "motorway_link", + "trunk" + ] + ] + ], + "layout": { + "symbol-placement": "line", + "icon-image": { + "base": 1, + "stops": [ + [ + 16, + "oneway-white-small" + ], + [ + 17, + "oneway-white-large" + ] + ] + }, + "icon-padding": 2, + "symbol-spacing": 200 + }, + "paint": {} + }, + { + "id": "turning-features", + "type": "symbol", + "metadata": { + "mapbox:group": "1444855786460.0557" + }, + "source": "composite", + "source-layer": "road", + "minzoom": 15, + "filter": [ + "all", + [ + "==", + "$type", + "Point" + ], + [ + "in", + "class", + "turning_circle", + "turning_loop" + ] + ], + "layout": { + "icon-image": "turning-circle", + "icon-size": { + "base": 1.5, + "stops": [ + [ + 14, + 0.095 + ], + [ + 18, + 1 + ] + ] + }, + "icon-allow-overlap": true, + "icon-ignore-placement": true, + "icon-padding": 0, + "icon-rotation-alignment": "map" + }, + "paint": {} + }, + { + "id": "bridge-path-bg", + "type": "line", + "metadata": { + "mapbox:group": "1444855799204.86" + }, + "source": "composite", + "source-layer": "road", + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "all", + [ + "!=", + "type", + "steps" + ], + [ + "==", + "class", + "path" + ], + [ + "==", + "structure", + "bridge" + ] + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-width": { + "base": 1.5, + "stops": [ + [ + 15, + 2 + ], + [ + 18, + 7 + ] + ] + }, + "line-dasharray": [ + 1, + 0 + ], + "line-color": "hsl(230, 17%, 82%)", + "line-blur": 0, + "line-opacity": { + "base": 1, + "stops": [ + [ + 15, + 0 + ], + [ + 15.25, + 1 + ] + ] + } + } + }, + { + "id": "bridge-steps-bg", + "type": "line", + "metadata": { + "mapbox:group": "1444855799204.86" + }, + "source": "composite", + "source-layer": "road", + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "all", + [ + "==", + "structure", + "bridge" + ], + [ + "==", + "type", + "steps" + ] + ] + ], + "layout": { + "line-join": "round" + }, + "paint": { + "line-width": { + "base": 1.5, + "stops": [ + [ + 15, + 2 + ], + [ + 17, + 4.6 + ], + [ + 18, + 7 + ] + ] + }, + "line-color": "hsl(230, 17%, 82%)", + "line-dasharray": [ + 1, + 0 + ], + "line-opacity": { + "base": 1, + "stops": [ + [ + 14, + 0 + ], + [ + 14.25, + 0.75 + ] + ] + } + } + }, + { + "id": "bridge-pedestrian-case", + "type": "line", + "metadata": { + "mapbox:group": "1444855799204.86" + }, + "source": "composite", + "source-layer": "road", + "minzoom": 13, + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "all", + [ + "==", + "class", + "pedestrian" + ], + [ + "==", + "structure", + "bridge" + ] + ] + ], + "layout": { + "line-join": "round" + }, + "paint": { + "line-width": { + "base": 1.5, + "stops": [ + [ + 14, + 2 + ], + [ + 18, + 14.5 + ] + ] + }, + "line-color": "hsl(230, 24%, 87%)", + "line-gap-width": 0, + "line-opacity": { + "base": 1, + "stops": [ + [ + 13.99, + 0 + ], + [ + 14, + 1 + ] + ] + } + } + }, + { + "id": "bridge-street-low", + "type": "line", + "metadata": { + "mapbox:group": "1444855799204.86" + }, + "source": "composite", + "source-layer": "road", + "minzoom": 11, + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "all", + [ + "==", + "class", + "street" + ], + [ + "==", + "structure", + "bridge" + ] + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-width": { + "base": 1.5, + "stops": [ + [ + 12.5, + 0.5 + ], + [ + 14, + 2 + ], + [ + 18, + 18 + ] + ] + }, + "line-color": "hsl(0, 0%, 100%)", + "line-opacity": { + "stops": [ + [ + 11.5, + 0 + ], + [ + 12, + 1 + ], + [ + 14, + 1 + ], + [ + 14.01, + 0 + ] + ] + } + } + }, + { + "id": "bridge-street_limited-low", + "type": "line", + "metadata": { + "mapbox:group": "1444855799204.86" + }, + "source": "composite", + "source-layer": "road", + "minzoom": 11, + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "all", + [ + "==", + "class", + "street_limited" + ], + [ + "==", + "structure", + "bridge" + ] + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-width": { + "base": 1.5, + "stops": [ + [ + 12.5, + 0.5 + ], + [ + 14, + 2 + ], + [ + 18, + 18 + ] + ] + }, + "line-color": "hsl(0, 0%, 100%)", + "line-opacity": { + "stops": [ + [ + 11.5, + 0 + ], + [ + 12, + 1 + ], + [ + 14, + 1 + ], + [ + 14.01, + 0 + ] + ] + } + } + }, + { + "id": "bridge-service-link-track-case", + "type": "line", + "metadata": { + "mapbox:group": "1444855799204.86" + }, + "source": "composite", + "source-layer": "road", + "minzoom": 14, + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "all", + [ + "!=", + "type", + "trunk_link" + ], + [ + "==", + "structure", + "bridge" + ], + [ + "in", + "class", + "link", + "service", + "track" + ] + ] + ], + "layout": { + "line-join": "round" + }, + "paint": { + "line-width": { + "base": 1.5, + "stops": [ + [ + 12, + 0.75 + ], + [ + 20, + 2 + ] + ] + }, + "line-color": "hsl(230, 24%, 87%)", + "line-gap-width": { + "base": 1.5, + "stops": [ + [ + 14, + 0.5 + ], + [ + 18, + 12 + ] + ] + } + } + }, + { + "id": "bridge-street_limited-case", + "type": "line", + "metadata": { + "mapbox:group": "1444855799204.86" + }, + "source": "composite", + "source-layer": "road", + "minzoom": 11, + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "all", + [ + "==", + "class", + "street_limited" + ], + [ + "==", + "structure", + "bridge" + ] + ] + ], + "layout": { + "line-join": "round" + }, + "paint": { + "line-width": { + "base": 1.5, + "stops": [ + [ + 12, + 0.75 + ], + [ + 20, + 2 + ] + ] + }, + "line-color": "hsl(230, 24%, 87%)", + "line-gap-width": { + "base": 1.5, + "stops": [ + [ + 13, + 0 + ], + [ + 14, + 2 + ], + [ + 18, + 18 + ] + ] + } + } + }, + { + "id": "bridge-street-case", + "type": "line", + "metadata": { + "mapbox:group": "1444855799204.86" + }, + "source": "composite", + "source-layer": "road", + "minzoom": 11, + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "all", + [ + "==", + "class", + "street" + ], + [ + "==", + "structure", + "bridge" + ] + ] + ], + "layout": { + "line-join": "round" + }, + "paint": { + "line-width": { + "base": 1.5, + "stops": [ + [ + 12, + 0.75 + ], + [ + 20, + 2 + ] + ] + }, + "line-color": "hsl(230, 24%, 87%)", + "line-opacity": { + "base": 1, + "stops": [ + [ + 13.99, + 0 + ], + [ + 14, + 1 + ] + ] + }, + "line-gap-width": { + "base": 1.5, + "stops": [ + [ + 13, + 0 + ], + [ + 14, + 2 + ], + [ + 18, + 18 + ] + ] + } + } + }, + { + "id": "bridge-secondary-tertiary-case", + "type": "line", + "metadata": { + "mapbox:group": "1444855799204.86" + }, + "source": "composite", + "source-layer": "road", + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "all", + [ + "==", + "structure", + "bridge" + ], + [ + "in", + "class", + "secondary", + "tertiary" + ] + ] + ], + "layout": { + "line-join": "round" + }, + "paint": { + "line-width": { + "base": 1.2, + "stops": [ + [ + 10, + 0.75 + ], + [ + 18, + 2 + ] + ] + }, + "line-color": "hsl(230, 24%, 87%)", + "line-gap-width": { + "base": 1.5, + "stops": [ + [ + 8.5, + 0.5 + ], + [ + 10, + 0.75 + ], + [ + 18, + 26 + ] + ] + }, + "line-translate": [ + 0, + 0 + ] + } + }, + { + "id": "bridge-primary-case", + "type": "line", + "metadata": { + "mapbox:group": "1444855799204.86" + }, + "source": "composite", + "source-layer": "road", + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "all", + [ + "==", + "class", + "primary" + ], + [ + "==", + "structure", + "bridge" + ] + ] + ], + "layout": { + "line-join": "round" + }, + "paint": { + "line-width": { + "base": 1.5, + "stops": [ + [ + 10, + 1 + ], + [ + 16, + 2 + ] + ] + }, + "line-color": "hsl(230, 24%, 87%)", + "line-gap-width": { + "base": 1.5, + "stops": [ + [ + 5, + 0.75 + ], + [ + 18, + 32 + ] + ] + }, + "line-translate": [ + 0, + 0 + ] + } + }, + { + "id": "bridge-trunk_link-case", + "type": "line", + "metadata": { + "mapbox:group": "1444855799204.86" + }, + "source": "composite", + "source-layer": "road", + "minzoom": 13, + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "all", + [ + "!in", + "layer", + 2, + 3, + 4, + 5 + ], + [ + "==", + "structure", + "bridge" + ], + [ + "==", + "type", + "trunk_link" + ] + ] + ], + "layout": { + "line-join": "round" + }, + "paint": { + "line-width": { + "base": 1.5, + "stops": [ + [ + 12, + 0.75 + ], + [ + 20, + 2 + ] + ] + }, + "line-color": "hsl(0, 0%, 100%)", + "line-gap-width": { + "base": 1.5, + "stops": [ + [ + 12, + 0.5 + ], + [ + 14, + 2 + ], + [ + 18, + 18 + ] + ] + }, + "line-opacity": { + "base": 1, + "stops": [ + [ + 10.99, + 0 + ], + [ + 11, + 1 + ] + ] + } + } + }, + { + "id": "bridge-motorway_link-case", + "type": "line", + "metadata": { + "mapbox:group": "1444855799204.86" + }, + "source": "composite", + "source-layer": "road", + "minzoom": 13, + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "all", + [ + "!in", + "layer", + 2, + 3, + 4, + 5 + ], + [ + "==", + "class", + "motorway_link" + ], + [ + "==", + "structure", + "bridge" + ] + ] + ], + "layout": { + "line-join": "round" + }, + "paint": { + "line-width": { + "base": 1.5, + "stops": [ + [ + 12, + 0.75 + ], + [ + 20, + 2 + ] + ] + }, + "line-color": "hsl(0, 0%, 100%)", + "line-gap-width": { + "base": 1.5, + "stops": [ + [ + 12, + 0.5 + ], + [ + 14, + 2 + ], + [ + 18, + 18 + ] + ] + }, + "line-opacity": 1 + } + }, + { + "id": "bridge-trunk-case", + "type": "line", + "metadata": { + "mapbox:group": "1444855799204.86" + }, + "source": "composite", + "source-layer": "road", + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "all", + [ + "!in", + "layer", + 2, + 3, + 4, + 5 + ], + [ + "==", + "class", + "trunk" + ], + [ + "==", + "structure", + "bridge" + ] + ] + ], + "layout": { + "line-join": "round" + }, + "paint": { + "line-width": { + "base": 1.5, + "stops": [ + [ + 10, + 1 + ], + [ + 16, + 2 + ] + ] + }, + "line-color": "hsl(0, 0%, 100%)", + "line-gap-width": { + "base": 1.5, + "stops": [ + [ + 5, + 0.75 + ], + [ + 18, + 32 + ] + ] + } + } + }, + { + "id": "bridge-motorway-case", + "type": "line", + "metadata": { + "mapbox:group": "1444855799204.86" + }, + "source": "composite", + "source-layer": "road", + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "all", + [ + "!in", + "layer", + 2, + 3, + 4, + 5 + ], + [ + "==", + "class", + "motorway" + ], + [ + "==", + "structure", + "bridge" + ] + ] + ], + "layout": { + "line-join": "round" + }, + "paint": { + "line-width": { + "base": 1.5, + "stops": [ + [ + 10, + 1 + ], + [ + 16, + 2 + ] + ] + }, + "line-color": "hsl(0, 0%, 100%)", + "line-gap-width": { + "base": 1.5, + "stops": [ + [ + 5, + 0.75 + ], + [ + 18, + 32 + ] + ] + } + } + }, + { + "id": "bridge-construction", + "type": "line", + "metadata": { + "mapbox:group": "1444855799204.86" + }, + "source": "composite", + "source-layer": "road", + "minzoom": 14, + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "all", + [ + "==", + "class", + "construction" + ], + [ + "==", + "structure", + "bridge" + ] + ] + ], + "layout": { + "line-join": "miter" + }, + "paint": { + "line-width": { + "base": 1.5, + "stops": [ + [ + 12.5, + 0.5 + ], + [ + 14, + 2 + ], + [ + 18, + 18 + ] + ] + }, + "line-color": "hsl(230, 24%, 87%)", + "line-opacity": { + "base": 1, + "stops": [ + [ + 13.99, + 0 + ], + [ + 14, + 1 + ] + ] + }, + "line-dasharray": { + "base": 1, + "stops": [ + [ + 14, + [ + 0.4, + 0.8 + ] + ], + [ + 15, + [ + 0.3, + 0.6 + ] + ], + [ + 16, + [ + 0.2, + 0.3 + ] + ], + [ + 17, + [ + 0.2, + 0.25 + ] + ], + [ + 18, + [ + 0.15, + 0.15 + ] + ] + ] + } + } + }, + { + "id": "bridge-path", + "type": "line", + "metadata": { + "mapbox:group": "1444855799204.86" + }, + "source": "composite", + "source-layer": "road", + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "all", + [ + "!=", + "type", + "steps" + ], + [ + "==", + "class", + "path" + ], + [ + "==", + "structure", + "bridge" + ] + ] + ], + "layout": { + "line-join": "round" + }, + "paint": { + "line-width": { + "base": 1.5, + "stops": [ + [ + 15, + 1 + ], + [ + 18, + 4 + ] + ] + }, + "line-color": "hsl(0, 0%, 100%)", + "line-dasharray": { + "base": 1, + "stops": [ + [ + 14, + [ + 1, + 0 + ] + ], + [ + 15, + [ + 1.75, + 1 + ] + ], + [ + 16, + [ + 1, + 0.75 + ] + ], + [ + 17, + [ + 1, + 0.5 + ] + ] + ] + }, + "line-opacity": { + "base": 1, + "stops": [ + [ + 14, + 0 + ], + [ + 14.25, + 1 + ] + ] + } + } + }, + { + "id": "bridge-steps", + "type": "line", + "metadata": { + "mapbox:group": "1444855799204.86" + }, + "source": "composite", + "source-layer": "road", + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "all", + [ + "==", + "structure", + "bridge" + ], + [ + "==", + "type", + "steps" + ] + ] + ], + "layout": { + "line-join": "round" + }, + "paint": { + "line-width": { + "base": 1.5, + "stops": [ + [ + 15, + 1 + ], + [ + 16, + 1.6 + ], + [ + 18, + 6 + ] + ] + }, + "line-color": "hsl(0, 0%, 100%)", + "line-dasharray": { + "base": 1, + "stops": [ + [ + 14, + [ + 1, + 0 + ] + ], + [ + 15, + [ + 1.75, + 1 + ] + ], + [ + 16, + [ + 1, + 0.75 + ] + ], + [ + 17, + [ + 0.3, + 0.3 + ] + ] + ] + }, + "line-opacity": { + "base": 1, + "stops": [ + [ + 14, + 0 + ], + [ + 14.25, + 1 + ] + ] + } + } + }, + { + "id": "bridge-trunk_link", + "type": "line", + "metadata": { + "mapbox:group": "1444855799204.86" + }, + "source": "composite", + "source-layer": "road", + "minzoom": 13, + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "all", + [ + "!in", + "layer", + 2, + 3, + 4, + 5 + ], + [ + "==", + "structure", + "bridge" + ], + [ + "==", + "type", + "trunk_link" + ] + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-width": { + "base": 1.5, + "stops": [ + [ + 12, + 0.5 + ], + [ + 14, + 2 + ], + [ + 18, + 18 + ] + ] + }, + "line-color": "hsl(46, 85%, 67%)" + } + }, + { + "id": "bridge-motorway_link", + "type": "line", + "metadata": { + "mapbox:group": "1444855799204.86" + }, + "source": "composite", + "source-layer": "road", + "minzoom": 13, + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "all", + [ + "!in", + "layer", + 2, + 3, + 4, + 5 + ], + [ + "==", + "class", + "motorway_link" + ], + [ + "==", + "structure", + "bridge" + ] + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-width": { + "base": 1.5, + "stops": [ + [ + 12, + 0.5 + ], + [ + 14, + 2 + ], + [ + 18, + 18 + ] + ] + }, + "line-color": "hsl(26, 100%, 68%)" + } + }, + { + "id": "bridge-pedestrian", + "type": "line", + "metadata": { + "mapbox:group": "1444855799204.86" + }, + "source": "composite", + "source-layer": "road", + "minzoom": 13, + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "all", + [ + "==", + "class", + "pedestrian" + ], + [ + "==", + "structure", + "bridge" + ] + ] + ], + "layout": { + "line-join": "round" + }, + "paint": { + "line-width": { + "base": 1.5, + "stops": [ + [ + 14, + 0.5 + ], + [ + 18, + 12 + ] + ] + }, + "line-color": "hsl(0, 0%, 100%)", + "line-opacity": 1, + "line-dasharray": { + "base": 1, + "stops": [ + [ + 14, + [ + 1, + 0 + ] + ], + [ + 15, + [ + 1.5, + 0.4 + ] + ], + [ + 16, + [ + 1, + 0.2 + ] + ] + ] + } + } + }, + { + "id": "bridge-service-link-track", + "type": "line", + "metadata": { + "mapbox:group": "1444855799204.86" + }, + "source": "composite", + "source-layer": "road", + "minzoom": 14, + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "all", + [ + "!=", + "type", + "trunk_link" + ], + [ + "==", + "structure", + "bridge" + ], + [ + "in", + "class", + "link", + "service", + "track" + ] + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-width": { + "base": 1.5, + "stops": [ + [ + 14, + 0.5 + ], + [ + 18, + 12 + ] + ] + }, + "line-color": "hsl(0, 0%, 100%)" + } + }, + { + "id": "bridge-street_limited", + "type": "line", + "metadata": { + "mapbox:group": "1444855799204.86" + }, + "source": "composite", + "source-layer": "road", + "minzoom": 11, + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "all", + [ + "==", + "class", + "street_limited" + ], + [ + "==", + "structure", + "bridge" + ] + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-width": { + "base": 1.5, + "stops": [ + [ + 12.5, + 0.5 + ], + [ + 14, + 2 + ], + [ + 18, + 18 + ] + ] + }, + "line-color": "hsl(35, 14%, 93%)", + "line-opacity": { + "base": 1, + "stops": [ + [ + 13.99, + 0 + ], + [ + 14, + 1 + ] + ] + } + } + }, + { + "id": "bridge-street", + "type": "line", + "metadata": { + "mapbox:group": "1444855799204.86" + }, + "source": "composite", + "source-layer": "road", + "minzoom": 11, + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "all", + [ + "==", + "class", + "street" + ], + [ + "==", + "structure", + "bridge" + ] + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-width": { + "base": 1.5, + "stops": [ + [ + 12.5, + 0.5 + ], + [ + 14, + 2 + ], + [ + 18, + 18 + ] + ] + }, + "line-color": "hsl(0, 0%, 100%)", + "line-opacity": { + "base": 1, + "stops": [ + [ + 13.99, + 0 + ], + [ + 14, + 1 + ] + ] + } + } + }, + { + "id": "bridge-secondary-tertiary", + "type": "line", + "metadata": { + "mapbox:group": "1444855799204.86" + }, + "source": "composite", + "source-layer": "road", + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "all", + [ + "==", + "structure", + "bridge" + ], + [ + "in", + "type", + "secondary", + "tertiary" + ] + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-width": { + "base": 1.5, + "stops": [ + [ + 8.5, + 0.5 + ], + [ + 10, + 0.75 + ], + [ + 18, + 26 + ] + ] + }, + "line-color": "hsl(0, 0%, 100%)", + "line-opacity": { + "base": 1.2, + "stops": [ + [ + 5, + 0 + ], + [ + 5.5, + 1 + ] + ] + } + } + }, + { + "id": "bridge-primary", + "type": "line", + "metadata": { + "mapbox:group": "1444855799204.86" + }, + "source": "composite", + "source-layer": "road", + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "all", + [ + "==", + "structure", + "bridge" + ], + [ + "==", + "type", + "primary" + ] + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-width": { + "base": 1.5, + "stops": [ + [ + 5, + 0.75 + ], + [ + 18, + 32 + ] + ] + }, + "line-color": "hsl(0, 0%, 100%)", + "line-opacity": 1 + } + }, + { + "id": "bridge-oneway-arrows-blue-minor", + "type": "symbol", + "metadata": { + "mapbox:group": "1444855799204.86" + }, + "source": "composite", + "source-layer": "road", + "minzoom": 16, + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "all", + [ + "==", + "oneway", + "true" + ], + [ + "==", + "structure", + "bridge" + ], + [ + "in", + "class", + "link", + "path", + "pedestrian", + "service", + "track" + ] + ] + ], + "layout": { + "symbol-placement": "line", + "icon-image": { + "base": 1, + "stops": [ + [ + 17, + "oneway-small" + ], + [ + 18, + "oneway-large" + ] + ] + }, + "symbol-spacing": 200, + "icon-rotation-alignment": "map", + "icon-padding": 2 + }, + "paint": {} + }, + { + "id": "bridge-oneway-arrows-blue-major", + "type": "symbol", + "metadata": { + "mapbox:group": "1444855799204.86" + }, + "source": "composite", + "source-layer": "road", + "minzoom": 15, + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "all", + [ + "==", + "oneway", + "true" + ], + [ + "==", + "structure", + "bridge" + ], + [ + "in", + "class", + "primary", + "secondary", + "street", + "street_limited", + "tertiary" + ] + ] + ], + "layout": { + "symbol-placement": "line", + "icon-image": { + "base": 1, + "stops": [ + [ + 16, + "oneway-small" + ], + [ + 17, + "oneway-large" + ] + ] + }, + "symbol-spacing": 200, + "icon-rotation-alignment": "map", + "icon-padding": 2 + }, + "paint": {} + }, + { + "id": "bridge-trunk", + "type": "line", + "metadata": { + "mapbox:group": "1444855799204.86" + }, + "source": "composite", + "source-layer": "road", + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "all", + [ + "!in", + "layer", + 2, + 3, + 4, + 5 + ], + [ + "==", + "class", + "trunk" + ], + [ + "==", + "structure", + "bridge" + ] + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-width": { + "base": 1.5, + "stops": [ + [ + 5, + 0.75 + ], + [ + 18, + 32 + ] + ] + }, + "line-color": "hsl(46, 85%, 67%)" + } + }, + { + "id": "bridge-motorway", + "type": "line", + "metadata": { + "mapbox:group": "1444855799204.86" + }, + "source": "composite", + "source-layer": "road", + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "all", + [ + "!in", + "layer", + 2, + 3, + 4, + 5 + ], + [ + "==", + "class", + "motorway" + ], + [ + "==", + "structure", + "bridge" + ] + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-width": { + "base": 1.5, + "stops": [ + [ + 5, + 0.75 + ], + [ + 18, + 32 + ] + ] + }, + "line-color": "hsl(26, 100%, 68%)" + } + }, + { + "id": "bridge-rail", + "type": "line", + "metadata": { + "mapbox:group": "1444855799204.86" + }, + "source": "composite", + "source-layer": "road", + "minzoom": 13, + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "all", + [ + "==", + "structure", + "bridge" + ], + [ + "in", + "class", + "major_rail", + "minor_rail" + ] + ] + ], + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": { + "stops": [ + [ + 13, + "hsl(50, 17%, 82%)" + ], + [ + 16, + "hsl(230, 10%, 74%)" + ] + ] + }, + "line-width": { + "base": 1.5, + "stops": [ + [ + 14, + 0.5 + ], + [ + 20, + 1 + ] + ] + } + } + }, + { + "id": "bridge-rail-tracks", + "type": "line", + "metadata": { + "mapbox:group": "1444855799204.86" + }, + "source": "composite", + "source-layer": "road", + "minzoom": 13, + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "all", + [ + "==", + "structure", + "bridge" + ], + [ + "in", + "class", + "major_rail", + "minor_rail" + ] + ] + ], + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": { + "stops": [ + [ + 13, + "hsl(50, 17%, 82%)" + ], + [ + 16, + "hsl(230, 10%, 74%)" + ] + ] + }, + "line-width": { + "base": 1.5, + "stops": [ + [ + 14, + 4 + ], + [ + 20, + 8 + ] + ] + }, + "line-dasharray": [ + 0.1, + 15 + ], + "line-opacity": { + "base": 1, + "stops": [ + [ + 13.75, + 0 + ], + [ + 20, + 1 + ] + ] + } + } + }, + { + "id": "bridge-trunk_link-2-case", + "type": "line", + "metadata": { + "mapbox:group": "1444855799204.86" + }, + "source": "composite", + "source-layer": "road", + "minzoom": 13, + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "all", + [ + "==", + "structure", + "bridge" + ], + [ + "==", + "type", + "trunk_link" + ], + [ + ">=", + "layer", + 2 + ] + ] + ], + "layout": { + "line-join": "round" + }, + "paint": { + "line-width": { + "base": 1.5, + "stops": [ + [ + 12, + 0.75 + ], + [ + 20, + 2 + ] + ] + }, + "line-color": "hsl(0, 0%, 100%)", + "line-gap-width": { + "base": 1.5, + "stops": [ + [ + 12, + 0.5 + ], + [ + 14, + 2 + ], + [ + 18, + 18 + ] + ] + }, + "line-opacity": { + "base": 1, + "stops": [ + [ + 10.99, + 0 + ], + [ + 11, + 1 + ] + ] + } + } + }, + { + "id": "bridge-motorway_link-2-case", + "type": "line", + "metadata": { + "mapbox:group": "1444855799204.86" + }, + "source": "composite", + "source-layer": "road", + "minzoom": 13, + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "all", + [ + "==", + "class", + "motorway_link" + ], + [ + "==", + "structure", + "bridge" + ], + [ + ">=", + "layer", + 2 + ] + ] + ], + "layout": { + "line-join": "round" + }, + "paint": { + "line-width": { + "base": 1.5, + "stops": [ + [ + 12, + 0.75 + ], + [ + 20, + 2 + ] + ] + }, + "line-color": "hsl(0, 0%, 100%)", + "line-gap-width": { + "base": 1.5, + "stops": [ + [ + 12, + 0.5 + ], + [ + 14, + 2 + ], + [ + 18, + 18 + ] + ] + }, + "line-opacity": 1 + } + }, + { + "id": "bridge-trunk-2-case", + "type": "line", + "metadata": { + "mapbox:group": "1444855799204.86" + }, + "source": "composite", + "source-layer": "road", + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "all", + [ + "==", + "class", + "trunk" + ], + [ + "==", + "structure", + "bridge" + ], + [ + ">=", + "layer", + 2 + ] + ] + ], + "layout": { + "line-join": "round" + }, + "paint": { + "line-width": { + "base": 1.5, + "stops": [ + [ + 10, + 1 + ], + [ + 16, + 2 + ] + ] + }, + "line-color": "hsl(0, 0%, 100%)", + "line-gap-width": { + "base": 1.5, + "stops": [ + [ + 5, + 0.75 + ], + [ + 18, + 32 + ] + ] + } + } + }, + { + "id": "bridge-motorway-2-case", + "type": "line", + "metadata": { + "mapbox:group": "1444855799204.86" + }, + "source": "composite", + "source-layer": "road", + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "all", + [ + "==", + "class", + "motorway" + ], + [ + "==", + "structure", + "bridge" + ], + [ + ">=", + "layer", + 2 + ] + ] + ], + "layout": { + "line-join": "round" + }, + "paint": { + "line-width": { + "base": 1.5, + "stops": [ + [ + 10, + 1 + ], + [ + 16, + 2 + ] + ] + }, + "line-color": "hsl(0, 0%, 100%)", + "line-gap-width": { + "base": 1.5, + "stops": [ + [ + 5, + 0.75 + ], + [ + 18, + 32 + ] + ] + } + } + }, + { + "id": "bridge-trunk_link-2", + "type": "line", + "metadata": { + "mapbox:group": "1444855799204.86" + }, + "source": "composite", + "source-layer": "road", + "minzoom": 13, + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "all", + [ + "==", + "structure", + "bridge" + ], + [ + "==", + "type", + "trunk_link" + ], + [ + ">=", + "layer", + 2 + ] + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-width": { + "base": 1.5, + "stops": [ + [ + 12, + 0.5 + ], + [ + 14, + 2 + ], + [ + 18, + 18 + ] + ] + }, + "line-color": "hsl(46, 85%, 67%)" + } + }, + { + "id": "bridge-motorway_link-2", + "type": "line", + "metadata": { + "mapbox:group": "1444855799204.86" + }, + "source": "composite", + "source-layer": "road", + "minzoom": 13, + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "all", + [ + "==", + "class", + "motorway_link" + ], + [ + "==", + "structure", + "bridge" + ], + [ + ">=", + "layer", + 2 + ] + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-width": { + "base": 1.5, + "stops": [ + [ + 12, + 0.5 + ], + [ + 14, + 2 + ], + [ + 18, + 18 + ] + ] + }, + "line-color": "hsl(26, 100%, 68%)" + } + }, + { + "id": "bridge-trunk-2", + "type": "line", + "metadata": { + "mapbox:group": "1444855799204.86" + }, + "source": "composite", + "source-layer": "road", + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "all", + [ + "==", + "class", + "trunk" + ], + [ + "==", + "structure", + "bridge" + ], + [ + ">=", + "layer", + 2 + ] + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-width": { + "base": 1.5, + "stops": [ + [ + 5, + 0.75 + ], + [ + 18, + 32 + ] + ] + }, + "line-color": "hsl(46, 85%, 67%)" + } + }, + { + "id": "bridge-motorway-2", + "type": "line", + "metadata": { + "mapbox:group": "1444855799204.86" + }, + "source": "composite", + "source-layer": "road", + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "all", + [ + "==", + "class", + "motorway" + ], + [ + "==", + "structure", + "bridge" + ], + [ + ">=", + "layer", + 2 + ] + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-width": { + "base": 1.5, + "stops": [ + [ + 5, + 0.75 + ], + [ + 18, + 32 + ] + ] + }, + "line-color": "hsl(26, 100%, 68%)" + } + }, + { + "id": "bridge-oneway-arrows-white", + "type": "symbol", + "metadata": { + "mapbox:group": "1444855799204.86" + }, + "source": "composite", + "source-layer": "road", + "minzoom": 16, + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "all", + [ + "!in", + "type", + "primary_link", + "secondary_link", + "tertiary_link" + ], + [ + "==", + "oneway", + "true" + ], + [ + "==", + "structure", + "bridge" + ], + [ + "in", + "class", + "link", + "motorway", + "motorway_link", + "trunk" + ] + ] + ], + "layout": { + "symbol-placement": "line", + "icon-image": { + "base": 1, + "stops": [ + [ + 16, + "oneway-white-small" + ], + [ + 17, + "oneway-white-large" + ] + ] + }, + "symbol-spacing": 200, + "icon-padding": 2 + }, + "paint": {} + }, + { + "id": "aerialway", + "type": "line", + "source": "composite", + "source-layer": "road", + "minzoom": 13, + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "==", + "class", + "aerialway" + ] + ], + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "hsl(230, 10%, 74%)", + "line-width": { + "base": 1.5, + "stops": [ + [ + 14, + 0.5 + ], + [ + 20, + 1 + ] + ] + } + } + }, + { + "id": "admin-3-4-boundaries-bg", + "type": "line", + "metadata": { + "mapbox:group": "1444934295202.7542" + }, + "source": "composite", + "source-layer": "admin", + "filter": [ + "all", + [ + "==", + "maritime", + 0 + ], + [ + ">=", + "admin_level", + 3 + ] + ], + "layout": { + "line-join": "bevel" + }, + "paint": { + "line-color": { + "base": 1, + "stops": [ + [ + 8, + "hsl(35, 12%, 89%)" + ], + [ + 16, + "hsl(230, 49%, 90%)" + ] + ] + }, + "line-width": { + "base": 1, + "stops": [ + [ + 7, + 3.75 + ], + [ + 12, + 5.5 + ] + ] + }, + "line-opacity": { + "base": 1, + "stops": [ + [ + 7, + 0 + ], + [ + 8, + 0.75 + ] + ] + }, + "line-dasharray": [ + 1, + 0 + ], + "line-translate": [ + 0, + 0 + ], + "line-blur": { + "base": 1, + "stops": [ + [ + 3, + 0 + ], + [ + 8, + 3 + ] + ] + } + } + }, + { + "id": "admin-2-boundaries-bg", + "type": "line", + "metadata": { + "mapbox:group": "1444934295202.7542" + }, + "source": "composite", + "source-layer": "admin", + "minzoom": 1, + "filter": [ + "all", + [ + "==", + "admin_level", + 2 + ], + [ + "==", + "maritime", + 0 + ] + ], + "layout": { + "line-join": "miter" + }, + "paint": { + "line-width": { + "base": 1, + "stops": [ + [ + 3, + 3.5 + ], + [ + 10, + 8 + ] + ] + }, + "line-color": { + "base": 1, + "stops": [ + [ + 6, + "hsl(35, 12%, 89%)" + ], + [ + 8, + "hsl(230, 49%, 90%)" + ] + ] + }, + "line-opacity": { + "base": 1, + "stops": [ + [ + 3, + 0 + ], + [ + 4, + 0.5 + ] + ] + }, + "line-translate": [ + 0, + 0 + ], + "line-blur": { + "base": 1, + "stops": [ + [ + 3, + 0 + ], + [ + 10, + 2 + ] + ] + } + } + }, + { + "id": "admin-3-4-boundaries", + "type": "line", + "metadata": { + "mapbox:group": "1444934295202.7542" + }, + "source": "composite", + "source-layer": "admin", + "filter": [ + "all", + [ + "==", + "maritime", + 0 + ], + [ + ">=", + "admin_level", + 3 + ] + ], + "layout": { + "line-join": "round", + "line-cap": "round" + }, + "paint": { + "line-dasharray": { + "base": 1, + "stops": [ + [ + 6, + [ + 2, + 0 + ] + ], + [ + 7, + [ + 2, + 2, + 6, + 2 + ] + ] + ] + }, + "line-width": { + "base": 1, + "stops": [ + [ + 7, + 0.75 + ], + [ + 12, + 1.5 + ] + ] + }, + "line-opacity": { + "base": 1, + "stops": [ + [ + 2, + 0 + ], + [ + 3, + 1 + ] + ] + }, + "line-color": { + "base": 1, + "stops": [ + [ + 3, + "hsl(230, 14%, 77%)" + ], + [ + 7, + "hsl(230, 8%, 62%)" + ] + ] + } + } + }, + { + "id": "admin-2-boundaries", + "type": "line", + "metadata": { + "mapbox:group": "1444934295202.7542" + }, + "source": "composite", + "source-layer": "admin", + "minzoom": 1, + "filter": [ + "all", + [ + "==", + "admin_level", + 2 + ], + [ + "==", + "disputed", + 0 + ], + [ + "==", + "maritime", + 0 + ] + ], + "layout": { + "line-join": "round", + "line-cap": "round" + }, + "paint": { + "line-color": "hsl(230, 8%, 51%)", + "line-width": { + "base": 1, + "stops": [ + [ + 3, + 0.5 + ], + [ + 10, + 2 + ] + ] + } + } + }, + { + "id": "admin-2-boundaries-dispute", + "type": "line", + "metadata": { + "mapbox:group": "1444934295202.7542" + }, + "source": "composite", + "source-layer": "admin", + "minzoom": 1, + "filter": [ + "all", + [ + "==", + "admin_level", + 2 + ], + [ + "==", + "disputed", + 1 + ], + [ + "==", + "maritime", + 0 + ] + ], + "layout": { + "line-join": "round" + }, + "paint": { + "line-dasharray": [ + 1.5, + 1.5 + ], + "line-color": "hsl(230, 8%, 51%)", + "line-width": { + "base": 1, + "stops": [ + [ + 3, + 0.5 + ], + [ + 10, + 2 + ] + ] + } + } + }, + { + "id": "housenum-label", + "type": "symbol", + "source": "composite", + "source-layer": "housenum_label", + "minzoom": 17, + "layout": { + "text-field": "{house_num}", + "text-font": [ + "DIN Offc Pro Italic", + "Arial Unicode MS Regular" + ], + "text-padding": 4, + "text-max-width": 7, + "text-size": 9.5 + }, + "paint": { + "text-color": "hsl(35, 2%, 69%)", + "text-halo-color": "hsl(35, 8%, 85%)", + "text-halo-width": 0.5, + "text-halo-blur": 0 + } + }, + { + "id": "waterway-label", + "type": "symbol", + "source": "composite", + "source-layer": "waterway_label", + "minzoom": 12, + "filter": [ + "in", + "class", + "canal", + "river" + ], + "layout": { + "text-field": "{name_en}", + "text-font": [ + "DIN Offc Pro Italic", + "Arial Unicode MS Regular" + ], + "symbol-placement": "line", + "text-pitch-alignment": "viewport", + "text-max-angle": 30, + "text-size": { + "base": 1, + "stops": [ + [ + 13, + 12 + ], + [ + 18, + 16 + ] + ] + } + }, + "paint": { + "text-halo-width": 0.5, + "text-halo-color": "hsl(196, 80%, 70%)", + "text-color": "hsl(230, 48%, 44%)", + "text-halo-blur": 0.5 + } + }, + { + "id": "poi-scalerank4-l15", + "type": "symbol", + "metadata": { + "mapbox:group": "1444933456003.5437" + }, + "source": "composite", + "source-layer": "poi_label", + "minzoom": 17, + "filter": [ + "all", + [ + "!in", + "maki", + "campsite", + "cemetery", + "dog-park", + "garden", + "golf", + "park", + "picnic-site", + "playground", + "zoo" + ], + [ + "==", + "scalerank", + 4 + ], + [ + ">=", + "localrank", + 15 + ] + ], + "layout": { + "text-line-height": 1.1, + "text-size": { + "base": 1, + "stops": [ + [ + 16, + 11 + ], + [ + 20, + 13 + ] + ] + }, + "icon-image": "{maki}-11", + "text-max-angle": 38, + "symbol-spacing": 250, + "text-font": [ + "DIN Offc Pro Medium", + "Arial Unicode MS Regular" + ], + "text-padding": 2, + "text-offset": [ + 0, + 0.65 + ], + "text-rotation-alignment": "viewport", + "text-anchor": "top", + "text-field": "{name_en}", + "text-letter-spacing": 0.01, + "text-max-width": 8 + }, + "paint": { + "text-color": "hsl(26, 25%, 32%)", + "text-halo-color": "hsl(0, 0%, 100%)", + "text-halo-width": 0.5, + "text-halo-blur": 0.5 + } + }, + { + "id": "poi-scalerank4-l1", + "type": "symbol", + "metadata": { + "mapbox:group": "1444933456003.5437" + }, + "source": "composite", + "source-layer": "poi_label", + "minzoom": 15, + "filter": [ + "all", + [ + "!in", + "maki", + "campsite", + "cemetery", + "dog-park", + "garden", + "golf", + "park", + "picnic-site", + "playground", + "zoo" + ], + [ + "<=", + "localrank", + 14 + ], + [ + "==", + "scalerank", + 4 + ] + ], + "layout": { + "text-line-height": 1.1, + "text-size": { + "base": 1, + "stops": [ + [ + 16, + 11 + ], + [ + 20, + 13 + ] + ] + }, + "icon-image": "{maki}-11", + "text-max-angle": 38, + "symbol-spacing": 250, + "text-font": [ + "DIN Offc Pro Medium", + "Arial Unicode MS Regular" + ], + "text-padding": 1, + "text-offset": [ + 0, + 0.65 + ], + "text-rotation-alignment": "viewport", + "text-anchor": "top", + "text-field": "{name_en}", + "text-letter-spacing": 0.01, + "text-max-width": 8 + }, + "paint": { + "text-color": "hsl(26, 25%, 32%)", + "text-halo-color": "hsl(0, 0%, 100%)", + "text-halo-width": 0.5, + "text-halo-blur": 0.5 + } + }, + { + "id": "poi-parks_scalerank4", + "type": "symbol", + "metadata": { + "mapbox:group": "1444933456003.5437" + }, + "source": "composite", + "source-layer": "poi_label", + "minzoom": 15, + "filter": [ + "all", + [ + "==", + "scalerank", + 4 + ], + [ + "in", + "maki", + "campsite", + "cemetery", + "dog-park", + "garden", + "golf", + "park", + "picnic-site", + "playground", + "zoo" + ] + ], + "layout": { + "text-line-height": 1.1, + "text-size": { + "base": 1, + "stops": [ + [ + 16, + 11 + ], + [ + 20, + 13 + ] + ] + }, + "icon-image": "{maki}-11", + "text-max-angle": 38, + "symbol-spacing": 250, + "text-font": [ + "DIN Offc Pro Medium", + "Arial Unicode MS Regular" + ], + "text-padding": 1, + "text-offset": [ + 0, + 0.65 + ], + "text-rotation-alignment": "viewport", + "text-anchor": "top", + "text-field": "{name_en}", + "text-letter-spacing": 0.01, + "text-max-width": 8 + }, + "paint": { + "text-color": "hsl(100, 100%, 20%)", + "text-halo-color": "hsl(0, 0%, 100%)", + "text-halo-width": 0.5, + "text-halo-blur": 0.5 + } + }, + { + "id": "poi-scalerank3", + "type": "symbol", + "metadata": { + "mapbox:group": "1444933372896.5967" + }, + "source": "composite", + "source-layer": "poi_label", + "filter": [ + "all", + [ + "!in", + "maki", + "campsite", + "cemetery", + "dog-park", + "garden", + "golf", + "park", + "picnic-site", + "playground", + "zoo" + ], + [ + "==", + "scalerank", + 3 + ] + ], + "layout": { + "text-line-height": 1.1, + "text-size": { + "base": 1, + "stops": [ + [ + 16, + 11 + ], + [ + 20, + 13 + ] + ] + }, + "icon-image": "{maki}-11", + "text-max-angle": 38, + "symbol-spacing": 250, + "text-font": [ + "DIN Offc Pro Medium", + "Arial Unicode MS Regular" + ], + "text-padding": 1, + "text-offset": [ + 0, + 0.65 + ], + "text-rotation-alignment": "viewport", + "text-anchor": "top", + "text-field": "{name_en}", + "text-letter-spacing": 0.01, + "text-max-width": 8 + }, + "paint": { + "text-color": "hsl(26, 25%, 32%)", + "text-halo-color": "hsl(0, 0%, 100%)", + "text-halo-width": 0.5, + "text-halo-blur": 0.5 + } + }, + { + "id": "poi-parks-scalerank3", + "type": "symbol", + "metadata": { + "mapbox:group": "1444933372896.5967" + }, + "source": "composite", + "source-layer": "poi_label", + "filter": [ + "all", + [ + "==", + "scalerank", + 3 + ], + [ + "in", + "maki", + "campsite", + "cemetery", + "dog-park", + "garden", + "golf", + "park", + "picnic-site", + "playground", + "zoo" + ] + ], + "layout": { + "text-line-height": 1.1, + "text-size": { + "base": 1, + "stops": [ + [ + 16, + 11 + ], + [ + 20, + 13 + ] + ] + }, + "icon-image": "{maki}-11", + "text-max-angle": 38, + "symbol-spacing": 250, + "text-font": [ + "DIN Offc Pro Medium", + "Arial Unicode MS Regular" + ], + "text-padding": 2, + "text-offset": [ + 0, + 0.65 + ], + "text-rotation-alignment": "viewport", + "text-anchor": "top", + "text-field": "{name_en}", + "text-letter-spacing": 0.01, + "text-max-width": 8 + }, + "paint": { + "text-color": "hsl(100, 100%, 20%)", + "text-halo-color": "hsl(0, 0%, 100%)", + "text-halo-width": 0.5, + "text-halo-blur": 0.5 + } + }, + { + "id": "road-label-small", + "type": "symbol", + "metadata": { + "mapbox:group": "1444933721429.3076" + }, + "source": "composite", + "source-layer": "road_label", + "minzoom": 15, + "filter": [ + "all", + [ + "!in", + "class", + "golf", + "link", + "motorway", + "pedestrian", + "primary", + "secondary", + "street", + "street_limited", + "tertiary", + "trunk" + ], + [ + "==", + "$type", + "LineString" + ] + ], + "layout": { + "text-size": { + "base": 1, + "stops": [ + [ + 15, + 10 + ], + [ + 20, + 13 + ] + ] + }, + "text-max-angle": 30, + "symbol-spacing": 250, + "text-font": [ + "DIN Offc Pro Regular", + "Arial Unicode MS Regular" + ], + "symbol-placement": "line", + "text-padding": 1, + "text-rotation-alignment": "map", + "text-pitch-alignment": "viewport", + "text-field": "{name_en}", + "text-letter-spacing": 0.01 + }, + "paint": { + "text-color": "hsl(0, 0%, 0%)", + "text-halo-color": "hsl(0, 0%, 100%)", + "text-halo-width": 1.25, + "text-halo-blur": 1 + } + }, + { + "id": "road-label-medium", + "type": "symbol", + "metadata": { + "mapbox:group": "1444933721429.3076" + }, + "source": "composite", + "source-layer": "road_label", + "minzoom": 11, + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "in", + "class", + "link", + "pedestrian", + "street", + "street_limited" + ] + ], + "layout": { + "text-size": { + "base": 1, + "stops": [ + [ + 11, + 10 + ], + [ + 20, + 14 + ] + ] + }, + "text-max-angle": 30, + "symbol-spacing": 250, + "text-font": [ + "DIN Offc Pro Regular", + "Arial Unicode MS Regular" + ], + "symbol-placement": "line", + "text-padding": 1, + "text-rotation-alignment": "map", + "text-pitch-alignment": "viewport", + "text-field": "{name_en}", + "text-letter-spacing": 0.01 + }, + "paint": { + "text-color": "hsl(0, 0%, 0%)", + "text-halo-color": "hsl(0, 0%, 100%)", + "text-halo-width": 1 + } + }, + { + "id": "road-label-large", + "type": "symbol", + "metadata": { + "mapbox:group": "1444933721429.3076" + }, + "source": "composite", + "source-layer": "road_label", + "filter": [ + "in", + "class", + "motorway", + "primary", + "secondary", + "tertiary", + "trunk" + ], + "layout": { + "text-size": { + "base": 1, + "stops": [ + [ + 9, + 10 + ], + [ + 20, + 16 + ] + ] + }, + "text-max-angle": 30, + "symbol-spacing": 250, + "text-font": [ + "DIN Offc Pro Regular", + "Arial Unicode MS Regular" + ], + "symbol-placement": "line", + "text-padding": 1, + "text-rotation-alignment": "map", + "text-pitch-alignment": "viewport", + "text-field": "{name_en}", + "text-letter-spacing": 0.01 + }, + "paint": { + "text-color": "hsl(0, 0%, 0%)", + "text-halo-color": "hsla(0, 0%, 100%, 0.75)", + "text-halo-width": 1, + "text-halo-blur": 1 + } + }, + { + "id": "road-shields-black", + "type": "symbol", + "metadata": { + "mapbox:group": "1444933575858.6992" + }, + "source": "composite", + "source-layer": "road_label", + "filter": [ + "all", + [ + "!in", + "shield", + "at-expressway", + "at-motorway", + "at-state-b", + "bg-motorway", + "bg-national", + "ch-main", + "ch-motorway", + "cz-motorway", + "cz-road", + "de-motorway", + "e-road", + "fi-main", + "gr-motorway", + "gr-national", + "hr-motorway", + "hr-state", + "hu-main", + "hu-motorway", + "nz-state", + "pl-expressway", + "pl-motorway", + "pl-national", + "ro-county", + "ro-motorway", + "ro-national", + "rs-motorway", + "rs-state-1b", + "se-main", + "si-expressway", + "si-motorway", + "sk-highway", + "sk-road", + "us-interstate", + "us-interstate-business", + "us-interstate-duplex", + "us-interstate-truck", + "za-metropolitan", + "za-national", + "za-provincial", + "za-regional" + ], + [ + "<=", + "reflen", + 6 + ] + ], + "layout": { + "text-size": 9, + "icon-image": "{shield}-{reflen}", + "icon-rotation-alignment": "viewport", + "text-max-angle": 38, + "symbol-spacing": { + "base": 1, + "stops": [ + [ + 11, + 150 + ], + [ + 14, + 200 + ] + ] + }, + "text-font": [ + "DIN Offc Pro Bold", + "Arial Unicode MS Bold" + ], + "symbol-placement": { + "base": 1, + "stops": [ + [ + 10, + "point" + ], + [ + 11, + "line" + ] + ] + }, + "text-padding": 2, + "text-rotation-alignment": "viewport", + "text-field": "{ref}", + "text-letter-spacing": 0.05, + "icon-padding": 2 + }, + "paint": { + "text-color": "hsl(0, 0%, 7%)", + "icon-halo-color": "rgba(0, 0, 0, 1)", + "icon-halo-width": 1, + "text-opacity": 1, + "icon-color": "white", + "text-halo-color": "hsl(0, 0%, 100%)", + "text-halo-width": 0 + } + }, + { + "id": "road-shields-white", + "type": "symbol", + "metadata": { + "mapbox:group": "1444933575858.6992" + }, + "source": "composite", + "source-layer": "road_label", + "filter": [ + "all", + [ + "<=", + "reflen", + 6 + ], + [ + "in", + "shield", + "at-expressway", + "at-motorway", + "at-state-b", + "bg-motorway", + "bg-national", + "ch-main", + "ch-motorway", + "cz-motorway", + "cz-road", + "de-motorway", + "e-road", + "fi-main", + "gr-motorway", + "gr-national", + "hr-motorway", + "hr-state", + "hu-main", + "hu-motorway", + "nz-state", + "pl-expressway", + "pl-motorway", + "pl-national", + "ro-county", + "ro-motorway", + "ro-national", + "rs-motorway", + "rs-state-1b", + "se-main", + "si-expressway", + "si-motorway", + "sk-highway", + "sk-road", + "us-interstate", + "us-interstate-business", + "us-interstate-duplex", + "us-interstate-truck", + "za-metropolitan", + "za-national", + "za-provincial", + "za-regional" + ] + ], + "layout": { + "text-size": 9, + "icon-image": "{shield}-{reflen}", + "icon-rotation-alignment": "viewport", + "text-max-angle": 38, + "symbol-spacing": { + "base": 1, + "stops": [ + [ + 11, + 150 + ], + [ + 14, + 200 + ] + ] + }, + "text-font": [ + "DIN Offc Pro Bold", + "Arial Unicode MS Bold" + ], + "symbol-placement": { + "base": 1, + "stops": [ + [ + 10, + "point" + ], + [ + 11, + "line" + ] + ] + }, + "text-padding": 2, + "text-rotation-alignment": "viewport", + "text-field": "{ref}", + "text-letter-spacing": 0.05, + "icon-padding": 2 + }, + "paint": { + "text-color": "hsl(0, 0%, 100%)", + "icon-halo-color": "rgba(0, 0, 0, 1)", + "icon-halo-width": 1, + "text-opacity": 1, + "icon-color": "white", + "text-halo-color": "hsl(0, 0%, 100%)", + "text-halo-width": 0 + } + }, + { + "id": "motorway-junction", + "type": "symbol", + "metadata": { + "mapbox:group": "1444933575858.6992" + }, + "source": "composite", + "source-layer": "motorway_junction", + "minzoom": 14, + "filter": [ + "all", + [ + "<=", + "reflen", + 9 + ], + [ + ">", + "reflen", + 0 + ] + ], + "layout": { + "text-field": "{ref}", + "text-size": 9, + "icon-image": "motorway-exit-{reflen}", + "text-font": [ + "DIN Offc Pro Bold", + "Arial Unicode MS Bold" + ] + }, + "paint": { + "text-color": "hsl(0, 0%, 100%)", + "text-translate": [ + 0, + 0 + ] + } + }, + { + "id": "poi-scalerank2", + "type": "symbol", + "metadata": { + "mapbox:group": "1444933358918.2366" + }, + "source": "composite", + "source-layer": "poi_label", + "filter": [ + "all", + [ + "!in", + "maki", + "campsite", + "cemetery", + "dog-park", + "garden", + "golf", + "park", + "picnic-site", + "playground", + "zoo" + ], + [ + "==", + "scalerank", + 2 + ] + ], + "layout": { + "text-line-height": 1.1, + "text-size": { + "base": 1, + "stops": [ + [ + 14, + 11 + ], + [ + 20, + 14 + ] + ] + }, + "icon-image": { + "stops": [ + [ + 14, + "{maki}-11" + ], + [ + 15, + "{maki}-15" + ] + ] + }, + "text-max-angle": 38, + "symbol-spacing": 250, + "text-font": [ + "DIN Offc Pro Medium", + "Arial Unicode MS Regular" + ], + "text-padding": 2, + "text-offset": [ + 0, + 0.65 + ], + "text-rotation-alignment": "viewport", + "text-anchor": "top", + "text-field": "{name_en}", + "text-letter-spacing": 0.01, + "text-max-width": 8 + }, + "paint": { + "text-color": "hsl(26, 25%, 32%)", + "text-halo-color": "hsl(0, 0%, 100%)", + "text-halo-width": 0.5, + "text-halo-blur": 0.5 + } + }, + { + "id": "poi-parks-scalerank2", + "type": "symbol", + "metadata": { + "mapbox:group": "1444933358918.2366" + }, + "source": "composite", + "source-layer": "poi_label", + "filter": [ + "all", + [ + "==", + "scalerank", + 2 + ], + [ + "in", + "maki", + "campsite", + "cemetery", + "dog-park", + "garden", + "golf", + "park", + "picnic-site", + "playground", + "zoo" + ] + ], + "layout": { + "text-line-height": 1.1, + "text-size": { + "base": 1, + "stops": [ + [ + 14, + 11 + ], + [ + 20, + 14 + ] + ] + }, + "icon-image": { + "stops": [ + [ + 14, + "{maki}-11" + ], + [ + 15, + "{maki}-15" + ] + ] + }, + "text-max-angle": 38, + "symbol-spacing": 250, + "text-font": [ + "DIN Offc Pro Medium", + "Arial Unicode MS Regular" + ], + "text-padding": 2, + "text-offset": [ + 0, + 0.65 + ], + "text-rotation-alignment": "viewport", + "text-anchor": "top", + "text-field": "{name_en}", + "text-letter-spacing": 0.01, + "text-max-width": 8 + }, + "paint": { + "text-color": "hsl(100, 100%, 20%)", + "text-halo-color": "hsl(0, 0%, 100%)", + "text-halo-width": 0.5, + "text-halo-blur": 0.5 + } + }, + { + "id": "rail-label", + "type": "symbol", + "source": "composite", + "source-layer": "rail_station_label", + "minzoom": 12, + "filter": [ + "!=", + "maki", + "entrance" + ], + "layout": { + "text-line-height": 1.1, + "text-size": { + "base": 1, + "stops": [ + [ + 16, + 11 + ], + [ + 20, + 13 + ] + ] + }, + "icon-image": "{network}", + "symbol-spacing": 250, + "text-font": [ + "DIN Offc Pro Medium", + "Arial Unicode MS Regular" + ], + "text-offset": [ + 0, + 0.85 + ], + "text-rotation-alignment": "viewport", + "text-anchor": "top", + "text-field": { + "base": 1, + "stops": [ + [ + 0, + "" + ], + [ + 13, + "{name_en}" + ] + ] + }, + "text-letter-spacing": 0.01, + "icon-padding": 0, + "text-max-width": 7 + }, + "paint": { + "text-color": "hsl(230, 48%, 44%)", + "text-halo-color": "hsl(0, 0%, 100%)", + "text-halo-width": 0.5, + "icon-halo-width": 4, + "icon-halo-color": "#fff", + "text-opacity": { + "base": 1, + "stops": [ + [ + 13.99, + 0 + ], + [ + 14, + 1 + ] + ] + }, + "text-halo-blur": 0.5 + } + }, + { + "id": "water-label-sm", + "type": "symbol", + "metadata": { + "mapbox:group": "1444933808272.805" + }, + "source": "composite", + "source-layer": "water_label", + "minzoom": 15, + "filter": [ + "<=", + "area", + 10000 + ], + "layout": { + "text-field": "{name_en}", + "text-font": [ + "DIN Offc Pro Italic", + "Arial Unicode MS Regular" + ], + "text-max-width": 7, + "text-size": { + "base": 1, + "stops": [ + [ + 16, + 13 + ], + [ + 20, + 16 + ] + ] + } + }, + "paint": { + "text-color": "hsl(230, 48%, 44%)" + } + }, + { + "id": "water-label", + "type": "symbol", + "metadata": { + "mapbox:group": "1444933808272.805" + }, + "source": "composite", + "source-layer": "water_label", + "minzoom": 5, + "filter": [ + ">", + "area", + 10000 + ], + "layout": { + "text-field": "{name_en}", + "text-font": [ + "DIN Offc Pro Italic", + "Arial Unicode MS Regular" + ], + "text-max-width": 7, + "text-size": { + "base": 1, + "stops": [ + [ + 13, + 13 + ], + [ + 18, + 18 + ] + ] + } + }, + "paint": { + "text-color": "hsl(230, 48%, 44%)" + } + }, + { + "id": "place-residential", + "type": "symbol", + "source": "composite", + "source-layer": "place_label", + "maxzoom": 18, + "filter": [ + "all", + [ + "all", + [ + "<=", + "localrank", + 10 + ], + [ + "==", + "type", + "residential" + ] + ], + [ + "in", + "$type", + "LineString", + "Point", + "Polygon" + ] + ], + "layout": { + "text-line-height": 1.2, + "text-size": { + "base": 1, + "stops": [ + [ + 10, + 11 + ], + [ + 18, + 14 + ] + ] + }, + "text-max-angle": 38, + "symbol-spacing": 250, + "text-font": [ + "DIN Offc Pro Regular", + "Arial Unicode MS Regular" + ], + "text-padding": 2, + "text-offset": [ + 0, + 0 + ], + "text-rotation-alignment": "viewport", + "text-field": "{name_en}", + "text-max-width": 7 + }, + "paint": { + "text-color": "hsl(26, 25%, 32%)", + "text-halo-color": "hsl(0, 0%, 100%)", + "text-halo-width": 1, + "text-halo-blur": 0.5 + } + }, + { + "id": "poi-parks-scalerank1", + "type": "symbol", + "metadata": { + "mapbox:group": "1444933322393.2852" + }, + "source": "composite", + "source-layer": "poi_label", + "filter": [ + "all", + [ + "<=", + "scalerank", + 1 + ], + [ + "in", + "maki", + "campsite", + "cemetery", + "dog-park", + "garden", + "golf", + "park", + "picnic-site", + "playground", + "zoo" + ] + ], + "layout": { + "text-line-height": 1.1, + "text-size": { + "base": 1, + "stops": [ + [ + 10, + 11 + ], + [ + 18, + 14 + ] + ] + }, + "icon-image": { + "stops": [ + [ + 13, + "{maki}-11" + ], + [ + 14, + "{maki}-15" + ] + ] + }, + "text-max-angle": 38, + "symbol-spacing": 250, + "text-font": [ + "DIN Offc Pro Medium", + "Arial Unicode MS Regular" + ], + "text-padding": 2, + "text-offset": [ + 0, + 0.65 + ], + "text-rotation-alignment": "viewport", + "text-anchor": "top", + "text-field": "{name_en}", + "text-letter-spacing": 0.01, + "text-max-width": 8 + }, + "paint": { + "text-color": "hsl(100, 100%, 20%)", + "text-halo-color": "hsl(0, 0%, 100%)", + "text-halo-width": 0.5, + "text-halo-blur": 0.5 + } + }, + { + "id": "poi-scalerank1", + "type": "symbol", + "metadata": { + "mapbox:group": "1444933322393.2852" + }, + "source": "composite", + "source-layer": "poi_label", + "filter": [ + "all", + [ + "!in", + "maki", + "campsite", + "cemetery", + "dog-park", + "garden", + "golf", + "park", + "picnic-site", + "playground", + "zoo" + ], + [ + "<=", + "scalerank", + 1 + ] + ], + "layout": { + "text-line-height": 1.1, + "text-size": { + "base": 1, + "stops": [ + [ + 10, + 11 + ], + [ + 18, + 14 + ] + ] + }, + "icon-image": { + "stops": [ + [ + 13, + "{maki}-11" + ], + [ + 14, + "{maki}-15" + ] + ] + }, + "text-max-angle": 38, + "symbol-spacing": 250, + "text-font": [ + "DIN Offc Pro Medium", + "Arial Unicode MS Regular" + ], + "text-padding": 2, + "text-offset": [ + 0, + 0.65 + ], + "text-rotation-alignment": "viewport", + "text-anchor": "top", + "text-field": "{name_en}", + "text-letter-spacing": 0.01, + "text-max-width": 8 + }, + "paint": { + "text-color": "hsl(26, 25%, 32%)", + "text-halo-color": "hsl(0, 0%, 100%)", + "text-halo-width": 0.5, + "text-halo-blur": 0.5 + } + }, + { + "id": "airport-label", + "type": "symbol", + "source": "composite", + "source-layer": "airport_label", + "minzoom": 9, + "filter": [ + "<=", + "scalerank", + 2 + ], + "layout": { + "text-line-height": 1.1, + "text-size": { + "base": 1, + "stops": [ + [ + 10, + 12 + ], + [ + 18, + 18 + ] + ] + }, + "icon-image": { + "stops": [ + [ + 12, + "{maki}-11" + ], + [ + 13, + "{maki}-15" + ] + ] + }, + "symbol-spacing": 250, + "text-font": [ + "DIN Offc Pro Medium", + "Arial Unicode MS Regular" + ], + "text-padding": 2, + "text-offset": [ + 0, + 0.75 + ], + "text-rotation-alignment": "viewport", + "text-anchor": "top", + "text-field": { + "stops": [ + [ + 11, + "{ref}" + ], + [ + 12, + "{name_en}" + ] + ] + }, + "text-letter-spacing": 0.01, + "text-max-width": 9 + }, + "paint": { + "text-color": "hsl(230, 48%, 44%)", + "text-halo-color": "hsl(0, 0%, 100%)", + "text-halo-width": 0.5, + "text-halo-blur": 0.5 + } + }, + { + "id": "place-islet-archipelago-aboriginal", + "type": "symbol", + "source": "composite", + "source-layer": "place_label", + "maxzoom": 16, + "filter": [ + "in", + "type", + "aboriginal_lands", + "archipelago", + "islet" + ], + "layout": { + "text-line-height": 1.2, + "text-size": { + "base": 1, + "stops": [ + [ + 10, + 11 + ], + [ + 18, + 16 + ] + ] + }, + "text-max-angle": 38, + "symbol-spacing": 250, + "text-font": [ + "DIN Offc Pro Regular", + "Arial Unicode MS Regular" + ], + "text-padding": 2, + "text-offset": [ + 0, + 0 + ], + "text-rotation-alignment": "viewport", + "text-field": "{name_en}", + "text-letter-spacing": 0.01, + "text-max-width": 8 + }, + "paint": { + "text-color": "hsl(230, 29%, 35%)", + "text-halo-color": "hsl(0, 0%, 100%)", + "text-halo-width": 1 + } + }, + { + "id": "place-neighbourhood", + "type": "symbol", + "source": "composite", + "source-layer": "place_label", + "minzoom": 10, + "maxzoom": 16, + "filter": [ + "==", + "type", + "neighbourhood" + ], + "layout": { + "text-field": "{name_en}", + "text-transform": "uppercase", + "text-letter-spacing": 0.1, + "text-max-width": 7, + "text-font": [ + "DIN Offc Pro Regular", + "Arial Unicode MS Regular" + ], + "text-padding": 3, + "text-size": { + "base": 1, + "stops": [ + [ + 12, + 11 + ], + [ + 16, + 16 + ] + ] + } + }, + "paint": { + "text-halo-color": "hsl(0, 0%, 100%)", + "text-halo-width": 1, + "text-color": "hsl(230, 29%, 35%)", + "text-halo-blur": 0.5 + } + }, + { + "id": "place-suburb", + "type": "symbol", + "source": "composite", + "source-layer": "place_label", + "minzoom": 10, + "maxzoom": 16, + "filter": [ + "==", + "type", + "suburb" + ], + "layout": { + "text-field": "{name_en}", + "text-transform": "uppercase", + "text-font": [ + "DIN Offc Pro Regular", + "Arial Unicode MS Regular" + ], + "text-letter-spacing": 0.15, + "text-max-width": 7, + "text-padding": 3, + "text-size": { + "base": 1, + "stops": [ + [ + 11, + 11 + ], + [ + 15, + 18 + ] + ] + } + }, + "paint": { + "text-halo-color": "hsl(0, 0%, 100%)", + "text-halo-width": 1, + "text-color": "hsl(230, 29%, 35%)", + "text-halo-blur": 0.5 + } + }, + { + "id": "place-hamlet", + "type": "symbol", + "source": "composite", + "source-layer": "place_label", + "minzoom": 10, + "maxzoom": 16, + "filter": [ + "==", + "type", + "hamlet" + ], + "layout": { + "text-field": "{name_en}", + "text-font": [ + "DIN Offc Pro Regular", + "Arial Unicode MS Regular" + ], + "text-size": { + "base": 1, + "stops": [ + [ + 12, + 11.5 + ], + [ + 15, + 16 + ] + ] + } + }, + "paint": { + "text-halo-color": "hsl(0, 0%, 100%)", + "text-halo-width": 1.25, + "text-color": "hsl(0, 0%, 0%)" + } + }, + { + "id": "place-village", + "type": "symbol", + "source": "composite", + "source-layer": "place_label", + "minzoom": 8, + "maxzoom": 15, + "filter": [ + "==", + "type", + "village" + ], + "layout": { + "text-field": "{name_en}", + "text-font": [ + "DIN Offc Pro Regular", + "Arial Unicode MS Regular" + ], + "text-max-width": 7, + "text-size": { + "base": 1, + "stops": [ + [ + 10, + 11.5 + ], + [ + 16, + 18 + ] + ] + } + }, + "paint": { + "text-halo-color": "hsl(0, 0%, 100%)", + "text-halo-width": 1.25, + "text-color": "hsl(0, 0%, 0%)" + } + }, + { + "id": "place-town", + "type": "symbol", + "source": "composite", + "source-layer": "place_label", + "minzoom": 6, + "maxzoom": 15, + "filter": [ + "==", + "type", + "town" + ], + "layout": { + "icon-image": "dot-9", + "text-font": { + "base": 1, + "stops": [ + [ + 11, + [ + "DIN Offc Pro Regular", + "Arial Unicode MS Regular" + ] + ], + [ + 12, + [ + "DIN Offc Pro Medium", + "Arial Unicode MS Regular" + ] + ] + ] + }, + "text-offset": { + "base": 1, + "stops": [ + [ + 7, + [ + 0, + -0.15 + ] + ], + [ + 8, + [ + 0, + 0 + ] + ] + ] + }, + "text-anchor": { + "base": 1, + "stops": [ + [ + 7, + "bottom" + ], + [ + 8, + "center" + ] + ] + }, + "text-field": "{name_en}", + "text-max-width": 7, + "text-size": { + "base": 1, + "stops": [ + [ + 7, + 11.5 + ], + [ + 15, + 20 + ] + ] + } + }, + "paint": { + "text-color": "hsl(0, 0%, 0%)", + "text-halo-color": "hsl(0, 0%, 100%)", + "text-halo-width": 1.25, + "icon-opacity": { + "base": 1, + "stops": [ + [ + 7.99, + 1 + ], + [ + 8, + 0 + ] + ] + } + } + }, + { + "id": "place-island", + "type": "symbol", + "source": "composite", + "source-layer": "place_label", + "maxzoom": 16, + "filter": [ + "==", + "type", + "island" + ], + "layout": { + "text-line-height": 1.2, + "text-size": { + "base": 1, + "stops": [ + [ + 10, + 11 + ], + [ + 18, + 16 + ] + ] + }, + "text-max-angle": 38, + "symbol-spacing": 250, + "text-font": [ + "DIN Offc Pro Regular", + "Arial Unicode MS Regular" + ], + "text-padding": 2, + "text-offset": [ + 0, + 0 + ], + "text-rotation-alignment": "viewport", + "text-field": "{name_en}", + "text-letter-spacing": 0.01, + "text-max-width": 7 + }, + "paint": { + "text-color": "hsl(230, 29%, 35%)", + "text-halo-color": "hsl(0, 0%, 100%)", + "text-halo-width": 1 + } + }, + { + "id": "place-city-sm", + "type": "symbol", + "metadata": { + "mapbox:group": "1444862510685.128" + }, + "source": "composite", + "source-layer": "place_label", + "maxzoom": 14, + "filter": [ + "all", + [ + "!in", + "scalerank", + 0, + 1, + 2, + 3, + 4, + 5 + ], + [ + "==", + "type", + "city" + ] + ], + "layout": { + "text-size": { + "base": 1, + "stops": [ + [ + 6, + 12 + ], + [ + 14, + 22 + ] + ] + }, + "icon-image": "dot-9", + "text-font": { + "base": 1, + "stops": [ + [ + 7, + [ + "DIN Offc Pro Regular", + "Arial Unicode MS Regular" + ] + ], + [ + 8, + [ + "DIN Offc Pro Medium", + "Arial Unicode MS Regular" + ] + ] + ] + }, + "text-offset": { + "base": 1, + "stops": [ + [ + 7.99, + [ + 0, + -0.2 + ] + ], + [ + 8, + [ + 0, + 0 + ] + ] + ] + }, + "text-anchor": { + "base": 1, + "stops": [ + [ + 7, + "bottom" + ], + [ + 8, + "center" + ] + ] + }, + "text-field": "{name_en}", + "text-max-width": 7 + }, + "paint": { + "text-color": "hsl(0, 0%, 0%)", + "text-halo-color": "hsl(0, 0%, 100%)", + "text-halo-width": 1.25, + "icon-opacity": { + "base": 1, + "stops": [ + [ + 7.99, + 1 + ], + [ + 8, + 0 + ] + ] + } + } + }, + { + "id": "place-city-md-s", + "type": "symbol", + "metadata": { + "mapbox:group": "1444862510685.128" + }, + "source": "composite", + "source-layer": "place_label", + "maxzoom": 14, + "filter": [ + "all", + [ + "==", + "type", + "city" + ], + [ + "in", + "ldir", + "E", + "S", + "SE", + "SW" + ], + [ + "in", + "scalerank", + 3, + 4, + 5 + ] + ], + "layout": { + "text-field": "{name_en}", + "icon-image": "dot-10", + "text-anchor": { + "base": 1, + "stops": [ + [ + 7, + "top" + ], + [ + 8, + "center" + ] + ] + }, + "text-offset": { + "base": 1, + "stops": [ + [ + 7.99, + [ + 0, + 0.1 + ] + ], + [ + 8, + [ + 0, + 0 + ] + ] + ] + }, + "text-font": { + "base": 1, + "stops": [ + [ + 7, + [ + "DIN Offc Pro Regular", + "Arial Unicode MS Regular" + ] + ], + [ + 8, + [ + "DIN Offc Pro Medium", + "Arial Unicode MS Regular" + ] + ] + ] + }, + "text-size": { + "base": 0.9, + "stops": [ + [ + 5, + 12 + ], + [ + 12, + 22 + ] + ] + } + }, + "paint": { + "text-halo-width": 1, + "text-halo-color": "hsl(0, 0%, 100%)", + "text-color": "hsl(0, 0%, 0%)", + "text-halo-blur": 1, + "icon-opacity": { + "base": 1, + "stops": [ + [ + 7.99, + 1 + ], + [ + 8, + 0 + ] + ] + } + } + }, + { + "id": "place-city-md-n", + "type": "symbol", + "metadata": { + "mapbox:group": "1444862510685.128" + }, + "source": "composite", + "source-layer": "place_label", + "maxzoom": 14, + "filter": [ + "all", + [ + "==", + "type", + "city" + ], + [ + "in", + "ldir", + "N", + "NE", + "NW", + "W" + ], + [ + "in", + "scalerank", + 3, + 4, + 5 + ] + ], + "layout": { + "icon-image": "dot-10", + "text-font": { + "base": 1, + "stops": [ + [ + 7, + [ + "DIN Offc Pro Regular", + "Arial Unicode MS Regular" + ] + ], + [ + 8, + [ + "DIN Offc Pro Medium", + "Arial Unicode MS Regular" + ] + ] + ] + }, + "text-offset": { + "base": 1, + "stops": [ + [ + 7.99, + [ + 0, + -0.25 + ] + ], + [ + 8, + [ + 0, + 0 + ] + ] + ] + }, + "text-anchor": { + "base": 1, + "stops": [ + [ + 7, + "bottom" + ], + [ + 8, + "center" + ] + ] + }, + "text-field": "{name_en}", + "text-max-width": 7, + "text-size": { + "base": 0.9, + "stops": [ + [ + 5, + 12 + ], + [ + 12, + 22 + ] + ] + } + }, + "paint": { + "text-color": "hsl(0, 0%, 0%)", + "text-halo-color": "hsl(0, 0%, 100%)", + "text-halo-width": 1, + "icon-opacity": { + "base": 1, + "stops": [ + [ + 7.99, + 1 + ], + [ + 8, + 0 + ] + ] + }, + "text-halo-blur": 1 + } + }, + { + "id": "place-city-lg-s", + "type": "symbol", + "metadata": { + "mapbox:group": "1444862510685.128" + }, + "source": "composite", + "source-layer": "place_label", + "minzoom": 1, + "maxzoom": 14, + "filter": [ + "all", + [ + "<=", + "scalerank", + 2 + ], + [ + "==", + "type", + "city" + ], + [ + "in", + "ldir", + "E", + "S", + "SE", + "SW" + ] + ], + "layout": { + "icon-image": "dot-11", + "text-font": { + "base": 1, + "stops": [ + [ + 7, + [ + "DIN Offc Pro Regular", + "Arial Unicode MS Regular" + ] + ], + [ + 8, + [ + "DIN Offc Pro Medium", + "Arial Unicode MS Regular" + ] + ] + ] + }, + "text-offset": { + "base": 1, + "stops": [ + [ + 7.99, + [ + 0, + 0.15 + ] + ], + [ + 8, + [ + 0, + 0 + ] + ] + ] + }, + "text-anchor": { + "base": 1, + "stops": [ + [ + 7, + "top" + ], + [ + 8, + "center" + ] + ] + }, + "text-field": "{name_en}", + "text-max-width": 7, + "text-size": { + "base": 0.9, + "stops": [ + [ + 4, + 12 + ], + [ + 10, + 22 + ] + ] + } + }, + "paint": { + "text-color": "hsl(0, 0%, 0%)", + "text-halo-color": "hsl(0, 0%, 100%)", + "text-halo-width": 1, + "icon-opacity": { + "base": 1, + "stops": [ + [ + 7.99, + 1 + ], + [ + 8, + 0 + ] + ] + }, + "text-halo-blur": 1 + } + }, + { + "id": "place-city-lg-n", + "type": "symbol", + "metadata": { + "mapbox:group": "1444862510685.128" + }, + "source": "composite", + "source-layer": "place_label", + "minzoom": 1, + "maxzoom": 14, + "filter": [ + "all", + [ + "<=", + "scalerank", + 2 + ], + [ + "==", + "type", + "city" + ], + [ + "in", + "ldir", + "N", + "NE", + "NW", + "W" + ] + ], + "layout": { + "icon-image": "dot-11", + "text-font": { + "base": 1, + "stops": [ + [ + 7, + [ + "DIN Offc Pro Regular", + "Arial Unicode MS Regular" + ] + ], + [ + 8, + [ + "DIN Offc Pro Medium", + "Arial Unicode MS Regular" + ] + ] + ] + }, + "text-offset": { + "base": 1, + "stops": [ + [ + 7.99, + [ + 0, + -0.25 + ] + ], + [ + 8, + [ + 0, + 0 + ] + ] + ] + }, + "text-anchor": { + "base": 1, + "stops": [ + [ + 7, + "bottom" + ], + [ + 8, + "center" + ] + ] + }, + "text-field": "{name_en}", + "text-max-width": 7, + "text-size": { + "base": 0.9, + "stops": [ + [ + 4, + 12 + ], + [ + 10, + 22 + ] + ] + } + }, + "paint": { + "text-color": "hsl(0, 0%, 0%)", + "text-opacity": 1, + "text-halo-color": "hsl(0, 0%, 100%)", + "text-halo-width": 1, + "icon-opacity": { + "base": 1, + "stops": [ + [ + 7.99, + 1 + ], + [ + 8, + 0 + ] + ] + }, + "text-halo-blur": 1 + } + }, + { + "id": "marine-label-sm-ln", + "type": "symbol", + "metadata": { + "mapbox:group": "1444856087950.3635" + }, + "source": "composite", + "source-layer": "marine_label", + "minzoom": 3, + "maxzoom": 10, + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + ">=", + "labelrank", + 4 + ] + ], + "layout": { + "text-line-height": 1.1, + "text-size": { + "base": 1, + "stops": [ + [ + 3, + 12 + ], + [ + 6, + 16 + ] + ] + }, + "symbol-spacing": { + "base": 1, + "stops": [ + [ + 4, + 100 + ], + [ + 6, + 400 + ] + ] + }, + "text-font": [ + "DIN Offc Pro Italic", + "Arial Unicode MS Regular" + ], + "symbol-placement": "line", + "text-pitch-alignment": "viewport", + "text-field": "{name_en}", + "text-letter-spacing": 0.1, + "text-max-width": 5 + }, + "paint": { + "text-color": "hsl(205, 83%, 88%)" + } + }, + { + "id": "marine-label-sm-pt", + "type": "symbol", + "metadata": { + "mapbox:group": "1444856087950.3635" + }, + "source": "composite", + "source-layer": "marine_label", + "minzoom": 3, + "maxzoom": 10, + "filter": [ + "all", + [ + "==", + "$type", + "Point" + ], + [ + ">=", + "labelrank", + 4 + ] + ], + "layout": { + "text-field": "{name_en}", + "text-max-width": 5, + "text-letter-spacing": 0.1, + "text-line-height": 1.5, + "text-font": [ + "DIN Offc Pro Italic", + "Arial Unicode MS Regular" + ], + "text-size": { + "base": 1, + "stops": [ + [ + 3, + 12 + ], + [ + 6, + 16 + ] + ] + } + }, + "paint": { + "text-color": "hsl(205, 83%, 88%)" + } + }, + { + "id": "marine-label-md-ln", + "type": "symbol", + "metadata": { + "mapbox:group": "1444856087950.3635" + }, + "source": "composite", + "source-layer": "marine_label", + "minzoom": 2, + "maxzoom": 8, + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "in", + "labelrank", + 2, + 3 + ] + ], + "layout": { + "text-line-height": 1.1, + "text-size": { + "base": 1.1, + "stops": [ + [ + 2, + 12 + ], + [ + 5, + 20 + ] + ] + }, + "symbol-spacing": 250, + "text-font": [ + "DIN Offc Pro Italic", + "Arial Unicode MS Regular" + ], + "symbol-placement": "line", + "text-pitch-alignment": "viewport", + "text-field": "{name_en}", + "text-letter-spacing": 0.15, + "text-max-width": 5 + }, + "paint": { + "text-color": "hsl(205, 83%, 88%)" + } + }, + { + "id": "marine-label-md-pt", + "type": "symbol", + "metadata": { + "mapbox:group": "14448560879503635" + }, + "source": "composite", + "source-layer": "marine_label", + "minzoom": 2, + "maxzoom": 8, + "filter": [ + "all", + [ + "==", + "$type", + "Point" + ], + [ + "in", + "labelrank", + 2, + 3 + ] + ], + "layout": { + "text-field": "{name_en}", + "text-max-width": 5, + "text-letter-spacing": 0.15, + "text-line-height": 1.5, + "text-font": [ + "DIN Offc Pro Italic", + "Arial Unicode MS Regular" + ], + "text-size": { + "base": 1.1, + "stops": [ + [ + 2, + 14 + ], + [ + 5, + 20 + ] + ] + } + }, + "paint": { + "text-color": "hsl(205, 83%, 88%)" + } + }, + { + "id": "marine-label-lg-ln", + "type": "symbol", + "metadata": { + "mapbox:group": "1444856087950.3635" + }, + "source": "composite", + "source-layer": "marine_label", + "minzoom": 1, + "maxzoom": 4, + "filter": [ + "all", + [ + "==", + "$type", + "LineString" + ], + [ + "==", + "labelrank", + 1 + ] + ], + "layout": { + "text-field": "{name_en}", + "text-max-width": 4, + "text-letter-spacing": 0.25, + "text-line-height": 1.1, + "symbol-placement": "line", + "text-pitch-alignment": "viewport", + "text-font": [ + "DIN Offc Pro Italic", + "Arial Unicode MS Regular" + ], + "text-size": { + "base": 1, + "stops": [ + [ + 1, + 14 + ], + [ + 4, + 30 + ] + ] + } + }, + "paint": { + "text-color": "hsl(205, 83%, 88%)" + } + }, + { + "id": "marine-label-lg-pt", + "type": "symbol", + "metadata": { + "mapbox:group": "1444856087950.3635" + }, + "source": "composite", + "source-layer": "marine_label", + "minzoom": 1, + "maxzoom": 4, + "filter": [ + "all", + [ + "==", + "$type", + "Point" + ], + [ + "==", + "labelrank", + 1 + ] + ], + "layout": { + "text-field": "{name_en}", + "text-max-width": 4, + "text-letter-spacing": 0.25, + "text-line-height": 1.5, + "text-font": [ + "DIN Offc Pro Italic", + "Arial Unicode MS Regular" + ], + "text-size": { + "base": 1, + "stops": [ + [ + 1, + 14 + ], + [ + 4, + 30 + ] + ] + } + }, + "paint": { + "text-color": "hsl(205, 83%, 88%)" + } + }, + { + "id": "state-label-sm", + "type": "symbol", + "metadata": { + "mapbox:group": "1444856151690.9143" + }, + "source": "composite", + "source-layer": "state_label", + "minzoom": 3, + "maxzoom": 9, + "filter": [ + "<", + "area", + 20000 + ], + "layout": { + "text-size": { + "base": 1, + "stops": [ + [ + 6, + 10 + ], + [ + 9, + 14 + ] + ] + }, + "text-transform": "uppercase", + "text-font": [ + "DIN Offc Pro Bold", + "Arial Unicode MS Bold" + ], + "text-field": { + "base": 1, + "stops": [ + [ + 0, + "{abbr}" + ], + [ + 6, + "{name_en}" + ] + ] + }, + "text-letter-spacing": 0.15, + "text-max-width": 5 + }, + "paint": { + "text-opacity": 1, + "text-color": "hsl(0, 0%, 0%)", + "text-halo-color": "hsl(0, 0%, 100%)", + "text-halo-width": 1 + } + }, + { + "id": "state-label-md", + "type": "symbol", + "metadata": { + "mapbox:group": "1444856151690.9143" + }, + "source": "composite", + "source-layer": "state_label", + "minzoom": 3, + "maxzoom": 8, + "filter": [ + "all", + [ + "<", + "area", + 80000 + ], + [ + ">=", + "area", + 20000 + ] + ], + "layout": { + "text-size": { + "base": 1, + "stops": [ + [ + 5, + 10 + ], + [ + 8, + 16 + ] + ] + }, + "text-transform": "uppercase", + "text-font": [ + "DIN Offc Pro Bold", + "Arial Unicode MS Bold" + ], + "text-field": { + "base": 1, + "stops": [ + [ + 0, + "{abbr}" + ], + [ + 5, + "{name_en}" + ] + ] + }, + "text-letter-spacing": 0.15, + "text-max-width": 6 + }, + "paint": { + "text-opacity": 1, + "text-color": "hsl(0, 0%, 0%)", + "text-halo-color": "hsl(0, 0%, 100%)", + "text-halo-width": 1 + } + }, + { + "id": "state-label-lg", + "type": "symbol", + "metadata": { + "mapbox:group": "1444856151690.9143" + }, + "source": "composite", + "source-layer": "state_label", + "minzoom": 3, + "maxzoom": 7, + "filter": [ + ">=", + "area", + 80000 + ], + "layout": { + "text-size": { + "base": 1, + "stops": [ + [ + 4, + 10 + ], + [ + 7, + 18 + ] + ] + }, + "text-transform": "uppercase", + "text-font": [ + "DIN Offc Pro Bold", + "Arial Unicode MS Bold" + ], + "text-padding": 1, + "text-field": { + "base": 1, + "stops": [ + [ + 0, + "{abbr}" + ], + [ + 4, + "{name_en}" + ] + ] + }, + "text-letter-spacing": 0.15, + "text-max-width": 6 + }, + "paint": { + "text-opacity": 1, + "text-color": "hsl(0, 0%, 0%)", + "text-halo-color": "hsl(0, 0%, 100%)", + "text-halo-width": 1 + } + }, + { + "id": "country-label-sm", + "type": "symbol", + "metadata": { + "mapbox:group": "1444856144497.7825" + }, + "source": "composite", + "source-layer": "country_label", + "minzoom": 1, + "maxzoom": 10, + "filter": [ + ">=", + "scalerank", + 5 + ], + "layout": { + "text-field": "{name_en}", + "text-max-width": 6, + "text-font": [ + "DIN Offc Pro Medium", + "Arial Unicode MS Regular" + ], + "text-size": { + "base": 0.9, + "stops": [ + [ + 5, + 14 + ], + [ + 9, + 22 + ] + ] + } + }, + "paint": { + "text-color": "hsl(0, 0%, 0%)", + "text-halo-color": { + "base": 1, + "stops": [ + [ + 2, + "rgba(255,255,255,0.75)" + ], + [ + 3, + "hsl(0, 0%, 100%)" + ] + ] + }, + "text-halo-width": 1.25 + } + }, + { + "id": "country-label-md", + "type": "symbol", + "metadata": { + "mapbox:group": "1444856144497.7825" + }, + "source": "composite", + "source-layer": "country_label", + "minzoom": 1, + "maxzoom": 8, + "filter": [ + "in", + "scalerank", + 3, + 4 + ], + "layout": { + "text-field": { + "base": 1, + "stops": [ + [ + 0, + "{code}" + ], + [ + 2, + "{name_en}" + ] + ] + }, + "text-max-width": 6, + "text-font": [ + "DIN Offc Pro Medium", + "Arial Unicode MS Regular" + ], + "text-size": { + "base": 1, + "stops": [ + [ + 3, + 10 + ], + [ + 8, + 24 + ] + ] + } + }, + "paint": { + "text-color": "hsl(0, 0%, 0%)", + "text-halo-color": { + "base": 1, + "stops": [ + [ + 2, + "rgba(255,255,255,0.75)" + ], + [ + 3, + "hsl(0, 0%, 100%)" + ] + ] + }, + "text-halo-width": 1.25 + } + }, + { + "id": "country-label-lg", + "type": "symbol", + "metadata": { + "mapbox:group": "1444856144497.7825" + }, + "source": "composite", + "source-layer": "country_label", + "minzoom": 1, + "maxzoom": 7, + "filter": [ + "in", + "scalerank", + 1, + 2 + ], + "layout": { + "text-field": "{name_en}", + "text-max-width": { + "base": 1, + "stops": [ + [ + 0, + 5 + ], + [ + 3, + 6 + ] + ] + }, + "text-font": [ + "DIN Offc Pro Medium", + "Arial Unicode MS Regular" + ], + "text-size": { + "base": 1, + "stops": [ + [ + 1, + 10 + ], + [ + 6, + 24 + ] + ] + } + }, + "paint": { + "text-color": "hsl(0, 0%, 0%)", + "text-halo-color": { + "base": 1, + "stops": [ + [ + 2, + "rgba(255,255,255,0.75)" + ], + [ + 3, + "hsl(0, 0%, 100%)" + ] + ] + }, + "text-halo-width": 1.25 + } + }, + { + "id": "quest-data-polygons", + "type": "fill", + "layout": {}, + "source": "quest-data-set", + "filter": [ + "==", + "$type", + "Polygon" + ], + "paint": { + "fill-color": [ + "match", + [ + "get", + "taskBusinessStatus" + ], + [ + "Not Visited" + ], + "hsl(46, 88%, 62%)", + [ + "Not Sprayed" + ], + "hsl(3, 71%, 54%)", + [ + "Sprayed" + ], + "hsl(89, 52%, 48%)", + [ + "Not Sprayable" + ], + "hsl(0, 0%, 9%)", + "hsl(0, 0%, 78%)" + ], + "fill-opacity": [ + "match", + [ + "get", + "taskBusinessStatus" + ], + [ + "Not Visited" + ], + 1, + [ + "Not Sprayed" + ], + 1, + [ + "Sprayed" + ], + 1, + [ + "Not Sprayable" + ], + 1, + 0.75 + ], + "fill-outline-color": "rgb(0, 0, 0)" + } + }, + { + "id": "quest-data-points", + "type": "circle", + "source": "quest-data-set", + "filter": [ + "==", + "$type", + "Point" + ], + "layout": {}, + "paint": { + "circle-radius": [ + "interpolate", + [ + "linear" + ], + [ + "zoom" + ], + 7.0, + 13, + 13.0, + 15 + ], + "circle-color": [ + "match", + [ + "get", + "taskStatus" + ], + [ + "due" + ], + "hsl(205, 100%, 40%)", + [ + "overdue" + ], + "hsl(357, 88%, 46%)", + "hsl(100, 100%, 100%)" + ], + "circle-opacity": 1 + } + }, + { + "id": "data-quest-polygon-text", + "type": "symbol", + "source": "quest-data-set", + "layout": { + "text-field": [ + "get", + "number" + ], + "text-font": [ + "DIN Offc Pro Medium", + "Arial Unicode MS Bold" + ], + "text-size": 14 + }, + "paint": { + "text-color": "#ffffff" + } + }, + { + "id": "select-layer-polygons", + "type": "fill", + "source": "select-data", + "filter": [ + "==", + "$type", + "Polygon" + ], + "layout": {}, + "paint": { + "fill-outline-color": "rgb(0, 0, 0)", + "fill-opacity": 1, + "fill-color": "hsl(26, 84%, 56%)" + } + }, + { + "id": "select-layer-circles", + "type": "circle", + "source": "select-data", + "filter": [ + "==", + "$type", + "Point" + ], + "paint": { + "circle-color": "hsl(26, 84%, 56%)", + "circle-radius": 10, + "circle-stroke-width": 1 + } + } + ], + "created": "2018-11-22T17:13:49.648Z", + "id": "cjosuw10y0gqw2smcjwq627bl", + "modified": "2018-11-22T17:13:49.648Z", + "owner": "ona", + "visibility": "private", + "draft": false +} \ No newline at end of file diff --git a/android/quest/src/gizEir/res/values-es/strings.xml b/android/quest/src/gizEir/res/values-es/strings.xml new file mode 100644 index 00000000000..c962a6bad82 --- /dev/null +++ b/android/quest/src/gizEir/res/values-es/strings.xml @@ -0,0 +1,407 @@ + + + + Sincronización manual + Idioma + Cerrar sesión como + Mostrar vencido + Buscar nombre o ID + Buscar nombre + Sincronizar datos + ESCANEAR CÓDIGO DE BARRAS + Sin resultados + Lo sentimos, no pudimos encontrar el cliente con su nombre o ID + Registrar nuevo cliente + Aplicación Fhir + Logotipo de la aplicación + Desarrollado por + Versión de la aplicación %1$d(%2$s) + Versión de migración de datos %1$s + Última sincronización %1$s + INICIAR + No se pudieron verificar las credenciales del servidor. Comprueba tu conexión a Internet + No se pudo obtener información del usuario del servidor. Comprueba tu conexión a Internet + Seleccionar idioma + Cerrar sesión como %1$s + Sincronización completa + Sincronización + Sincronizando + Sincronizando + Sincronización iniciada… + Error de sincronización. Verifique la conexión a Internet o vuelva a intentarlo más tarde + Sincronización completada con errores. Reintentando… + La sincronización falló porque las credenciales de autenticación no son válidas. + No se pudieron recuperar las credenciales del servidor. Por favor verifique su conexión a Internet. + Inténtalo de nuevo + Vacunado + Atrasado + Vacuna %1$d \n%2$s + "Completamente vacunado" + "No puedo recibir otra dosis. Ya estoy completamente vacunado" + Registro\nVacuna + Visto por última vez %1$s + Última visita %1$s + Guardar + Error de inicio de sesión: %1$s + SIGUIENTE + ANTERIOR + Página %1$d de %2$d + %1$d RESULTADO(S) + Sin resultados + Lo sentimos, no podemos encontrar a la persona con el nombre o ID proporcionado + Hombre + Mujer + Otro + Desconocido + Sincronización fallida + Reintentar sincronización + Sincronización en curso + Cargando + Mensaje de error al cerrar sesión: %1$s + No se puede cerrar sesión: ya se ha cerrado sesión o el dispositivo está desconectado. + No se pudo cargar la configuración. Inténtalo de nuevo más tarde + No se pudo cargar la configuración. Por favor revisa tu conexión a Internet + Error al conectarse al servidor. Por favor contacte al administrador del sistema + Error al cargar el formulario + Error encontrado al no poder guardar el formulario + Procesando datos. Por favor espera + ¿Estás seguro de que quieres volver? + ¿Estás seguro de que deseas descartar las respuestas? + Descartar cambios + Descartar + Guardar borrador parcial + Cancelar + + Los detalles proporcionados tienen errores de validación. Resolver errores y enviar de nuevo + Validación fallida + Aceptar + Nombre de usuario + Contraseña + Olvidé mi contraseña + ¡Olvidé mi contraseña! + Más + Llame a su supervisor al %1$s + MARCAR NÚMERO + Registrarse + Visitas + Informes + Perfil + Configuración + Seleccionar registro + Marca + Clientes + Cerrar sesión + ID de aplicación + por ejemplo, ecbis, quest, cha + CARGAR CONFIGURACIONES + APLICACIÓN FHIRCORE + Recordar aplicación + Listo + Reemplazar foto + Editar + Error al cargar las configuraciones de la aplicación %1$s + eCBIS + el nombre de usuario o la contraseña no son válidos + ID de formulario adjunto no válido + Establecer PIN + CHA utilizará este PIN para iniciar sesión + Ingrese el PIN para %1$s + PIN incorrecto, inténtelo de nuevo + Iniciar sesión + ¿Olvidaste tu PIN? + Por favor, póngase en contacto con su supervisor. + % + Cerrar sesión. Por favor espera… + La sesión ha caducado y debes iniciar sesión nuevamente + Sexo + Edad + fecha de nacimiento + ID: %1$s + VER TODO + FORMULARIOS + ANTECEDENTES MÉDICOS + PRÓXIMOS SERVICIOS + TARJETA DE SERVICIO + Otros pacientes + Seleccionar ubicación + RESPUESTAS (%1$s) + Intenté iniciar sesión con un proveedor diferente + Por favor espera… + Restablecer datos + Transferir datos + ¡Restablecer base de datos! + Restablecer la base de datos borrará todos los registros de su dispositivo. Esta acción no se puede deshacer. + Restableciendo aplicación… + https://smartregister.org/care-team-tag-id + https://smartregister.org/location-tag-id + https://smartregister.org/organization-tag-id + https://smartregister.org/practitioner-tag-id + https://smartregister.org/ related-entity-location-tag-id + Ubicación de entidad relacionada + Equipo de atención de profesionales + Ubicación del practicante + Organización profesional + Practicante + Año + Mes + Semana(s) + Días(s) + Inicializando configuración … + por ejemplo, JohnDoe + %1$d%% + Algo salió mal… + Perspectivas + "Contactar con ayuda" + "Mapas sin conexión" + Descartar + Información del usuario + Información de la tarea + Información de la aplicación + Información del dispositivo + Actualizar + Recursos no sincronizados + Todos los recursos sincronizados + Estadísticas sincronizadas + Todos los datos sincronizados + Se requiere configuración de usuario. Habilite su conexión a Internet + Seleccionar mes + Usuario + Equipo + Localidad + Equipo(Organización) + Equipo de atención + Ubicación + Código de versión de la aplicación + Fecha de compilación + Fabricante + Versión de la aplicación + Versión del sistema operativo + Fecha + Dispositivo + OK + AÑADIR + Migración de datos iniciada desde la versión %1$d + Datos de la aplicación migrados a la versión %1$d + Sin conjunto de datos + archivo + + + Volver a la lista de clientes + Resultados de la prueba + + Editar información + + Clientes + Configuración + ¡No se puede encontrar el cuestionario principal! + + Cargando formularios... + Cargando respuestas... + No se encontraron resultados de la prueba + RESULTADOS DE LA PRUEBA + Última prueba: %1$s + 2,4 km + # + Tareas + Hogar + Visita de rutina + Visita de ANC + En riesgo + Sin tareas + AGREGAR MIEMBRO + ¿Qué desea hacer? + + + Ver perfil + Detalles de la familia + Cambiar cabeza de familia + Cambiar cuidador principal + Actividad familiar + Ver encuentros pasados + Eliminar familia + Familia %1$s + Eliminar esta persona + Detalles individuales + Ver familia + Registrar niño enfermo + + + Sincronización de dispositivo a dispositivo + Clientes de ANC + Todos los clientes + Clientes de PNC + Lista de clientes + Rastreo telefónico + Rastreo domiciliario + Citas + Clientes de planificación familiar + Hogares + Niños + + + Si eliminas a %1$s, se eliminará todo su historial médico de tu dispositivo. Esta acción no se puede deshacer. + + + %1$s se eliminará de forma permanente de esta familia. Esta acción no se puede deshacer. + Eliminar miembro + + + Rango de fechas + Cambiar + Todos + Individual + Paciente + Asunto + GENERAR INFORME + SELECCIONAR PACIENTE + SELECCIONAR ASUNTO + Fecha de inicio + Fecha de finalización + + + No se encontraron miembros de familia elegibles para el jefe de familia. + Seleccionar un nuevo jefe de familia + Asignar nuevo jefe de familia + ¿Está seguro de que desea cancelar esta operación? + + + Si abandona el sitio antes de guardar, se perderán sus modificaciones o cambios + %1$s vencen el %2$s + %1$s vencen hoy + %1$s vencidos hace %2$s + Registrar como ANC + Resultado del embarazo + Registros + Sin ubicación establecida + Establecer ubicación para sincronizar datos y cargar puntos de servicio + Establecer ubicación + + %1$s (%2$s) + Vence el %1$s + + Destino del fragmento de GeoWidget + + No se pudieron extraer los recursos para %1$s + Falta StructureMap para Questionnaire, QuestionnaireResponse guardado + Se extrajeron correctamente los recursos para %1$s + Entidad administradora reasignada correctamente + No se pudieron obtener los detalles del usuario + No se encontró el cuestionario. Sincronizar todos los cuestionarios para solucionarlo + No hay visitas + Respuesta al cuestionario no válida + https://smartregister.org/app-version + Versión de la aplicación + Falta el tipo de tema en el cuestionario. Proporcione Questionnaire.subjectType para solucionarlo. + Se requiere QuestionnaireConfig, pero no está disponible. + Error al completar algunos campos del cuestionario. Respuesta de cuestionario no válida. + Procesando datos del cuestionario… + Cargando cuestionario… + Borrar todo + + Acceso a la ubicación denegado: para capturar las coordenadas GPS, habilite los permisos de ubicación en la configuración de su dispositivo. + Los servicios de ubicación están deshabilitados. ¿Desea habilitarlos? + Enlace %1$s copiado correctamente + + El recurso base para GeoWidgetConfiguration DEBE ser Ubicación + No se proporcionaron configuraciones para la barra de búsqueda + No se encontró ninguna ubicación que coincida con el texto \"%1$s\" + + + + + + + Siguiente + Anterior + Atrás + Editar + Revisar respuestas + Revisar + Enviar + @android:string/cancel + + + Errores encontrados + Corrige las siguientes preguntas: + • %s + Enviar de todos modos + Corregir preguntas + + + ¿Quieres salir del cuestionario? + + + + "Sin respuesta" + Ayuda + + + No + + + + - + + + Otro + Ingresar opción personalizada + Agregar otra respuesta + Seleccione todo lo que corresponda + Guardar + Cancelar + + + Fecha + Hora + Seleccionar fecha + Seleccionar tiempo + + + Tomar foto + Vista previa de foto + Vista previa del icono de archivo + Subir foto + Subir audio + Subir vídeo + Subir documento + Subir archivo + Eliminar + Error : El tamaño de la imagen es mayor que %1$s MB + Error: El tamaño del archivo es mayor que %1$s MB + Error en la carga + Error: formato multimedia incorrecto + Subido + Imagen cargada + Archivo subido + Video subido + Audio subido + Imagen eliminada + Archivo eliminado + Video eliminado + Audio eliminado + + + Agregar elemento + + + + + Falta respuesta para el campo obligatorio. + El valor mínimo permitido es:%1$s + El valor máximo permitido es:%1$s + El número mínimo de caracteres que están permitidos en la respuesta es: %1$s + El número máximo de decimales que están permitidos en la respuesta la respuesta es: %1$s + La respuesta no coincide con la expresión regular: %1$s + Utilice únicamente (.) entre dos números. No se admiten otros caracteres especiales. + El formato de fecha debe ser %1$s (por ejemplo, %2$s ) + El número debe estar entre %1$s y %2 $s + Número no válido + Opcional + Obligatorio + Requerido\n + \u0020\u002a + %1$s. %2$s + Agregar %1$s + + + diff --git a/android/quest/src/gizEir/res/values-fr/strings.xml b/android/quest/src/gizEir/res/values-fr/strings.xml new file mode 100644 index 00000000000..a3541cafb28 --- /dev/null +++ b/android/quest/src/gizEir/res/values-fr/strings.xml @@ -0,0 +1,407 @@ + + + + Synchronisation manuelle + Langue + Se déconnecter en tant que + Afficher les retards + Rechercher le nom ou l\'ID + Nom de recherche + Synchroniser les données + NUMÉRISER LE CODE-BARRES + Aucun résultat + Désolé, nous n\'avons pas pu trouver le client avec son nom ou son identifiant + Enregistrer un nouveau client + Application Fhir + Logo de l\'application + Développé par + Version de l\'application %1$d(%2$s) + Version de migration de données %1$s + Dernière synchronisation %1$s + START + Impossible de vérifier les informations d\'identification du serveur. Vérifiez votre connexion Internet + Impossible d\'obtenir les informations utilisateur du serveur. Vérifiez votre connexion Internet + Sélectionner la langue + Déconnectez-vous en tant que %1$s + Synchronisation terminée + Synchronisation + Synchronisation + Synchronisation + Synchronisation démarrée… + La synchronisation a échoué. Veuillez vérifier votre connexion Internet ou réessayer plus tard + Synchronisation terminée avec des erreurs. Nouvelle tentative… + La synchronisation a échoué car les informations d\'authentification ne sont pas valides. + Impossible de récupérer les informations d\'identification du serveur. Veuillez vérifier votre connexion Internet. + Réessayez + Vacciné + En retard + Vaccin %1$d \n%2$s + "Entièrement vacciné" + "Je ne peux pas recevoir une autre dose. Je suis déjà complètement vacciné" + Enregistrement des vaccins + Dernière vue %1$s + Dernière visite %1$s + Enregistrer + Erreur de connexion : %1$s + SUIVANT + PRÉCÉDENT + Page %1$d sur %2$d + %1$d RÉSULTAT(S) + Aucun résultat + Désolé, nous ne pouvons pas trouver la personne portant le nom ou l\'identifiant fourni + Homme + Femme + Autre + Inconnu + Échec de la synchronisation + Réessayer la synchronisation + Synchronisation en cours + Chargement + Message d\'erreur lors de la déconnexion : %1$s + Déconnexion impossible : vous êtes déjà déconnecté ou l\'appareil est hors ligne. + Impossible de charger la configuration. Veuillez réessayer plus tard + Impossible de charger la configuration. Veuillez vérifier votre connexion Internet + Erreur de connexion au serveur. Veuillez contacter votre administrateur système + Erreur de chargement du formulaire + Erreur rencontrée, impossible d\'enregistrer le formulaire + Traitement des données. Veuillez patienter + Êtes-vous sûr de vouloir revenir en arrière ? + Êtes-vous sûr de vouloir supprimer les réponses ? + Annuler les modifications + Annuler + Enregistrer le brouillon partiel + Annuler + Oui + Les détails fournis comportent des erreurs de validation. Résoudre les erreurs et soumettre à nouveau + Échec de la validation + OK + Nom d\'utilisateur + Mot de passe + Mot de passe oublié + Mot de passe oublié ! + Plus + Veuillez appeler votre superviseur au %1$s + NUMÉRO DE COMPOSER + S\'inscrire + Visites + Rapports + Profil + Paramètres + Sélectionner l\'inscription + Cocher + Clients + Déconnexion + ID d\'application + par ex. ecbis, quest, cha + CHARGER LES CONFIGURATIONS + APPLICATION FHIRCORE + Mémoriser l\'application + Terminé + Remplacer la photo + Modifier + Erreur lors du chargement des configurations de l\'application %1$s + eCBIS + Le nom d\'utilisateur ou le mot de passe n\'est pas valide + ID de formulaire non valide joint + Définir le code PIN + CHA utilisera ce code PIN pour se connecter + Entrez le code PIN pour %1$s + PIN incorrect, veuillez réessayer + Connexion + PIN oublié ? + Veuillez contacter votre superviseur. + % + Déconnexion. Veuillez patienter… + La session a expiré et vous devez vous reconnecter + Sexe + Âge + DATE DE naiss. + ID : %1$s + VOIR TOUT + FORMULAIRES + HISTORIQUE MÉDICAL + SERVICES À VENIR + CARTE DE SERVICE + Autres patients + Sélectionner l\'emplacement + RÉPONSES (%1$s) + Tentative de connexion avec un autre fournisseur + Veuillez patienter… + Réinitialiser les données + Transférer les données + Réinitialiser la base de données ! + La réinitialisation de la base de données effacera tous les enregistrements de votre appareil. Cette action ne peut pas être annulée. + Réinitialisation de l\'application… + https://smartregister.org/care-team-tag-id + https://smartregister.org/location-tag-id + https://smartregister.org/organisation-tag-id + https://smartregister.org/practitioner-tag-id + https://smartregister.org/related-entity-location-tag-id + Emplacement de l\'entité associée + Équipe de soins du praticien + Emplacement du praticien + Organisation du praticien + Praticien + Année + Mois + Semaine(s) + Jours(s) + Initialisation des paramètres … + p.ex. JohnDoe + %1$d%% + Une erreur s\'est produite… + Insights + "Contacter l'aide" + "Cartes hors ligne" + Rejeter + Informations sur l\'utilisateur + Informations sur l\'affectation + Informations sur l\'application + Informations sur l\'appareil + Actualiser + Ressources non synchronisées + Toutes les ressources synchronisées + Statistiques synchronisées + Toutes les données synchronisées + Configuration utilisateur requise. Activer votre connexion Internet + Sélectionner le mois + Utilisateur + Équipe + Localité + Équipe (organisation) + Équipe de soins + Emplacement + Code de version de l\'application + Date de création + Fabricant + Version de l\'application + Version du système d\'exploitation + Date + Appareil + OK + AJOUTER + Démarrage de la migration des données depuis la version %1$d + Données d\'application migrées vers la version %1$d + Aucun ensemble de données + fichier + + + Retour à la liste des clients + Résultats des tests + + Modifier les informations + + Clients + Paramètres + Impossible de trouver le questionnaire parent ! + + Chargement des formulaires... + Chargement des réponses... + Aucun résultat de test trouvé + RÉSULTATS DES TEST + Dernier test - %1$s + 2.4 km + # + Tâches + Ménage + Visite de routine + Visite de CPN + À risque + Aucune tâche + AJOUTER UN MEMBRE + Que voulez-vous faire ? + + + Afficher le profil + Détails de la famille + Changer de chef de famille + Changer de principal soignant + Activité familiale + Afficher les rencontres passées + Supprimer la famille + Famille %1$s + Supprimer cette personne + Détails individuels + Afficher la famille + Enregistrer un enfant malade + + + Synchronisation d\'appareil à appareil + Clients ANC + Tous les clients + Clients PNC + Liste des clients + Suivi téléphonique + Suivi à domicile + Rendez-vous + Clients de planification familiale + Foyers + Enfants + + + La suppression de %1$s supprimera l\'intégralité de son dossier médical de votre appareil. Cette action ne peut pas être annulée. + + + %1$s sera définitivement supprimé de cette famille. Cette action ne peut pas être annulée. + Supprimer le membre + + + Plage de dates + Modifier + Tous + Individu + Patient + Sujet + GÉNÉRER UN RAPPORT + SÉLECTIONNER LE PATIENT + SÉLECTIONNER LE SUJET + Date de début + Date de fin + + + Aucun membre de la famille éligible n\'a été trouvé pour le chef de famille. + Sélectionnez un nouveau chef de famille + Affectez un nouveau chef de famille + Êtes-vous sûr de vouloir abandonner cette opération ? + + + Si vous quittez avant d\'avoir enregistré, vos modifications ou changements seront perdus + %1$s dus le %2$s + %1$s dus aujourd\'hui + %1$s en retard depuis %2$s + Enregistrer comme ANC + Résultat de la grossesse + Registres + Aucun emplacement défini + Définir l\'emplacement pour synchroniser les données et charger les points de service + Définir l\'emplacement + + %1$s (%2$s) + Échéance le %1$s + + Destination du fragment GeoWidget + + Échec de l\'extraction des ressources pour %1$s + StructureMap manquante pour le questionnaire, QuestionnaireResponse enregistrée + Ressources extraites avec succès pour %1$s + Entité de gestion réaffectée avec succès + Échec de la récupération des détails de l\'utilisateur + Questionnaire introuvable. Synchronisez tous les questionnaires pour résoudre le problème + Aucune visite + Réponse au questionnaire non valide + https://smartregister.org/app-version + Version de l\'application + Type de sujet manquant sur le questionnaire. Fournissez Questionnaire.subjectType pour résoudre le problème. + QuestionnaireConfig est requis mais manquant. + Erreur lors du remplissage de certains champs du questionnaire. Réponse au questionnaire non valide. + Traitement des données du questionnaire… + Chargement du questionnaire… + Effacer tout + + Accès à la localisation refusé : pour capturer les coordonnées GPS, veuillez activer les autorisations de localisation dans les paramètres de votre appareil. + Les services de localisation sont désactivés. Voulez-vous les activer ? + Lien %1$s copié avec succès + + La ressource de base pour GeoWidgetConfiguration DOIT être Location + Aucune configuration fournie pour la barre de recherche + Aucun emplacement trouvé correspondant au texte \"%1$s\" + + + + + + + Suivant + Précédent + Retour + Modifier + Examiner les réponses + Révision + Soumettre + @android:string/cancel + + + Erreurs trouvées + Corrigez les questions suivantes : + • %s + Envoyer quand même + Corriger les questions + + + Voulez-vous quitter le questionnaire ? + + + + "Aucune réponse" + Aide + + + Non + Oui + + + - + + + Autre + Entrer une option personnalisée + Ajouter une autre réponse + Sélectionnez tout ce qui s\'applique + Enregistrer + Annuler + + + Date + Heure + Sélectionner la date + Sélectionnez l\'heure + + + Prendre une photo + Aperçu de la photo + Aperçu de l\'icône de fichier + Télécharger une photo + Télécharger l\'audio + Télécharger la vidéo + Télécharger le document + Télécharger le fichier + Supprimer + Erreur : la taille de l\'image est supérieure à %1$s Mo + Erreur : la taille du fichier est supérieure à %1$s Mo + Échec du téléchargement + Erreur : mauvais format de média + Téléchargé + Image téléchargée + Fichier téléchargé + Vidéo mise en ligne + Audio téléchargé + Image supprimée + Fichier supprimé + Vidéo supprimée + Audio supprimé + + + Ajouter un élément + + + + + Réponse manquante pour le champ obligatoire. + La valeur minimale autorisée est :%1$s + La valeur maximale autorisée est :%1$s + Le nombre minimum de caractères autorisés dans la réponse est : %1$s + Le nombre maximum de décimales autorisées dans la réponse est : %1$s + La réponse ne correspond pas à l\'expression régulière : %1$s + Utilisez uniquement (.) entre deux nombres. Les autres caractères spéciaux ne sont pas pris en charge. + Le format de date doit être %1$s (par exemple, %2$s ) + Le nombre doit être compris entre %1$s et %2 $s + Numéro invalide + Facultatif + Obligatoire + Obligatoire\n + \u0020\u002a + %1$s. %2$s + Ajouter %1$s + + + diff --git a/android/quest/src/gizEir/res/values/strings.xml b/android/quest/src/gizEir/res/values/strings.xml new file mode 100644 index 00000000000..eefeafda2d4 --- /dev/null +++ b/android/quest/src/gizEir/res/values/strings.xml @@ -0,0 +1,3 @@ + + No caregiver found matching text \"%1$s\" + diff --git a/android/quest/src/main/AndroidManifest.xml b/android/quest/src/main/AndroidManifest.xml index 827f5d1abdd..46fe396c608 100644 --- a/android/quest/src/main/AndroidManifest.xml +++ b/android/quest/src/main/AndroidManifest.xml @@ -2,21 +2,29 @@ + + + + + + + tools:replace="android:allowBackup,android:theme"> @@ -28,8 +36,7 @@ + android:exported="true"> @@ -39,31 +46,26 @@ + android:launchMode="singleTop" /> + android:launchMode="singleTop" /> + android:launchMode="singleTop" /> @@ -89,8 +91,13 @@ android:name="android.support.FILE_PROVIDER_PATHS" android:resource="@xml/file_paths" /> + + + - - - diff --git a/android/quest/src/main/assets/configs/app/application_config.json b/android/quest/src/main/assets/configs/app/application_config.json index 2e4a334048c..a4af956619e 100644 --- a/android/quest/src/main/assets/configs/app/application_config.json +++ b/android/quest/src/main/assets/configs/app/application_config.json @@ -21,7 +21,8 @@ ], "loginConfig": { "showLogo": true, - "enablePin": true + "enablePin": true, + "supervisorContactNumber": "1234567890" }, "deviceToDeviceSync": { "resourcesToSync": [ @@ -84,5 +85,6 @@ ], "logGpsLocation": [ "QUESTIONNAIRE" - ] + ], + "dateFormat": "MMM d, hh:mm aa" } diff --git a/android/quest/src/main/assets/fhircore_style.json b/android/quest/src/main/assets/fhircore_style.json index 6d44adf8db4..d1e242ecebc 100644 --- a/android/quest/src/main/assets/fhircore_style.json +++ b/android/quest/src/main/assets/fhircore_style.json @@ -12405,21 +12405,33 @@ [ "in_progress" ], - "hsl(45.1,62.5%,50.8%)", + "hsl(47, 60%, 51%)", [ "finished" ], - "hsl(101.6,66.8%,44.9%)", + "hsl(119, 74%, 40%)", [ "not_started" ], - "hsl(0, 0%, 64%)", + "hsl(0, 0%, 56%)", "hsl(100, 100%, 100%)" ], - "circle-stroke-width": 2, "circle-opacity": 1 } }, + { + "id": "data-quest-polygon-text", + "type": "symbol", + "source": "quest-data-set", + "layout": { + "text-field": ["get", "number"], + "text-font": ["DIN Offc Pro Medium", "Arial Unicode MS Bold"], + "text-size": 14 + }, + "paint": { + "text-color": "#ffffff" + } + }, { "id": "select-layer-polygons", "type": "fill", diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/data/DataMigration.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/data/DataMigration.kt index 8807b5d3e63..09ec272474f 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/data/DataMigration.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/data/DataMigration.kt @@ -211,7 +211,9 @@ constructor( } if (migrationConfig.createLocalChangeEntitiesAfterPurge) { defaultRepository.addOrUpdate(resource = updatedResource as Resource) - } else defaultRepository.createRemote(resource = *arrayOf(updatedResource as Resource)) + } else { + defaultRepository.createRemote(resource = arrayOf(updatedResource as Resource)) + } } } Timber.i("Data migration completed successfully for version: ${migrationConfig.version}") diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/data/geowidget/GeoWidgetPagingSource.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/data/geowidget/GeoWidgetPagingSource.kt new file mode 100644 index 00000000000..8e0217064bb --- /dev/null +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/data/geowidget/GeoWidgetPagingSource.kt @@ -0,0 +1,114 @@ +/* + * Copyright 2021-2024 Ona Systems, Inc + * + * 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 org.smartregister.fhircore.quest.data.geowidget + +import android.database.SQLException +import androidx.paging.PagingSource +import androidx.paging.PagingState +import com.google.android.fhir.datacapture.extensions.logicalId +import kotlinx.serialization.json.JsonPrimitive +import org.hl7.fhir.r4.model.Location +import org.smartregister.fhircore.engine.configuration.geowidget.GeoWidgetConfiguration +import org.smartregister.fhircore.engine.data.local.DefaultRepository +import org.smartregister.fhircore.engine.data.local.register.RegisterRepository +import org.smartregister.fhircore.engine.rulesengine.ResourceDataRulesExecutor +import org.smartregister.fhircore.engine.util.extension.interpolate +import org.smartregister.fhircore.geowidget.model.GeoJsonFeature +import org.smartregister.fhircore.geowidget.model.Geometry +import timber.log.Timber + +/** [RegisterRepository] function for loading data to the paging source. */ +class GeoWidgetPagingSource( + private val defaultRepository: DefaultRepository, + private val resourceDataRulesExecutor: ResourceDataRulesExecutor, + private val geoWidgetConfig: GeoWidgetConfiguration, +) : PagingSource() { + + override suspend fun load(params: LoadParams): LoadResult { + return try { + val currentPage = params.key ?: 0 + val prevKey = if (currentPage > 0) currentPage - 1 else null + + val registerData = + defaultRepository.searchResourcesRecursively( + filterActiveResources = null, + fhirResourceConfig = geoWidgetConfig.resourceConfig, + configRules = null, + secondaryResourceConfigs = null, + filterByRelatedEntityLocationMetaTag = + geoWidgetConfig.filterDataByRelatedEntityLocation == true, + currentPage = currentPage, + pageSize = DEFAULT_PAGE_SIZE, + ) + + val nextKey = if (registerData.isNotEmpty()) currentPage + 1 else null + + val data = + registerData + .asSequence() + .filter { it.resource is Location } + .filter { (it.resource as Location).hasPosition() } + .filter { with((it.resource as Location).position) { hasLongitude() && hasLatitude() } } + .map { + Pair( + it.resource as Location, + resourceDataRulesExecutor.processResourceData( + repositoryResourceData = it, + ruleConfigs = geoWidgetConfig.servicePointConfig?.rules ?: emptyList(), + params = emptyMap(), + ), + ) + } + .map { (location, resourceData) -> + GeoJsonFeature( + id = location.logicalId, + geometry = + Geometry( + coordinates = // MapBox coordinates are represented as Long,Lat (NOT Lat,Long) + listOf( + location.position.longitude.toDouble(), + location.position.latitude.toDouble(), + ), + ), + properties = + geoWidgetConfig.servicePointConfig?.servicePointProperties?.mapValues { + JsonPrimitive(it.value.interpolate(resourceData.computedValuesMap)) + } ?: emptyMap(), + ) + } + .toList() + LoadResult.Page(data = data, prevKey = prevKey, nextKey = nextKey) + } catch (exception: SQLException) { + Timber.e(exception) + LoadResult.Error(exception) + } catch (exception: Exception) { + Timber.e(exception) + LoadResult.Error(exception) + } + } + + override fun getRefreshKey(state: PagingState): Int? { + return state.anchorPosition?.let { anchorPosition -> + state.closestPageToPosition(anchorPosition)?.prevKey?.plus(1) + ?: state.closestPageToPosition(anchorPosition)?.nextKey?.minus(1) + } + } + + companion object { + const val DEFAULT_PAGE_SIZE = 20 + } +} diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/data/register/RegisterPagingSource.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/data/register/RegisterPagingSource.kt index ade0d08f77e..f489eba575f 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/data/register/RegisterPagingSource.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/data/register/RegisterPagingSource.kt @@ -64,15 +64,12 @@ class RegisterPagingSource( ) val prevKey = - when { - _registerPagingSourceState.loadAll -> if (currentPage == 0) null else currentPage - 1 - else -> null - } + if (_registerPagingSourceState.loadAll && currentPage > 0) currentPage - 1 else null val nextKey = - when { - _registerPagingSourceState.loadAll -> - if (registerData.isNotEmpty()) currentPage + 1 else null - else -> null + if (_registerPagingSourceState.loadAll && registerData.isNotEmpty()) { + currentPage + 1 + } else { + null } val data = @@ -87,9 +84,13 @@ class RegisterPagingSource( } catch (exception: SQLException) { Timber.e(exception) LoadResult.Error(exception) + } catch (exception: Exception) { + Timber.e(exception) + LoadResult.Error(exception) } } + @Synchronized fun setPatientPagingSourceState(registerPagingSourceState: RegisterPagingSourceState) { this._registerPagingSourceState = registerPagingSourceState } diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/data/report/measure/MeasureReportRepository.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/data/report/measure/MeasureReportRepository.kt index a50b4918699..569fbd42783 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/data/report/measure/MeasureReportRepository.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/data/report/measure/MeasureReportRepository.kt @@ -34,6 +34,7 @@ import org.hl7.fhir.r4.model.ResourceType import org.smartregister.fhircore.engine.configuration.ConfigurationRegistry import org.smartregister.fhircore.engine.configuration.app.ConfigService import org.smartregister.fhircore.engine.configuration.report.measure.ReportConfiguration +import org.smartregister.fhircore.engine.data.local.ContentCache import org.smartregister.fhircore.engine.data.local.DefaultRepository import org.smartregister.fhircore.engine.rulesengine.ConfigRulesExecutor import org.smartregister.fhircore.engine.util.DispatcherProvider @@ -47,7 +48,6 @@ class MeasureReportRepository @Inject constructor( override val fhirEngine: FhirEngine, - override val dispatcherProvider: DispatcherProvider, override val sharedPreferencesHelper: SharedPreferencesHelper, override val configurationRegistry: ConfigurationRegistry, override val configService: ConfigService, @@ -57,10 +57,11 @@ constructor( override val fhirPathDataExtractor: FhirPathDataExtractor, override val parser: IParser, @ApplicationContext override val context: Context, + override val dispatcherProvider: DispatcherProvider, + override val contentCache: ContentCache, ) : DefaultRepository( fhirEngine = fhirEngine, - dispatcherProvider = dispatcherProvider, sharedPreferencesHelper = sharedPreferencesHelper, configurationRegistry = configurationRegistry, configService = configService, @@ -68,6 +69,8 @@ constructor( fhirPathDataExtractor = fhirPathDataExtractor, parser = parser, context = context, + dispatcherProvider = dispatcherProvider, + contentCache = contentCache, ) { /** @@ -91,31 +94,29 @@ constructor( ): List { val measureReport = mutableListOf() try { - withContext(dispatcherProvider.io()) { - if (subjects.isNotEmpty()) { - subjects - .map { - runMeasureReport( - measureUrl = measureUrl, - reportType = MeasureReportViewModel.SUBJECT, - startDateFormatted = startDateFormatted, - endDateFormatted = endDateFormatted, - subject = it, - practitionerId = practitionerId, - ) - } - .forEach { subject -> measureReport.add(subject) } - } else { - runMeasureReport( + if (subjects.isNotEmpty()) { + subjects + .map { + runMeasureReport( measureUrl = measureUrl, - reportType = MeasureReportViewModel.POPULATION, + reportType = MeasureReportViewModel.SUBJECT, startDateFormatted = startDateFormatted, endDateFormatted = endDateFormatted, - subject = null, + subject = it, practitionerId = practitionerId, ) - .also { measureReport.add(it) } - } + } + .forEach { subject -> measureReport.add(subject) } + } else { + runMeasureReport( + measureUrl = measureUrl, + reportType = MeasureReportViewModel.POPULATION, + startDateFormatted = startDateFormatted, + endDateFormatted = endDateFormatted, + subject = null, + practitionerId = practitionerId, + ) + .also { measureReport.add(it) } } measureReport.forEach { report -> @@ -130,6 +131,8 @@ constructor( } } catch (exception: NullPointerException) { Timber.e(exception, "Exception thrown with measureUrl: $measureUrl.") + } catch (exception: IllegalStateException) { + Timber.e(exception, "Exception thrown with measureUrl: $measureUrl.") } return measureReport } @@ -152,17 +155,29 @@ constructor( subject: String?, practitionerId: String?, ): MeasureReport { - return fhirOperator.evaluateMeasure( - measure = - knowledgeManager - .loadResources(ResourceType.Measure.name, measureUrl, null, null, null) - .firstOrNull() as Measure, - start = startDateFormatted, - end = endDateFormatted, - reportType = reportType, - subjectId = subject, - practitioner = practitionerId.takeIf { it?.isNotBlank() == true }, - ) + return withContext(dispatcherProvider.io()) { + try { + fhirOperator.evaluateMeasure( + measure = + knowledgeManager + .loadResources(ResourceType.Measure.name, measureUrl, null, null, null) + .firstOrNull() as Measure, + start = startDateFormatted, + end = endDateFormatted, + reportType = reportType, + subjectId = subject, + practitioner = practitionerId.takeIf { it?.isNotBlank() == true }, + ) + } catch (exception: IllegalArgumentException) { + Timber.e(exception) + throw IllegalArgumentException() + } catch (exception: NoSuchElementException) { + Timber.e(exception) + throw IllegalStateException( + "No FHIR resource found in Knowledge Manager with URL $measureUrl", + ) + } + } } /** diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/event/AppEvent.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/event/AppEvent.kt index ef04e7acacc..68ea1fc31ad 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/event/AppEvent.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/event/AppEvent.kt @@ -22,5 +22,5 @@ sealed class AppEvent { data class OnSubmitQuestionnaire(val questionnaireSubmission: QuestionnaireSubmission) : AppEvent() - data object RefreshRegisterData : AppEvent() + data object RefreshData : AppEvent() } diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/navigation/MainNavigationScreen.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/navigation/MainNavigationScreen.kt index 71d500c8b83..a3ff11c5cc3 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/navigation/MainNavigationScreen.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/navigation/MainNavigationScreen.kt @@ -54,7 +54,7 @@ sealed class MainNavigationScreen( route = org.smartregister.fhircore.quest.R.id.profileFragment, ) - object GeoWidgetLauncher : + data object GeoWidgetLauncher : MainNavigationScreen(route = org.smartregister.fhircore.quest.R.id.geoWidgetLauncherFragment) data object Insight : diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/appsetting/AppSettingActivity.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/appsetting/AppSettingActivity.kt index 2b39c7e560a..985bbc3c50a 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/appsetting/AppSettingActivity.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/appsetting/AppSettingActivity.kt @@ -49,7 +49,7 @@ class AppSettingActivity : AppCompatActivity() { @Inject lateinit var dispatcherProvider: DispatcherProvider - val appSettingViewModel: AppSettingViewModel by viewModels() + private val appSettingViewModel: AppSettingViewModel by viewModels() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -75,7 +75,6 @@ class AppSettingActivity : AppCompatActivity() { loadConfigurations(appSettingActivity) } } else if (!BuildConfig.OPENSRP_APP_ID.isNullOrEmpty()) { - // this part simulates what the user would have done manually via the text field and button appSettingViewModel.onApplicationIdChanged(BuildConfig.OPENSRP_APP_ID) appSettingViewModel.fetchConfigurations(appSettingActivity) } else { diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/appsetting/AppSettingViewModel.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/appsetting/AppSettingViewModel.kt index a3c22a8ca08..f6f2ff9eb4d 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/appsetting/AppSettingViewModel.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/appsetting/AppSettingViewModel.kt @@ -24,11 +24,11 @@ import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import java.net.UnknownHostException import javax.inject.Inject +import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import okhttp3.RequestBody.Companion.toRequestBody import org.apache.commons.lang3.StringUtils -import org.hl7.fhir.r4.model.Binary import org.hl7.fhir.r4.model.Bundle import org.hl7.fhir.r4.model.Composition import org.hl7.fhir.r4.model.ResourceType @@ -101,8 +101,10 @@ constructor( } } + private val exceptionHandler = CoroutineExceptionHandler { _, exception -> Timber.e(exception) } + private fun fetchRemoteConfigurations(appId: String?, context: Context) { - viewModelScope.launch { + viewModelScope.launch(exceptionHandler) { try { showProgressBar.postValue(true) @@ -147,7 +149,6 @@ constructor( return@launch } - val patientRelatedResourceTypes = mutableListOf() compositionResource .retrieveCompositionSections() .asSequence() @@ -173,31 +174,23 @@ constructor( entry.key, parentIt.map { it.focus.extractId() }, ) - } else + } else { fhirResourceDataSource.post( requestBody = generateRequestBundle(entry.key, parentIt.map { it.focus.extractId() }) .encodeResourceToString() .toRequestBody(NetworkModule.JSON_MEDIA_TYPE), ) + } resultBundle.entry.forEach { bundleEntryComponent -> if (bundleEntryComponent.resource != null) { defaultRepository.createRemote(false, bundleEntryComponent.resource) - - if (bundleEntryComponent.resource is Binary) { - configurationRegistry.processResultBundleBinaries( - bundleEntryComponent.resource as Binary, - patientRelatedResourceTypes, - ) - } } } } } - configurationRegistry.saveSyncSharedPreferences(patientRelatedResourceTypes.toList()) - // Save composition after fetching all the referenced section resources defaultRepository.createRemote(false, compositionResource) Timber.d("Done fetching application configurations remotely") diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/bottomsheet/SummaryBottomSheetFragment.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/bottomsheet/SummaryBottomSheetFragment.kt index 8190710a3c6..18d9b6f449f 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/bottomsheet/SummaryBottomSheetFragment.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/bottomsheet/SummaryBottomSheetFragment.kt @@ -49,7 +49,7 @@ class SummaryBottomSheetFragment( setContent { AppTheme { SummaryBottomSheetView( - properties = summaryBottomSheetConfig.views!!, + properties = summaryBottomSheetConfig.views ?: emptyList(), resourceData = resourceData, navController = findNavController(), ) diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/bottomsheet/SummaryBottomSheetView.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/bottomsheet/SummaryBottomSheetView.kt index 193e27bbfcd..4842fc5186d 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/bottomsheet/SummaryBottomSheetView.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/bottomsheet/SummaryBottomSheetView.kt @@ -32,5 +32,6 @@ fun SummaryBottomSheetView( viewProperties = properties, resourceData = resourceData, navController = navController, + decodeImage = null, ) } diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/geowidget/GeoWidgetEvent.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/geowidget/GeoWidgetEvent.kt new file mode 100644 index 00000000000..195c11e8224 --- /dev/null +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/geowidget/GeoWidgetEvent.kt @@ -0,0 +1,29 @@ +/* + * Copyright 2021-2024 Ona Systems, Inc + * + * 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 org.smartregister.fhircore.quest.ui.geowidget + +import org.smartregister.fhircore.engine.configuration.geowidget.GeoWidgetConfiguration +import org.smartregister.fhircore.quest.ui.shared.models.SearchQuery + +sealed class GeoWidgetEvent { + data object ClearMap : GeoWidgetEvent() + + data class RetrieveFeatures( + val geoWidgetConfig: GeoWidgetConfiguration, + val searchQuery: SearchQuery = SearchQuery.emptyText, + ) : GeoWidgetEvent() +} diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/launcher/GeoWidgetLauncherFragment.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/geowidget/GeoWidgetLauncherFragment.kt similarity index 50% rename from android/quest/src/main/java/org/smartregister/fhircore/quest/ui/launcher/GeoWidgetLauncherFragment.kt rename to android/quest/src/main/java/org/smartregister/fhircore/quest/ui/geowidget/GeoWidgetLauncherFragment.kt index f87cc90b207..2b4d302b1e1 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/launcher/GeoWidgetLauncherFragment.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/geowidget/GeoWidgetLauncherFragment.kt @@ -14,9 +14,8 @@ * limitations under the License. */ -package org.smartregister.fhircore.quest.ui.launcher +package org.smartregister.fhircore.quest.ui.geowidget -import android.content.Context import android.os.Bundle import android.view.LayoutInflater import android.view.View @@ -27,25 +26,24 @@ import androidx.compose.foundation.layout.padding import androidx.compose.material.Scaffold import androidx.compose.material.rememberScaffoldState import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.fragment.app.Fragment -import androidx.fragment.app.FragmentManager import androidx.fragment.app.activityViewModels import androidx.fragment.app.viewModels import androidx.lifecycle.Lifecycle -import androidx.lifecycle.flowWithLifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import androidx.navigation.findNavController +import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.navArgs -import com.google.android.fhir.datacapture.extensions.tryUnwrapContext +import com.google.android.fhir.sync.CurrentSyncJobStatus +import com.google.android.fhir.sync.SyncJobStatus +import com.google.android.fhir.sync.SyncOperation import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch @@ -53,53 +51,75 @@ import org.hl7.fhir.r4.model.ResourceType import org.smartregister.fhircore.engine.configuration.ConfigType import org.smartregister.fhircore.engine.configuration.ConfigurationRegistry import org.smartregister.fhircore.engine.configuration.geowidget.GeoWidgetConfiguration -import org.smartregister.fhircore.engine.domain.model.ResourceData +import org.smartregister.fhircore.engine.sync.OnSyncListener +import org.smartregister.fhircore.engine.sync.SyncListenerManager import org.smartregister.fhircore.engine.ui.base.AlertDialogue +import org.smartregister.fhircore.engine.ui.base.AlertIntent import org.smartregister.fhircore.engine.ui.theme.AppTheme +import org.smartregister.fhircore.engine.util.DispatcherProvider +import org.smartregister.fhircore.engine.util.extension.getActivity import org.smartregister.fhircore.engine.util.extension.showToast -import org.smartregister.fhircore.geowidget.model.Feature -import org.smartregister.fhircore.geowidget.screens.GeoWidgetFragment import org.smartregister.fhircore.quest.R import org.smartregister.fhircore.quest.event.AppEvent import org.smartregister.fhircore.quest.event.EventBus import org.smartregister.fhircore.quest.navigation.MainNavigationScreen -import org.smartregister.fhircore.quest.ui.bottomsheet.SummaryBottomSheetFragment +import org.smartregister.fhircore.quest.ui.main.AppMainEvent import org.smartregister.fhircore.quest.ui.main.AppMainUiState import org.smartregister.fhircore.quest.ui.main.AppMainViewModel import org.smartregister.fhircore.quest.ui.main.components.AppDrawer import org.smartregister.fhircore.quest.ui.shared.components.SnackBarMessage +import org.smartregister.fhircore.quest.ui.shared.models.SearchMode +import org.smartregister.fhircore.quest.ui.shared.models.SearchQuery +import org.smartregister.fhircore.quest.ui.shared.viewmodels.SearchViewModel +import org.smartregister.fhircore.quest.util.extensions.handleClickEvent import org.smartregister.fhircore.quest.util.extensions.hookSnackBar import org.smartregister.fhircore.quest.util.extensions.rememberLifecycleEvent import timber.log.Timber @AndroidEntryPoint -class GeoWidgetLauncherFragment : Fragment() { +class GeoWidgetLauncherFragment : Fragment(), OnSyncListener { @Inject lateinit var eventBus: EventBus + @Inject lateinit var syncListenerManager: SyncListenerManager + @Inject lateinit var configurationRegistry: ConfigurationRegistry - private lateinit var geoWidgetFragment: GeoWidgetFragment + + @Inject lateinit var dispatcherProvider: DispatcherProvider + private lateinit var geoWidgetConfiguration: GeoWidgetConfiguration - private val geoWidgetLauncherViewModel by viewModels() private val navArgs by navArgs() + private val geoWidgetLauncherViewModel by viewModels() private val appMainViewModel by activityViewModels() + private val searchViewModel by activityViewModels() override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?, ): View { - buildGeoWidgetFragment() + geoWidgetConfiguration = + configurationRegistry.retrieveConfiguration( + configType = ConfigType.GeoWidget, + configId = navArgs.geoWidgetId, + ) + if (geoWidgetConfiguration.resourceConfig.baseResource.resource != ResourceType.Location) { + val message = getString(R.string.invalid_base_resource) + requireContext().showToast(message) + Timber.e(message, geoWidgetConfiguration.toString()) + requireContext().getActivity()?.finish() + } return ComposeView(requireContext()).apply { setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) setContent { val appConfig = appMainViewModel.applicationConfiguration - val scope = rememberCoroutineScope() + val coroutineScope = rememberCoroutineScope() val scaffoldState = rememberScaffoldState() val uiState: AppMainUiState = appMainViewModel.appMainUiState.value + val appDrawerUIState = appMainViewModel.appDrawerUiState.value val openDrawer: (Boolean) -> Unit = { open: Boolean -> - scope.launch { + coroutineScope.launch { if (open) scaffoldState.drawerState.open() else scaffoldState.drawerState.close() } } @@ -119,16 +139,24 @@ class GeoWidgetLauncherFragment : Fragment() { } AppTheme { - // Register screen provides access to the side navigation Scaffold( drawerGesturesEnabled = scaffoldState.drawerState.isOpen, scaffoldState = scaffoldState, drawerContent = { AppDrawer( appUiState = uiState, + appDrawerUIState = appDrawerUIState, openDrawer = openDrawer, - onSideMenuClick = appMainViewModel::onEvent, + onSideMenuClick = { + if (it is AppMainEvent.TriggerWorkflow) { + searchViewModel.searchQuery.value = SearchQuery.emptyText + } + appMainViewModel.onEvent(it) + }, navController = findNavController(), + unSyncedResourceCount = appMainViewModel.unSyncedResourcesCount, + onCountUnSyncedResources = appMainViewModel::updateUnSyncedResourcesCount, + decodeImage = { geoWidgetLauncherViewModel.getImageBitmap(it) }, ) }, snackbarHost = { snackBarHostState -> @@ -141,17 +169,31 @@ class GeoWidgetLauncherFragment : Fragment() { }, ) { innerPadding -> Box(modifier = Modifier.padding(innerPadding)) { - val fragment = remember { geoWidgetFragment } - GeoWidgetLauncherScreen( + modifier = Modifier.fillMaxSize(), openDrawer = openDrawer, - onEvent = geoWidgetLauncherViewModel::onEvent, navController = findNavController(), toolBarHomeNavigation = navArgs.toolBarHomeNavigation, - modifier = Modifier.fillMaxSize(), // Adjust the modifier as needed - fragmentManager = childFragmentManager, - fragment = fragment, geoWidgetConfiguration = geoWidgetConfiguration, + searchQuery = searchViewModel.searchQuery, + search = { searchText -> + geoWidgetLauncherViewModel.run { + onEvent(GeoWidgetEvent.ClearMap) + onEvent( + GeoWidgetEvent.RetrieveFeatures( + searchQuery = SearchQuery(searchText, SearchMode.KeyboardInput), + geoWidgetConfig = geoWidgetConfiguration, + ), + ) + } + }, + isFirstTimeSync = geoWidgetLauncherViewModel.isFirstTime(), + appDrawerUIState = appDrawerUIState, + clearMapLiveData = geoWidgetLauncherViewModel.clearMapLiveData, + geoJsonFeatures = geoWidgetLauncherViewModel.geoJsonFeatures, + launchQuestionnaire = geoWidgetLauncherViewModel::launchQuestionnaire, + decodeImage = geoWidgetLauncherViewModel::getImageBitmap, + onAppMainEvent = appMainViewModel::onEvent, ) } } @@ -160,87 +202,102 @@ class GeoWidgetLauncherFragment : Fragment() { } } - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - showSetLocationDialog() - setOnQuestionnaireSubmissionListener() - setLocationFromDbCollector() - geoWidgetLauncherViewModel.checkSelectedLocation(geoWidgetConfiguration) - Timber.i("GeoWidgetLauncherFragment onViewCreated") + override fun onResume() { + super.onResume() + syncListenerManager.registerSyncListener(this, lifecycle) } - private fun buildGeoWidgetFragment() { - geoWidgetConfiguration = - configurationRegistry.retrieveConfiguration( - configType = ConfigType.GeoWidget, - configId = navArgs.geoWidgetId, - ) - geoWidgetFragment = - GeoWidgetFragment.builder() - .setUseGpsOnAddingLocation(false) - .setAddLocationButtonVisibility(geoWidgetConfiguration.showAddLocation) - .setOnAddLocationListener { feature: Feature -> - if (feature.geometry?.coordinates == null) return@setOnAddLocationListener - geoWidgetLauncherViewModel.launchQuestionnaire( - geoWidgetConfiguration.registrationQuestionnaire, - feature, - activity?.tryUnwrapContext() as Context, + override fun onSync(syncJobStatus: CurrentSyncJobStatus) { + when (syncJobStatus) { + is CurrentSyncJobStatus.Running -> { + if (syncJobStatus.inProgressSyncJob is SyncJobStatus.InProgress) { + val inProgressSyncJob = syncJobStatus.inProgressSyncJob as SyncJobStatus.InProgress + val isSyncUpload = inProgressSyncJob.syncOperation == SyncOperation.UPLOAD + val progressPercentage = appMainViewModel.calculatePercentageProgress(inProgressSyncJob) + appMainViewModel.updateAppDrawerUIState( + isSyncUpload = isSyncUpload, + currentSyncJobStatus = syncJobStatus, + percentageProgress = progressPercentage, ) } - .setOnCancelAddingLocationListener { - requireContext().showToast("on cancel adding location") - } - .setOnClickLocationListener { feature: Feature, parentFragmentManager: FragmentManager -> - SummaryBottomSheetFragment( - geoWidgetConfiguration.summaryBottomSheetConfig!!, - ResourceData(feature.id, ResourceType.Location, feature.properties), - ) - .run { show(parentFragmentManager, SummaryBottomSheetFragment.TAG) } + } + is CurrentSyncJobStatus.Succeeded, + is CurrentSyncJobStatus.Failed, -> { + appMainViewModel.updateAppDrawerUIState(currentSyncJobStatus = syncJobStatus) + if (syncJobStatus is CurrentSyncJobStatus.Succeeded) { + geoWidgetLauncherViewModel.onEvent(GeoWidgetEvent.ClearMap) } - .setMapLayers(geoWidgetConfiguration.mapLayers) - .showCurrentLocationButtonVisibility(geoWidgetConfiguration.showLocation) - .setPlaneSwitcherButtonVisibility(geoWidgetConfiguration.showPlaneSwitcher) - .build() + geoWidgetLauncherViewModel.onEvent( + GeoWidgetEvent.RetrieveFeatures( + geoWidgetConfig = geoWidgetConfiguration, + searchQuery = searchViewModel.searchQuery.value, + ), + ) + } + else -> appMainViewModel.updateAppDrawerUIState(currentSyncJobStatus = syncJobStatus) + } } - private fun setOnQuestionnaireSubmissionListener() { + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) viewLifecycleOwner.lifecycleScope.launch { - repeatOnLifecycle(Lifecycle.State.STARTED) { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.CREATED) { eventBus.events - .getFor(MainNavigationScreen.GeoWidgetLauncher.eventId(geoWidgetConfiguration.id)) + .getFor(MainNavigationScreen.GeoWidgetLauncher.eventId(navArgs.geoWidgetId)) .onEach { appEvent -> - if (appEvent is AppEvent.OnSubmitQuestionnaire) { - val extractedResourceIds = appEvent.questionnaireSubmission.extractedResourceIds - geoWidgetLauncherViewModel.onQuestionnaireSubmission( - extractedResourceIds, - ) + when (appEvent) { + is AppEvent.RefreshData, + is AppEvent.OnSubmitQuestionnaire, -> { + appMainViewModel.countRegisterData() + geoWidgetLauncherViewModel.run { + onEvent(GeoWidgetEvent.ClearMap) + onEvent( + GeoWidgetEvent.RetrieveFeatures( + geoWidgetConfig = geoWidgetConfiguration, + searchQuery = searchViewModel.searchQuery.value, + ), + ) + } + } } } .launchIn(lifecycleScope) } } - } - - private fun setLocationFromDbCollector() { - viewLifecycleOwner.lifecycleScope.launch { - delay(1000) - geoWidgetLauncherViewModel.locationsFlow - .flowWithLifecycle(lifecycle, Lifecycle.State.STARTED) - .collect { locations -> geoWidgetFragment.addLocationsToMap(locations) } - } - } - - private fun showSetLocationDialog() { - viewLifecycleOwner.lifecycleScope.launch { - geoWidgetLauncherViewModel.locationDialog.observe(requireActivity()) { - AlertDialogue.showConfirmAlert( + geoWidgetLauncherViewModel.noLocationFoundDialog.observe(viewLifecycleOwner) { show -> + if (show) { + AlertDialogue.showAlert( context = requireContext(), - message = R.string.message_location_set, - title = R.string.title_no_location_set, - confirmButtonListener = {}, + alertIntent = AlertIntent.INFO, + message = geoWidgetConfiguration.noResults?.message!!, + title = geoWidgetConfiguration.noResults?.title!!, + confirmButtonListener = { + geoWidgetConfiguration.noResults + ?.actionButton + ?.actions + ?.handleClickEvent(findNavController()) + }, confirmButtonText = R.string.positive_button_location_set, + cancellable = true, + neutralButtonListener = {}, ) } } + geoWidgetLauncherViewModel.onEvent( + GeoWidgetEvent.RetrieveFeatures( + geoWidgetConfig = geoWidgetConfiguration, + searchQuery = searchViewModel.searchQuery.value, + ), + ) + } + + override fun onPause() { + super.onPause() + appMainViewModel.updateAppDrawerUIState(false, null, 0) + } + + override fun onDestroy() { + super.onDestroy() + appMainViewModel.updateAppDrawerUIState(false, null, 0) } } diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/geowidget/GeoWidgetLauncherScreen.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/geowidget/GeoWidgetLauncherScreen.kt new file mode 100644 index 00000000000..86dc61b6bcb --- /dev/null +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/geowidget/GeoWidgetLauncherScreen.kt @@ -0,0 +1,158 @@ +/* + * Copyright 2021-2024 Ona Systems, Inc + * + * 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 org.smartregister.fhircore.quest.ui.geowidget + +import android.content.Context +import android.graphics.Bitmap +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.material.Scaffold +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.fragment.app.FragmentManager +import androidx.fragment.compose.AndroidFragment +import androidx.fragment.compose.rememberFragmentState +import androidx.lifecycle.MutableLiveData +import androidx.navigation.NavController +import org.hl7.fhir.r4.model.ResourceType +import org.smartregister.fhircore.engine.configuration.QuestionnaireConfig +import org.smartregister.fhircore.engine.configuration.geowidget.GeoWidgetConfiguration +import org.smartregister.fhircore.engine.domain.model.ResourceData +import org.smartregister.fhircore.engine.domain.model.ToolBarHomeNavigation +import org.smartregister.fhircore.engine.util.extension.showToast +import org.smartregister.fhircore.geowidget.model.GeoJsonFeature +import org.smartregister.fhircore.geowidget.screens.GeoWidgetFragment +import org.smartregister.fhircore.quest.R +import org.smartregister.fhircore.quest.event.ToolbarClickEvent +import org.smartregister.fhircore.quest.ui.bottomsheet.SummaryBottomSheetFragment +import org.smartregister.fhircore.quest.ui.main.AppMainEvent +import org.smartregister.fhircore.quest.ui.main.components.TopScreenSection +import org.smartregister.fhircore.quest.ui.shared.components.SyncBottomBar +import org.smartregister.fhircore.quest.ui.shared.models.AppDrawerUIState +import org.smartregister.fhircore.quest.ui.shared.models.SearchQuery +import org.smartregister.fhircore.quest.util.extensions.handleClickEvent + +@Composable +fun GeoWidgetLauncherScreen( + modifier: Modifier = Modifier, + openDrawer: (Boolean) -> Unit, + navController: NavController, + toolBarHomeNavigation: ToolBarHomeNavigation = ToolBarHomeNavigation.OPEN_DRAWER, + geoWidgetConfiguration: GeoWidgetConfiguration, + searchQuery: MutableState, + search: (String) -> Unit, + isFirstTimeSync: Boolean, + appDrawerUIState: AppDrawerUIState, + clearMapLiveData: MutableLiveData, + geoJsonFeatures: MutableLiveData>, + launchQuestionnaire: (QuestionnaireConfig, GeoJsonFeature, Context) -> Unit, + decodeImage: ((String) -> Bitmap?)?, + onAppMainEvent: (AppMainEvent) -> Unit, +) { + val context = LocalContext.current + Scaffold( + topBar = { + Column { + TopScreenSection( + title = geoWidgetConfiguration.topScreenSection?.title ?: "", + searchQuery = searchQuery.value, + isSearchBarVisible = geoWidgetConfiguration.topScreenSection?.searchBar?.visible ?: true, + searchPlaceholder = geoWidgetConfiguration.topScreenSection?.searchBar?.display, + showSearchByQrCode = + geoWidgetConfiguration.topScreenSection?.searchBar?.searchByQrCode ?: false, + toolBarHomeNavigation = toolBarHomeNavigation, + performSearchOnValueChanged = false, + onSearchTextChanged = { searchedQuery: SearchQuery, performSearchOnValueChanged -> + searchQuery.value = searchedQuery + if (performSearchOnValueChanged) { + val computedRules = geoWidgetConfiguration.topScreenSection?.searchBar?.computedRules + if (!computedRules.isNullOrEmpty()) { + search(searchQuery.value.query) + } else { + context.showToast(context.getString(R.string.no_search_coonfigs_provided)) + } + } + }, + isFilterIconEnabled = false, + topScreenSection = geoWidgetConfiguration.topScreenSection, + navController = navController, + decodeImage = decodeImage, + ) { event -> + when (event) { + ToolbarClickEvent.Navigate -> + when (toolBarHomeNavigation) { + ToolBarHomeNavigation.OPEN_DRAWER -> openDrawer(true) + ToolBarHomeNavigation.NAVIGATE_BACK -> navController.popBackStack() + } + ToolbarClickEvent.FilterData -> {} + is ToolbarClickEvent.Actions -> + event.actions.handleClickEvent(navController = navController) + } + } + } + }, + bottomBar = { + SyncBottomBar( + isFirstTimeSync = isFirstTimeSync, + appDrawerUIState = appDrawerUIState, + onAppMainEvent = onAppMainEvent, + openDrawer = openDrawer, + ) + }, + ) { innerPadding -> + val fragmentState = rememberFragmentState() + Box(modifier = modifier.padding(innerPadding)) { + AndroidFragment(fragmentState = fragmentState) { fragment -> + fragment + .setUseGpsOnAddingLocation(false) + .setAddLocationButtonVisibility(geoWidgetConfiguration.showAddLocation) + .setOnAddLocationListener { feature: GeoJsonFeature -> + if (feature.geometry?.coordinates == null) return@setOnAddLocationListener + launchQuestionnaire(geoWidgetConfiguration.registrationQuestionnaire, feature, context) + } + .setOnCancelAddingLocationListener { + context.showToast(context.getString(R.string.on_cancel_adding_location)) + } + .setOnClickLocationListener { + feature: GeoJsonFeature, + parentFragmentManager: FragmentManager, + -> + SummaryBottomSheetFragment( + geoWidgetConfiguration.summaryBottomSheetConfig!!, + ResourceData( + baseResourceId = feature.id, + baseResourceType = ResourceType.Location, + computedValuesMap = feature.properties.mapValues { it.value.content }, + ), + ) + .run { show(parentFragmentManager, SummaryBottomSheetFragment.TAG) } + } + .setMapLayers(geoWidgetConfiguration.mapLayers) + .showCurrentLocationButtonVisibility(geoWidgetConfiguration.showLocation) + .setPlaneSwitcherButtonVisibility(geoWidgetConfiguration.showPlaneSwitcher) + + fragment.apply { + observerMapReset(clearMapLiveData) + observerGeoJsonFeatures(geoJsonFeatures) + } + } + } + } +} diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/geowidget/GeoWidgetLauncherViewModel.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/geowidget/GeoWidgetLauncherViewModel.kt new file mode 100644 index 00000000000..5c9eef34ff4 --- /dev/null +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/geowidget/GeoWidgetLauncherViewModel.kt @@ -0,0 +1,340 @@ +/* + * Copyright 2021-2024 Ona Systems, Inc + * + * 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 org.smartregister.fhircore.quest.ui.geowidget + +import android.content.Context +import android.graphics.Bitmap +import androidx.compose.material.SnackbarDuration +import androidx.compose.runtime.mutableStateMapOf +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.google.android.fhir.datacapture.extensions.logicalId +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.JsonPrimitive +import org.hl7.fhir.r4.model.Enumerations +import org.hl7.fhir.r4.model.Location +import org.hl7.fhir.r4.model.ResourceType +import org.smartregister.fhircore.engine.configuration.ConfigType +import org.smartregister.fhircore.engine.configuration.ConfigurationRegistry +import org.smartregister.fhircore.engine.configuration.QuestionnaireConfig +import org.smartregister.fhircore.engine.configuration.app.ApplicationConfiguration +import org.smartregister.fhircore.engine.configuration.geowidget.GeoWidgetConfiguration +import org.smartregister.fhircore.engine.configuration.register.ActiveResourceFilterConfig +import org.smartregister.fhircore.engine.data.local.DefaultRepository +import org.smartregister.fhircore.engine.domain.model.ActionParameter +import org.smartregister.fhircore.engine.domain.model.ActionParameterType +import org.smartregister.fhircore.engine.domain.model.MultiSelectViewAction +import org.smartregister.fhircore.engine.domain.model.SnackBarMessageConfig +import org.smartregister.fhircore.engine.rulesengine.ResourceDataRulesExecutor +import org.smartregister.fhircore.engine.util.DispatcherProvider +import org.smartregister.fhircore.engine.util.SharedPreferenceKey +import org.smartregister.fhircore.engine.util.SharedPreferencesHelper +import org.smartregister.fhircore.engine.util.extension.interpolate +import org.smartregister.fhircore.engine.util.extension.retrieveRelatedEntitySyncLocationState +import org.smartregister.fhircore.geowidget.model.GeoJsonFeature +import org.smartregister.fhircore.geowidget.model.Geometry +import org.smartregister.fhircore.quest.R +import org.smartregister.fhircore.quest.ui.shared.QuestionnaireHandler +import org.smartregister.fhircore.quest.util.extensions.referenceToBitmap +import timber.log.Timber + +@HiltViewModel +class GeoWidgetLauncherViewModel +@Inject +constructor( + val defaultRepository: DefaultRepository, + val dispatcherProvider: DispatcherProvider, + val sharedPreferencesHelper: SharedPreferencesHelper, + val resourceDataRulesExecutor: ResourceDataRulesExecutor, + val configurationRegistry: ConfigurationRegistry, + @ApplicationContext val context: Context, +) : ViewModel() { + val clearMapLiveData: MutableLiveData = MutableLiveData() + val geoJsonFeatures: MutableLiveData> = MutableLiveData() + + private val _snackBarStateFlow = MutableSharedFlow() + val snackBarStateFlow = _snackBarStateFlow.asSharedFlow() + + private val _noLocationFoundDialog = MutableLiveData() + val noLocationFoundDialog: LiveData + get() = _noLocationFoundDialog + + private val applicationConfiguration by lazy { + configurationRegistry.retrieveConfiguration(ConfigType.Application) + } + + private val decodedImageMap = mutableStateMapOf() + + fun onEvent(geoWidgetEvent: GeoWidgetEvent) { + when (geoWidgetEvent) { + is GeoWidgetEvent.RetrieveFeatures -> + retrieveLocations(geoWidgetEvent.geoWidgetConfig, geoWidgetEvent.searchQuery.query) + GeoWidgetEvent.ClearMap -> clearMapLiveData.postValue(true) + } + } + + private fun retrieveLocations( + geoWidgetConfig: GeoWidgetConfiguration, + searchText: String?, + ) { + viewModelScope.launch { + val totalCount = + withContext(dispatcherProvider.io()) { + defaultRepository.countResources( + filterByRelatedEntityLocation = + geoWidgetConfig.filterDataByRelatedEntityLocation == true, + baseResourceConfig = geoWidgetConfig.resourceConfig.baseResource, + filterActiveResources = + listOf( + ActiveResourceFilterConfig( + resourceType = ResourceType.Patient, + active = true, + ), + ActiveResourceFilterConfig( + resourceType = ResourceType.Group, + active = true, + ), + ), + configComputedRuleValues = emptyMap(), + ) + } + if (totalCount == 0L) { + showNoLocationDialog(geoWidgetConfig) + return@launch + } + var count = 0 + var pageNumber = 0 + var locationsWithoutCoordinatesCount = 0L + var registerDataCount = 0L + while (count < totalCount) { + val (locationsWithCoordinates, locationsWithoutCoordinates) = + defaultRepository + .searchResourcesRecursively( + filterActiveResources = null, + fhirResourceConfig = geoWidgetConfig.resourceConfig, + configRules = null, + secondaryResourceConfigs = null, + filterByRelatedEntityLocationMetaTag = + geoWidgetConfig.filterDataByRelatedEntityLocation == true, + currentPage = pageNumber, + pageSize = DefaultRepository.DEFAULT_BATCH_SIZE, + ) + .asSequence() + .filter { it.resource is Location } + .partition { + with((it.resource as Location).position) { hasLongitude() && hasLatitude() } + } + + val registerData = + locationsWithCoordinates + .asSequence() + .map { + Pair( + it.resource as Location, + resourceDataRulesExecutor.processResourceData( + repositoryResourceData = it, + ruleConfigs = geoWidgetConfig.servicePointConfig?.rules ?: emptyList(), + params = emptyMap(), + ), + ) + } + .map { (location, resourceData) -> + GeoJsonFeature( + id = location.logicalId, + geometry = + Geometry( + coordinates = // MapBox coordinates are represented as Long,Lat (NOT Lat,Long) + listOf( + location.position.longitude.toDouble(), + location.position.latitude.toDouble(), + ), + ), + properties = + geoWidgetConfig.servicePointConfig?.servicePointProperties?.mapValues { + JsonPrimitive(it.value.interpolate(resourceData.computedValuesMap)) + } ?: emptyMap(), + ) + } + .toList() + val features = + if (searchText.isNullOrBlank()) { + registerData + } else { + registerData.filter { geoJsonFeature: GeoJsonFeature -> + geoWidgetConfig.topScreenSection?.searchBar?.computedRules?.any { ruleName -> + // if ruleName not found in map return {-1}; check always return false hence no data + val value = geoJsonFeature.properties[ruleName]?.toString() ?: "{-1}" + value.contains(other = searchText, ignoreCase = true) + } == true + } + } + + geoJsonFeatures.postValue(features) + + Timber.w( + locationsWithoutCoordinates.joinToString("\n") { + val position = (it.resource as Location).position + "Location id ${it.resource.logicalId} coordinates (${position.longitude},${position.latitude}) invalid." + }, + ) + pageNumber++ + count += DefaultRepository.DEFAULT_BATCH_SIZE + registerDataCount += features.size + locationsWithoutCoordinatesCount += locationsWithoutCoordinates.size + } + + val locationsCount = if (searchText.isNullOrBlank()) totalCount else registerDataCount + + // Account for locations without coordinates + if (locationsWithoutCoordinatesCount in 1..locationsCount) { + val message = + context.getString( + R.string.locations_without_coordinates, + locationsWithoutCoordinatesCount, + locationsCount, + ) + Timber.w(message) + emitSnackBarState( + SnackBarMessageConfig( + message = message, + actionLabel = context.getString(org.smartregister.fhircore.engine.R.string.ok), + duration = SnackbarDuration.Long, + ), + ) + } else { + val message = + if (searchText.isNullOrBlank()) { + context.getString(R.string.all_locations_rendered) + } else context.getString(R.string.all_matching_locations_rendered, locationsCount) + emitSnackBarState( + SnackBarMessageConfig( + message = message, + actionLabel = context.getString(org.smartregister.fhircore.engine.R.string.ok), + duration = SnackbarDuration.Short, + ), + ) + } + + // Account for missing locations + if (locationsCount == 0L) { + if (!searchText.isNullOrBlank()) { + val message = + context.getString( + R.string.no_found_locations_matching_text, + searchText, + ) + Timber.w(message) + emitSnackBarState( + SnackBarMessageConfig( + message = message, + actionLabel = context.getString(org.smartregister.fhircore.engine.R.string.ok), + duration = SnackbarDuration.Long, + ), + ) + } else { + SnackBarMessageConfig( + message = context.getString(R.string.no_locations_to_render), + actionLabel = context.getString(org.smartregister.fhircore.engine.R.string.ok), + duration = SnackbarDuration.Long, + ) + } + } + } + } + + suspend fun showNoLocationDialog(geoWidgetConfiguration: GeoWidgetConfiguration) { + geoWidgetConfiguration.noResults?.let { + _noLocationFoundDialog.postValue( + context.retrieveRelatedEntitySyncLocationState(MultiSelectViewAction.SYNC_DATA).isEmpty(), + ) + } + } + + fun launchQuestionnaire( + questionnaireConfig: QuestionnaireConfig, + feature: GeoJsonFeature, + context: Context, + ) { + val params = + addMatchingCoordinatesToActionParameters( + feature.geometry?.coordinates?.get(0), + feature.geometry?.coordinates?.get(1), + questionnaireConfig.extraParams, + ) + if (context is QuestionnaireHandler) { + context.launchQuestionnaire( + context = context, + questionnaireConfig = questionnaireConfig, + actionParams = params, + ) + } + } + + /** + * Adds coordinates into the correct action parameter as [ActionParameter.value] if the + * [ActionParameter.key] matches with [KEY_LATITUDE] or [KEY_LONGITUDE] constants. * + */ + private fun addMatchingCoordinatesToActionParameters( + latitude: Double?, + longitude: Double?, + params: List?, + ): List { + if (latitude == null || longitude == null) { + throw IllegalArgumentException("Latitude or Longitude must not be null") + } + params ?: return emptyList() + return params + .filter { + it.paramType == ActionParameterType.PREPOPULATE && + it.dataType == Enumerations.DataType.STRING + } + .map { + return@map when (it.key) { + KEY_LATITUDE -> it.copy(value = latitude.toString()) + KEY_LONGITUDE -> it.copy(value = longitude.toString()) + else -> it + } + } + } + + suspend fun emitSnackBarState(snackBarMessageConfig: SnackBarMessageConfig) { + _snackBarStateFlow.emit(snackBarMessageConfig) + } + + fun isFirstTime(): Boolean = + sharedPreferencesHelper + .read(SharedPreferenceKey.LAST_SYNC_TIMESTAMP.name, null) + .isNullOrEmpty() && applicationConfiguration.usePractitionerAssignedLocationOnSync + + fun getImageBitmap(reference: String) = runBlocking { + reference.referenceToBitmap(defaultRepository.fhirEngine, decodedImageMap) + } + + private companion object { + const val KEY_LATITUDE = "positionLatitude" + const val KEY_LONGITUDE = "positionLongitude" + } +} diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/launcher/GeoWidgetLauncherScreen.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/launcher/GeoWidgetLauncherScreen.kt deleted file mode 100644 index 9f82d59ebb9..00000000000 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/launcher/GeoWidgetLauncherScreen.kt +++ /dev/null @@ -1,121 +0,0 @@ -/* - * Copyright 2021-2024 Ona Systems, Inc - * - * 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 org.smartregister.fhircore.quest.ui.launcher - -import android.view.View -import android.widget.FrameLayout -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.padding -import androidx.compose.material.Scaffold -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.viewinterop.AndroidView -import androidx.fragment.app.Fragment -import androidx.fragment.app.FragmentManager -import androidx.navigation.NavController -import org.smartregister.fhircore.engine.configuration.geowidget.GeoWidgetConfiguration -import org.smartregister.fhircore.engine.domain.model.ToolBarHomeNavigation -import org.smartregister.fhircore.quest.event.ToolbarClickEvent -import org.smartregister.fhircore.quest.ui.main.components.TopScreenSection -import org.smartregister.fhircore.quest.util.extensions.handleClickEvent - -const val NO_REGISTER_VIEW_COLUMN_TEST_TAG = "noRegisterViewColumnTestTag" -const val NO_REGISTER_VIEW_TITLE_TEST_TAG = "noRegisterViewTitleTestTag" -const val NO_REGISTER_VIEW_MESSAGE_TEST_TAG = "noRegisterViewMessageTestTag" -const val NO_REGISTER_VIEW_BUTTON_TEST_TAG = "noRegisterViewButtonTestTag" -const val NO_REGISTER_VIEW_BUTTON_ICON_TEST_TAG = "noRegisterViewButtonIconTestTag" -const val NO_REGISTER_VIEW_BUTTON_TEXT_TEST_TAG = "noRegisterViewButtonTextTestTag" - -@Composable -fun GeoWidgetLauncherScreen( - modifier: Modifier = Modifier, - openDrawer: (Boolean) -> Unit, - onEvent: (GeoWidgetEvent) -> Unit, - navController: NavController, - toolBarHomeNavigation: ToolBarHomeNavigation = ToolBarHomeNavigation.OPEN_DRAWER, - fragmentManager: FragmentManager, - fragment: Fragment, - geoWidgetConfiguration: GeoWidgetConfiguration, -) { - Scaffold( - topBar = { - Column { - /* - * Top section has toolbar and a results counts view - * by default isSearchBarVisible is visible - * */ - TopScreenSection( - title = geoWidgetConfiguration.topScreenSection?.title ?: "", - searchText = "", - filteredRecordsCount = 1, - isSearchBarVisible = geoWidgetConfiguration.topScreenSection?.searchBar?.visible ?: true, - searchPlaceholder = geoWidgetConfiguration.topScreenSection?.searchBar?.display, - toolBarHomeNavigation = toolBarHomeNavigation, - onSearchTextChanged = { searchText -> - onEvent(GeoWidgetEvent.SearchServicePoints(searchText = searchText)) - }, - isFilterIconEnabled = false, - topScreenSection = geoWidgetConfiguration.topScreenSection, - navController = navController, - ) { event -> - when (event) { - ToolbarClickEvent.Navigate -> - when (toolBarHomeNavigation) { - ToolBarHomeNavigation.OPEN_DRAWER -> openDrawer(true) - ToolBarHomeNavigation.NAVIGATE_BACK -> navController.popBackStack() - } - ToolbarClickEvent.FilterData -> {} - is ToolbarClickEvent.Actions -> { - event.actions.handleClickEvent(navController = navController) - } - } - } - } - }, - ) { innerPadding -> - Box(modifier = modifier.padding(innerPadding)) { - FragmentContainerView( - modifier = modifier, - fragmentManager = fragmentManager, - fragment = fragment, - ) - } - } -} - -@Composable -fun FragmentContainerView( - modifier: Modifier = Modifier, - fragmentManager: FragmentManager, - fragment: Fragment, -) { - val viewId = remember { View.generateViewId() } - AndroidView( - modifier = modifier, - factory = { context -> FrameLayout(context).apply { id = viewId } }, - ) - DisposableEffect(fragmentManager, fragment) { - val transaction = fragmentManager.beginTransaction() - transaction.replace(viewId, fragment) - transaction.commitNow() - - onDispose { fragmentManager.beginTransaction().remove(fragment).commitNowAllowingStateLoss() } - } -} diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/launcher/GeoWidgetLauncherViewModel.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/launcher/GeoWidgetLauncherViewModel.kt deleted file mode 100644 index 1808310f5ba..00000000000 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/launcher/GeoWidgetLauncherViewModel.kt +++ /dev/null @@ -1,222 +0,0 @@ -/* - * Copyright 2021-2024 Ona Systems, Inc - * - * 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 org.smartregister.fhircore.quest.ui.launcher - -import android.content.Context -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import dagger.hilt.android.lifecycle.HiltViewModel -import javax.inject.Inject -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asSharedFlow -import kotlinx.coroutines.launch -import org.hl7.fhir.r4.model.Enumerations -import org.hl7.fhir.r4.model.IdType -import org.hl7.fhir.r4.model.Location -import org.hl7.fhir.r4.model.ResourceType -import org.smartregister.fhircore.engine.configuration.QuestionnaireConfig -import org.smartregister.fhircore.engine.configuration.geowidget.GeoWidgetConfiguration -import org.smartregister.fhircore.engine.data.local.DefaultRepository -import org.smartregister.fhircore.engine.domain.model.ActionParameter -import org.smartregister.fhircore.engine.domain.model.ActionParameterType -import org.smartregister.fhircore.engine.domain.model.SnackBarMessageConfig -import org.smartregister.fhircore.engine.rulesengine.ResourceDataRulesExecutor -import org.smartregister.fhircore.engine.util.DispatcherProvider -import org.smartregister.fhircore.engine.util.SharedPreferencesHelper -import org.smartregister.fhircore.engine.util.extension.extractLogicalIdUuid -import org.smartregister.fhircore.engine.util.extension.interpolate -import org.smartregister.fhircore.geowidget.model.Coordinates -import org.smartregister.fhircore.geowidget.model.Feature -import org.smartregister.fhircore.geowidget.model.Geometry -import org.smartregister.fhircore.quest.ui.shared.QuestionnaireHandler - -@HiltViewModel -class GeoWidgetLauncherViewModel -@Inject -constructor( - val defaultRepository: DefaultRepository, - val dispatcherProvider: DispatcherProvider, - val sharedPreferencesHelper: SharedPreferencesHelper, - val resourceDataRulesExecutor: ResourceDataRulesExecutor, -) : ViewModel() { - - private val _snackBarStateFlow = MutableSharedFlow() - val snackBarStateFlow = _snackBarStateFlow.asSharedFlow() - - private val _locationsFlow: MutableStateFlow> = MutableStateFlow(setOf()) - val locationsFlow: StateFlow> = _locationsFlow - - private val _locationDialog = MutableLiveData() - val locationDialog: LiveData - get() = _locationDialog - - // TODO: use List or Linkage resource to connect Location with Group/Patient/etc - private fun retrieveLocations(geoWidgetConfig: GeoWidgetConfiguration) { - viewModelScope.launch(dispatcherProvider.io()) { - // TODO: Loading all the data with the related resources may impact performance. This - // needs to be refactored in future - val repositoryResourceDataList = - defaultRepository.searchResourcesRecursively( - filterActiveResources = null, - fhirResourceConfig = geoWidgetConfig.resourceConfig, - configRules = null, - secondaryResourceConfigs = null, - filterByRelatedEntityLocationMetaTag = false, - ) - - repositoryResourceDataList.forEach { repositoryResourceData -> - val location = repositoryResourceData.resource as Location - val resourceData = - resourceDataRulesExecutor.processResourceData( - repositoryResourceData = repositoryResourceData, - ruleConfigs = geoWidgetConfig.servicePointConfig?.rules!!, - params = emptyMap(), - ) - val servicePointProperties = mutableMapOf() - geoWidgetConfig.servicePointConfig?.servicePointProperties?.forEach { (key, value) -> - servicePointProperties[key] = value.interpolate(resourceData.computedValuesMap) - } - if ( - location.hasPosition() && - location.position.hasLatitude() && - location.position.hasLongitude() - ) { - val feature = - Feature( - id = location.idElement.idPart, - geometry = - Geometry( - coordinates = - arrayListOf( - Coordinates( - latitude = location.position.latitude.toDouble(), - longitude = location.position.longitude.toDouble(), - ), - ), - ), - properties = servicePointProperties, - ) - addLocationToFlow(feature) - } - } - } - } - - fun checkSelectedLocation(configuration: GeoWidgetConfiguration) { - // check preference if location/region is already selected otherwise show dialog to select - // location - // through Location Selector Feature/Screen - // todo - for now we are calling this method, once location Selector is developed, we can remove - // this line - retrieveLocations(configuration) - } - - private fun addLocationToFlow(location: Feature) { - _locationsFlow.value = _locationsFlow.value + location - } - - private suspend fun getLocationFromDb(id: String): Location? { - return defaultRepository.loadResource(id.extractLogicalIdUuid()) - } - - suspend fun onQuestionnaireSubmission(extractedResourceIds: List) { - val locationId = - extractedResourceIds.firstOrNull { it.resourceType == ResourceType.Location.name } ?: return - val location = getLocationFromDb(locationId.valueAsString) ?: return - - val feature = - Feature( - id = location.id, - geometry = - Geometry( - coordinates = - listOf( - Coordinates( - latitude = location.position.latitude.toDouble(), - longitude = location.position.longitude.toDouble(), - ), - ), - ), - // TODO: add initial color for location - ) - addLocationToFlow(feature) - } - - fun launchQuestionnaire( - questionnaireConfig: QuestionnaireConfig, - feature: Feature, - context: Context, - ) { - val params = - addMatchingCoordinatesToActionParameters( - feature.geometry?.coordinates?.get(0)?.latitude!!, - feature.geometry?.coordinates?.get(0)?.longitude, - questionnaireConfig.extraParams, - ) - if (context is QuestionnaireHandler) { - context.launchQuestionnaire( - context = context, - questionnaireConfig = questionnaireConfig, - actionParams = params, - ) - } - } - - fun onEvent(event: GeoWidgetEvent) = - when (event) { - is GeoWidgetEvent.SearchServicePoints -> { - // TODO: here the search bar query will be processed - "" - } - } - - /** - * Adds coordinates into the correct action parameter as [ActionParameter.value] if the - * [ActionParameter.key] matches with [KEY_LATITUDE] or [KEY_LONGITUDE] constants. * - */ - private fun addMatchingCoordinatesToActionParameters( - latitude: Double?, - longitude: Double?, - params: List?, - ): List { - if (latitude == null || longitude == null) { - throw IllegalArgumentException("Latitude or Longitude must not be null") - } - params ?: return emptyList() - return params - .filter { - it.paramType == ActionParameterType.PREPOPULATE && - it.dataType == Enumerations.DataType.STRING - } - .map { - return@map when (it.key) { - KEY_LATITUDE -> it.copy(value = latitude.toString()) - KEY_LONGITUDE -> it.copy(value = longitude.toString()) - else -> it - } - } - } - - private companion object { - const val KEY_LATITUDE = "positionLatitude" - const val KEY_LONGITUDE = "positionLongitude" - } -} diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/login/LoginActivity.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/login/LoginActivity.kt index 367766311d5..ebc621174fc 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/login/LoginActivity.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/login/LoginActivity.kt @@ -24,9 +24,12 @@ import androidx.activity.viewModels import androidx.annotation.VisibleForTesting import androidx.compose.material.ExperimentalMaterialApi import androidx.core.os.bundleOf +import androidx.lifecycle.viewModelScope import androidx.work.WorkManager import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject +import kotlinx.coroutines.launch +import org.smartregister.fhircore.engine.data.local.ContentCache import org.smartregister.fhircore.engine.data.remote.shared.TokenAuthenticator import org.smartregister.fhircore.engine.p2p.dao.P2PReceiverTransferDao import org.smartregister.fhircore.engine.p2p.dao.P2PSenderTransferDao @@ -47,13 +50,21 @@ open class LoginActivity : BaseMultiLanguageActivity() { @Inject lateinit var p2pReceiverTransferDao: P2PReceiverTransferDao + @Inject lateinit var contentCache: ContentCache + @Inject lateinit var workManager: WorkManager val loginViewModel by viewModels() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) this.applyWindowInsetListener() - + loginViewModel.launchDialPad.observe( + this, + ) { phone -> + if (!phone.isNullOrBlank()) { + startActivity(Intent(Intent.ACTION_DIAL).apply { data = Uri.parse("tel:$phone") }) + } + } // Cancel sync background job to get new auth token; login required, refresh token expired val cancelBackgroundSync = intent.extras?.getBoolean(TokenAuthenticator.CANCEL_BACKGROUND_SYNC, false) ?: false @@ -78,16 +89,18 @@ open class LoginActivity : BaseMultiLanguageActivity() { navigateToPinLogin(launchSetup = false) } } - + viewModelScope.launch { contentCache.invalidate() } navigateToHome.observe(loginActivity) { launchHomeScreen -> if (launchHomeScreen) { downloadNowWorkflowConfigs() if (isPinEnabled && !hasActivePin) { navigateToPinLogin(launchSetup = true) - } else loginActivity.navigateToHome() + } else { + loginActivity.navigateToHome() + } } } - launchDialPad.observe(loginActivity) { if (!it.isNullOrEmpty()) launchDialPad(it) } + launchDialPad.observe(loginActivity) { if (!it.isNullOrBlank()) launchDialPad(it) } } } @@ -126,7 +139,7 @@ open class LoginActivity : BaseMultiLanguageActivity() { ) } - private fun launchDialPad(phone: String) { - startActivity(Intent(Intent.ACTION_DIAL).apply { data = Uri.parse(phone) }) + fun launchDialPad(phone: String) { + startActivity(Intent(Intent.ACTION_DIAL).apply { data = Uri.parse("tel:$phone") }) } } diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/login/LoginScreen.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/login/LoginScreen.kt index 0c662d4da63..10a69deda5e 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/login/LoginScreen.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/login/LoginScreen.kt @@ -122,7 +122,7 @@ fun LoginScreen(loginViewModel: LoginViewModel, appVersionPair: Pair Unit, onDismissDialog: () -> Unit, modifier: Modifier = Modifier, @@ -404,7 +406,20 @@ fun ForgotPasswordDialog( ) }, text = { - Text(text = stringResource(R.string.call_supervisor, "012-3456-789"), fontSize = 16.sp) + Column( + modifier = modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp), + ) { + Text( + text = stringResource(R.string.call_supervisor), + fontSize = 16.sp, + ) + if (!supervisorContactNumber.isNullOrBlank()) { + Text( + text = supervisorContactNumber, + fontSize = 16.sp, + ) + } + } }, buttons = { Row( diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/login/LoginViewModel.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/login/LoginViewModel.kt index 45811d2364c..7a369fac669 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/login/LoginViewModel.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/login/LoginViewModel.kt @@ -17,6 +17,7 @@ package org.smartregister.fhircore.quest.ui.login import android.content.Context +import android.widget.Toast import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel @@ -32,9 +33,9 @@ import java.net.SocketTimeoutException import java.net.UnknownHostException import javax.inject.Inject import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import org.hl7.fhir.r4.model.Bundle as FhirR4ModelBundle import org.hl7.fhir.r4.model.ResourceType +import org.smartregister.fhircore.engine.R import org.smartregister.fhircore.engine.configuration.ConfigType import org.smartregister.fhircore.engine.configuration.ConfigurationRegistry import org.smartregister.fhircore.engine.configuration.app.ApplicationConfiguration @@ -50,9 +51,11 @@ import org.smartregister.fhircore.engine.util.SharedPreferenceKey import org.smartregister.fhircore.engine.util.SharedPreferencesHelper import org.smartregister.fhircore.engine.util.clearPasswordInMemory import org.smartregister.fhircore.engine.util.extension.extractLogicalIdUuid +import org.smartregister.fhircore.engine.util.extension.formatPhoneNumber import org.smartregister.fhircore.engine.util.extension.getActivity import org.smartregister.fhircore.engine.util.extension.isDeviceOnline import org.smartregister.fhircore.engine.util.extension.practitionerEndpointUrl +import org.smartregister.fhircore.engine.util.extension.showToast import org.smartregister.fhircore.engine.util.extension.valueToString import org.smartregister.fhircore.quest.BuildConfig import org.smartregister.model.location.LocationHierarchy @@ -124,7 +127,7 @@ constructor( val passwordAsCharArray = password.value!!.trim().toCharArray() viewModelScope.launch(dispatcherProvider.io()) { - if (context.getActivity()!!.isDeviceOnline()) { + if (context.getActivity()?.isDeviceOnline() == true) { fetchToken( username = trimmedUsername, password = passwordAsCharArray, @@ -179,9 +182,14 @@ constructor( } } - fun forgotPassword() { - // TODO load supervisor contact e.g. - _launchDialPad.value = "tel:0123456789" + fun forgotPassword(context: Context) { + val formattedNumber = + applicationConfiguration.loginConfig.supervisorContactNumber.formatPhoneNumber(context) + if (!formattedNumber.isNullOrBlank()) { + _launchDialPad.value = formattedNumber + } else { + context.showToast(context.getString(R.string.missing_supervisor_contact), Toast.LENGTH_LONG) + } } fun updateNavigateHome(navigateHome: Boolean = true) { @@ -305,7 +313,6 @@ constructor( viewModelScope.launch { bundle.entry.forEach { entry -> val practitionerDetails = entry.resource as PractitionerDetails - val careTeams = practitionerDetails.fhirPractitionerDetails?.careTeams ?: listOf() val organizations = practitionerDetails.fhirPractitionerDetails?.organizations ?: listOf() val locations = practitionerDetails.fhirPractitionerDetails?.locations ?: listOf() @@ -316,42 +323,33 @@ constructor( practitionerDetails.fhirPractitionerDetails?.locationHierarchyList ?: listOf() val careTeamIds = - withContext(dispatcherProvider.io()) { - defaultRepository.createRemote(false, *careTeams.toTypedArray()).run { - careTeams.map { it.id.extractLogicalIdUuid() } - } + defaultRepository.createRemote(false, *careTeams.toTypedArray()).run { + careTeams.map { it.id.extractLogicalIdUuid() } } + val organizationIds = - withContext(dispatcherProvider.io()) { - defaultRepository.createRemote(false, *organizations.toTypedArray()).run { - organizations.map { it.id.extractLogicalIdUuid() } - } + defaultRepository.createRemote(false, *organizations.toTypedArray()).run { + organizations.map { it.id.extractLogicalIdUuid() } } + val locationIds = - withContext(dispatcherProvider.io()) { - defaultRepository.createRemote(false, *locations.toTypedArray()).run { - locations.map { it.id.extractLogicalIdUuid() } - } + defaultRepository.createRemote(false, *locations.toTypedArray()).run { + locations.map { it.id.extractLogicalIdUuid() } } val location = - withContext(dispatcherProvider.io()) { - defaultRepository.createRemote(false, *locations.toTypedArray()).run { - locations.map { it.name } - } + defaultRepository.createRemote(false, *locations.toTypedArray()).run { + locations.map { it.name } } val careTeam = - withContext(dispatcherProvider.io()) { - defaultRepository.createRemote(false, *careTeams.toTypedArray()).run { - careTeams.map { it.name } - } + defaultRepository.createRemote(false, *careTeams.toTypedArray()).run { + careTeams.map { it.name } } + val organization = - withContext(dispatcherProvider.io()) { - defaultRepository.createRemote(false, *organizations.toTypedArray()).run { - organizations.map { it.name } - } + defaultRepository.createRemote(false, *organizations.toTypedArray()).run { + organizations.map { it.name } } defaultRepository.createRemote(false, *practitioners.toTypedArray()) diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/main/AppMainActivity.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/main/AppMainActivity.kt index 1366dfff70c..c1c47cdbfd3 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/main/AppMainActivity.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/main/AppMainActivity.kt @@ -22,13 +22,14 @@ import android.app.AlertDialog import android.content.Intent import android.os.Bundle import android.provider.Settings +import android.view.View import android.widget.Toast +import androidx.activity.OnBackPressedCallback import androidx.activity.result.ActivityResult import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.viewModels import androidx.compose.material.ExperimentalMaterialApi -import androidx.core.os.bundleOf import androidx.lifecycle.lifecycleScope import androidx.navigation.findNavController import androidx.navigation.fragment.NavHostFragment @@ -39,23 +40,22 @@ import dagger.hilt.android.AndroidEntryPoint import io.sentry.android.navigation.SentryNavigationListener import java.time.Instant import javax.inject.Inject -import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.async import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext import org.hl7.fhir.r4.model.IdType import org.hl7.fhir.r4.model.QuestionnaireResponse import org.smartregister.fhircore.engine.configuration.QuestionnaireConfig import org.smartregister.fhircore.engine.configuration.app.LocationLogOptions -import org.smartregister.fhircore.engine.configuration.app.SyncStrategy -import org.smartregister.fhircore.engine.configuration.workflow.ActionTrigger import org.smartregister.fhircore.engine.datastore.ProtoDataStore -import org.smartregister.fhircore.engine.datastore.syncLocationIdsProtoStore import org.smartregister.fhircore.engine.domain.model.LauncherType import org.smartregister.fhircore.engine.rulesengine.services.LocationCoordinate import org.smartregister.fhircore.engine.sync.OnSyncListener import org.smartregister.fhircore.engine.sync.SyncListenerManager +import org.smartregister.fhircore.engine.ui.base.AlertDialogue +import org.smartregister.fhircore.engine.ui.base.AlertIntent import org.smartregister.fhircore.engine.ui.base.BaseMultiLanguageActivity -import org.smartregister.fhircore.engine.util.extension.isDeviceOnline +import org.smartregister.fhircore.engine.util.DispatcherProvider import org.smartregister.fhircore.engine.util.extension.parcelable import org.smartregister.fhircore.engine.util.extension.serializable import org.smartregister.fhircore.engine.util.extension.showToast @@ -64,8 +64,9 @@ import org.smartregister.fhircore.engine.util.location.PermissionUtils import org.smartregister.fhircore.quest.R import org.smartregister.fhircore.quest.event.AppEvent import org.smartregister.fhircore.quest.event.EventBus -import org.smartregister.fhircore.quest.navigation.NavigationArg import org.smartregister.fhircore.quest.ui.questionnaire.QuestionnaireActivity +import org.smartregister.fhircore.quest.ui.shared.ActivityOnResultType +import org.smartregister.fhircore.quest.ui.shared.ON_RESULT_TYPE import org.smartregister.fhircore.quest.ui.shared.QuestionnaireHandler import org.smartregister.fhircore.quest.ui.shared.models.QuestionnaireSubmission import timber.log.Timber @@ -79,16 +80,40 @@ open class AppMainActivity : BaseMultiLanguageActivity(), QuestionnaireHandler, @Inject lateinit var protoDataStore: ProtoDataStore @Inject lateinit var eventBus: EventBus + + @Inject lateinit var dispatcherProvider: DispatcherProvider + val appMainViewModel by viewModels() private val sentryNavListener = SentryNavigationListener(enableNavigationBreadcrumbs = true, enableNavigationTracing = true) - private lateinit var locationPermissionLauncher: ActivityResultLauncher> - private lateinit var activityResultLauncher: ActivityResultLauncher + + private val locationPermissionLauncher: ActivityResultLauncher> = + registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { + permissions: Map -> + PermissionUtils.getLocationPermissionLauncher( + permissions = permissions, + onFineLocationPermissionGranted = { fetchLocation() }, + onCoarseLocationPermissionGranted = { fetchLocation() }, + onLocationPermissionDenied = { + showToast( + getString(R.string.location_permissions_denied), + Toast.LENGTH_SHORT, + ) + }, + ) + } + private lateinit var fusedLocationClient: FusedLocationProviderClient override val startForResult = - registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { activityResult -> - if (activityResult.resultCode == Activity.RESULT_OK) { + registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { + activityResult: ActivityResult -> + val onResultType = activityResult.data?.extras?.getString(ON_RESULT_TYPE) + if ( + activityResult.resultCode == Activity.RESULT_OK && + !onResultType.isNullOrBlank() && + ActivityOnResultType.valueOf(onResultType) == ActivityOnResultType.QUESTIONNAIRE + ) { lifecycleScope.launch { onSubmitQuestionnaire(activityResult) } } } @@ -104,79 +129,43 @@ open class AppMainActivity : BaseMultiLanguageActivity(), QuestionnaireHandler, */ override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - setupLocationServices() setContentView(R.layout.activity_main) + lifecycleScope.launch(dispatcherProvider.main()) { + val navController = + (supportFragmentManager.findFragmentById(R.id.nav_host) as NavHostFragment).navController - val startDestinationConfig = - appMainViewModel.applicationConfiguration.navigationStartDestination - val startDestinationArgs = - when (startDestinationConfig.launcherType) { - LauncherType.REGISTER -> { - val topMenuConfig = appMainViewModel.navigationConfiguration.clientRegisters.first() - val clickAction = topMenuConfig.actions?.find { it.trigger == ActionTrigger.ON_CLICK } - bundleOf( - NavigationArg.SCREEN_TITLE to - if (startDestinationConfig.screenTitle.isNullOrEmpty()) { - topMenuConfig.display - } else startDestinationConfig.screenTitle, - NavigationArg.REGISTER_ID to - if (startDestinationConfig.id.isNullOrEmpty()) { - clickAction?.id ?: topMenuConfig.id - } else startDestinationConfig.id, - ) + val graph = + withContext(dispatcherProvider.io()) { + navController.navInflater.inflate(R.navigation.application_nav_graph).apply { + val startDestination = + when ( + appMainViewModel.applicationConfiguration.navigationStartDestination.launcherType + ) { + LauncherType.MAP -> R.id.geoWidgetLauncherFragment + LauncherType.REGISTER -> R.id.registerFragment + } + setStartDestination(startDestination) + } } - LauncherType.MAP -> bundleOf(NavigationArg.GEO_WIDGET_ID to startDestinationConfig.id) - } - // Retrieve the navController directly from the NavHostFragment - val navController = - (supportFragmentManager.findFragmentById(R.id.nav_host) as NavHostFragment).navController - - val graph = - navController.navInflater.inflate(R.navigation.application_nav_graph).apply { - val startDestination = - when (appMainViewModel.applicationConfiguration.navigationStartDestination.launcherType) { - LauncherType.MAP -> R.id.geoWidgetLauncherFragment - LauncherType.REGISTER -> R.id.registerFragment - } - setStartDestination(startDestination) + appMainViewModel.run { + navController.setGraph(graph, getStartDestinationArgs()) + retrieveAppMainUiState() + withContext(dispatcherProvider.io()) { schedulePeriodicJobs(this@AppMainActivity) } } - navController.setGraph(graph, startDestinationArgs) + setupLocationServices() + overrideOnBackPressListener() - // Register sync listener then run sync in that order - syncListenerManager.registerSyncListener(this, lifecycle) - - // Setup the drawer and schedule jobs - appMainViewModel.run { - retrieveAppMainUiState() - if (isDeviceOnline()) { - // Do not schedule sync until location selected when strategy is RelatedEntityLocation - // Use applicationConfiguration.usePractitionerAssignedLocationOnSync to identify - // if we need to trigger sync based on assigned locations or not - if (applicationConfiguration.syncStrategy.contains(SyncStrategy.RelatedEntityLocation)) { - if ( - applicationConfiguration.usePractitionerAssignedLocationOnSync || - runBlocking { syncLocationIdsProtoStore.data.firstOrNull() }?.isNotEmpty() == true - ) { - triggerSync() - } - } else { - triggerSync() - } - } else { - showToast( - getString(org.smartregister.fhircore.engine.R.string.sync_failed), - Toast.LENGTH_LONG, - ) - } - schedulePeriodicJobs() + findViewById(R.id.mainScreenProgressBar).apply { visibility = View.GONE } + findViewById(R.id.mainScreenProgressBarText).apply { visibility = View.GONE } } } override fun onResume() { super.onResume() findNavController(R.id.nav_host).addOnDestinationChangedListener(sentryNavListener) + syncListenerManager.registerSyncListener(this, lifecycle) } override fun onPause() { @@ -184,6 +173,13 @@ open class AppMainActivity : BaseMultiLanguageActivity(), QuestionnaireHandler, findNavController(R.id.nav_host).removeOnDestinationChangedListener(sentryNavListener) } + override fun onQuestionnaireLaunched(questionnaireConfig: QuestionnaireConfig) { + // Data filter QRs are not persisted; reset filters when questionnaire is launched + if (!questionnaireConfig.saveQuestionnaireResponse) { + appMainViewModel.resetRegisterFilters.value = true + } + } + override suspend fun onSubmitQuestionnaire(activityResult: ActivityResult) { if (activityResult.resultCode == RESULT_OK) { val questionnaireResponse: QuestionnaireResponse? = @@ -207,7 +203,9 @@ open class AppMainActivity : BaseMultiLanguageActivity(), QuestionnaireHandler, ), ), ) - } else Timber.e("QuestionnaireConfig & QuestionnaireResponse are both null") + } else { + Timber.e("QuestionnaireConfig & QuestionnaireResponse are both null") + } } } @@ -220,99 +218,68 @@ open class AppMainActivity : BaseMultiLanguageActivity(), QuestionnaireHandler, fusedLocationClient = LocationServices.getFusedLocationProviderClient(this) if (!LocationUtils.isLocationEnabled(this)) { - openLocationServicesSettings() + showLocationSettingsDialog( + Intent(Settings.ACTION_LOCATION_SOURCE_SETTINGS).apply { + putExtra(ON_RESULT_TYPE, ActivityOnResultType.LOCATION.name) + }, + ) } - if (!hasLocationPermissions()) { - launchLocationPermissionsDialog() + if (!PermissionUtils.hasLocationPermissions(this)) { + locationPermissionLauncher.launch( + arrayOf( + Manifest.permission.ACCESS_FINE_LOCATION, + Manifest.permission.ACCESS_COARSE_LOCATION, + ), + ) } - if (LocationUtils.isLocationEnabled(this) && hasLocationPermissions()) { + if (LocationUtils.isLocationEnabled(this) && PermissionUtils.hasLocationPermissions(this)) { fetchLocation() } } } - fun hasLocationPermissions(): Boolean { - return PermissionUtils.checkPermissions( - this, - listOf( - Manifest.permission.ACCESS_COARSE_LOCATION, - Manifest.permission.ACCESS_FINE_LOCATION, - ), - ) - } - - private fun openLocationServicesSettings() { - activityResultLauncher = - PermissionUtils.getStartActivityForResultLauncher(this) { resultCode, _ -> - if (resultCode == RESULT_OK || hasLocationPermissions()) { - Timber.d("Location or permissions successfully enabled") - } - } - - val intent = Intent(Settings.ACTION_LOCATION_SOURCE_SETTINGS) - showLocationSettingsDialog(intent) - } - private fun showLocationSettingsDialog(intent: Intent) { AlertDialog.Builder(this) .setMessage(getString(R.string.location_services_disabled)) .setCancelable(true) - .setPositiveButton(getString(R.string.yes)) { _, _ -> activityResultLauncher.launch(intent) } + .setPositiveButton(getString(R.string.yes)) { _, _ -> startForResult.launch(intent) } .setNegativeButton(getString(R.string.no)) { dialog, _ -> dialog.cancel() } .show() } - fun launchLocationPermissionsDialog() { - locationPermissionLauncher = - PermissionUtils.getLocationPermissionLauncher( - this, - onFineLocationPermissionGranted = { fetchLocation() }, - onCoarseLocationPermissionGranted = { fetchLocation() }, - onLocationPermissionDenied = { - Toast.makeText( - this, - getString(R.string.location_permissions_denied), - Toast.LENGTH_SHORT, - ) - .show() - }, - ) - - locationPermissionLauncher.launch( - arrayOf( - Manifest.permission.ACCESS_FINE_LOCATION, - Manifest.permission.ACCESS_COARSE_LOCATION, - ), - ) - } - - fun fetchLocation() { + private fun fetchLocation() { val context = this lifecycleScope.launch { val retrievedLocation = - if (PermissionUtils.hasFineLocationPermissions(context)) { - LocationUtils.getAccurateLocation(fusedLocationClient) - } else if (PermissionUtils.hasCoarseLocationPermissions(context)) { - LocationUtils.getApproximateLocation(fusedLocationClient) - } else { - null - } - retrievedLocation?.let { - protoDataStore.writeLocationCoordinates( - LocationCoordinate(it.latitude, it.longitude, it.altitude, Instant.now()), - ) - } + async(dispatcherProvider.io()) { + when { + PermissionUtils.hasFineLocationPermissions(context) -> + LocationUtils.getAccurateLocation(fusedLocationClient) + PermissionUtils.hasCoarseLocationPermissions(context) -> + LocationUtils.getApproximateLocation(fusedLocationClient) + else -> null + } + } + .await() + ?.also { + protoDataStore.writeLocationCoordinates( + LocationCoordinate(it.latitude, it.longitude, it.altitude, Instant.now()), + ) + } + if (retrievedLocation == null) { - this@AppMainActivity.showToast("Failed to get GPS location", Toast.LENGTH_LONG) + withContext(dispatcherProvider.main()) { + showToast(getString(R.string.failed_to_get_gps_location), Toast.LENGTH_LONG) + } } } } override fun onSync(syncJobStatus: CurrentSyncJobStatus) { when (syncJobStatus) { - is CurrentSyncJobStatus.Succeeded -> { + is CurrentSyncJobStatus.Succeeded -> appMainViewModel.run { onEvent( AppMainEvent.UpdateSyncState( @@ -320,9 +287,9 @@ open class AppMainActivity : BaseMultiLanguageActivity(), QuestionnaireHandler, lastSyncTime = formatLastSyncTimestamp(syncJobStatus.timestamp), ), ) + appMainViewModel.updateAppDrawerUIState(currentSyncJobStatus = syncJobStatus) } - } - is CurrentSyncJobStatus.Failed -> { + is CurrentSyncJobStatus.Failed -> appMainViewModel.run { onEvent( AppMainEvent.UpdateSyncState( @@ -330,11 +297,33 @@ open class AppMainActivity : BaseMultiLanguageActivity(), QuestionnaireHandler, lastSyncTime = formatLastSyncTimestamp(syncJobStatus.timestamp), ), ) + updateAppDrawerUIState(currentSyncJobStatus = syncJobStatus) } - } else -> { - // Do nothing + // Do Nothing } } } + + private fun overrideOnBackPressListener() { + onBackPressedDispatcher.addCallback( + object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + val navHostFragment = + (supportFragmentManager.findFragmentById(R.id.nav_host) as NavHostFragment) + if (navHostFragment.childFragmentManager.backStackEntryCount == 0) { + AlertDialogue.showAlert( + this@AppMainActivity, + alertIntent = AlertIntent.CONFIRM, + title = getString(R.string.exit_app), + message = getString(R.string.exit_app_message), + cancellable = false, + confirmButtonListener = { finish() }, + neutralButtonListener = { dialog -> dialog.dismiss() }, + ) + } else navHostFragment.navController.navigateUp() + } + }, + ) + } } diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/main/AppMainEvent.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/main/AppMainEvent.kt index f4d7cbba148..e8cc830c469 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/main/AppMainEvent.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/main/AppMainEvent.kt @@ -51,4 +51,6 @@ sealed class AppMainEvent { ) : AppMainEvent() data class SyncData(val context: Context) : AppMainEvent() + + data class CancelSyncData(val context: Context) : AppMainEvent() } diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/main/AppMainViewModel.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/main/AppMainViewModel.kt index 3d58722e11c..ed921891e89 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/main/AppMainViewModel.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/main/AppMainViewModel.kt @@ -16,17 +16,23 @@ package org.smartregister.fhircore.quest.ui.main +import android.content.Context +import android.os.Bundle import android.widget.Toast import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.snapshots.SnapshotStateMap import androidx.core.os.bundleOf +import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.work.WorkManager import androidx.work.workDataOf +import com.google.android.fhir.FhirEngine import com.google.android.fhir.sync.CurrentSyncJobStatus +import com.google.android.fhir.sync.SyncJobStatus import dagger.hilt.android.lifecycle.HiltViewModel import java.text.SimpleDateFormat import java.time.OffsetDateTime @@ -35,6 +41,7 @@ import java.util.Locale import java.util.TimeZone import javax.inject.Inject import kotlin.time.Duration +import kotlinx.coroutines.async import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.hl7.fhir.r4.model.QuestionnaireResponse @@ -43,12 +50,15 @@ import org.smartregister.fhircore.engine.R import org.smartregister.fhircore.engine.configuration.ConfigType import org.smartregister.fhircore.engine.configuration.ConfigurationRegistry import org.smartregister.fhircore.engine.configuration.app.ApplicationConfiguration -import org.smartregister.fhircore.engine.configuration.navigation.ICON_TYPE_REMOTE +import org.smartregister.fhircore.engine.configuration.app.SyncStrategy import org.smartregister.fhircore.engine.configuration.navigation.NavigationConfiguration import org.smartregister.fhircore.engine.configuration.navigation.NavigationMenuConfig import org.smartregister.fhircore.engine.configuration.report.measure.MeasureReportConfiguration import org.smartregister.fhircore.engine.configuration.workflow.ActionTrigger import org.smartregister.fhircore.engine.data.local.register.RegisterRepository +import org.smartregister.fhircore.engine.domain.model.LauncherType +import org.smartregister.fhircore.engine.domain.model.MultiSelectViewAction +import org.smartregister.fhircore.engine.sync.CustomSyncWorker import org.smartregister.fhircore.engine.sync.SyncBroadcaster import org.smartregister.fhircore.engine.task.FhirCarePlanGenerator import org.smartregister.fhircore.engine.task.FhirCompleteCarePlanWorker @@ -59,19 +69,23 @@ import org.smartregister.fhircore.engine.util.DispatcherProvider import org.smartregister.fhircore.engine.util.SecureSharedPreference import org.smartregister.fhircore.engine.util.SharedPreferenceKey import org.smartregister.fhircore.engine.util.SharedPreferencesHelper +import org.smartregister.fhircore.engine.util.extension.countUnSyncedResources import org.smartregister.fhircore.engine.util.extension.extractLogicalIdUuid import org.smartregister.fhircore.engine.util.extension.fetchLanguages +import org.smartregister.fhircore.engine.util.extension.formatDate import org.smartregister.fhircore.engine.util.extension.getActivity import org.smartregister.fhircore.engine.util.extension.isDeviceOnline +import org.smartregister.fhircore.engine.util.extension.reformatDate import org.smartregister.fhircore.engine.util.extension.refresh +import org.smartregister.fhircore.engine.util.extension.retrieveRelatedEntitySyncLocationState import org.smartregister.fhircore.engine.util.extension.setAppLocale import org.smartregister.fhircore.engine.util.extension.showToast import org.smartregister.fhircore.engine.util.extension.tryParse import org.smartregister.fhircore.quest.navigation.MainNavigationScreen import org.smartregister.fhircore.quest.navigation.NavigationArg import org.smartregister.fhircore.quest.ui.report.measure.worker.MeasureReportMonthPeriodWorker +import org.smartregister.fhircore.quest.ui.shared.models.AppDrawerUIState import org.smartregister.fhircore.quest.ui.shared.models.QuestionnaireSubmission -import org.smartregister.fhircore.quest.util.extensions.decodeBinaryResourcesToBitmap import org.smartregister.fhircore.quest.util.extensions.handleClickEvent import org.smartregister.fhircore.quest.util.extensions.schedulePeriodically @@ -87,6 +101,7 @@ constructor( val dispatcherProvider: DispatcherProvider, val workManager: WorkManager, val fhirCarePlanGenerator: FhirCarePlanGenerator, + val fhirEngine: FhirEngine, ) : ViewModel() { val appMainUiState: MutableState = mutableStateOf( @@ -97,6 +112,14 @@ constructor( ), ), ) + private val simpleDateFormat = SimpleDateFormat(SYNC_TIMESTAMP_OUTPUT_FORMAT, Locale.getDefault()) + private val registerCountMap: SnapshotStateMap = mutableStateMapOf() + + val appDrawerUiState = mutableStateOf(AppDrawerUIState()) + + val resetRegisterFilters = MutableLiveData(false) + + val unSyncedResourcesCount = mutableIntStateOf(0) val applicationConfiguration: ApplicationConfiguration by lazy { configurationRegistry.retrieveConfiguration(ConfigType.Application, paramsMap = emptyMap()) @@ -109,19 +132,6 @@ constructor( private val measureReportConfigurations: List by lazy { configurationRegistry.retrieveConfigurations(ConfigType.MeasureReport) } - private val simpleDateFormat = SimpleDateFormat(SYNC_TIMESTAMP_OUTPUT_FORMAT, Locale.getDefault()) - private val registerCountMap: SnapshotStateMap = mutableStateMapOf() - - fun retrieveIconsAsBitmap() { - navigationConfiguration.clientRegisters - .asSequence() - .filter { - it.menuIconConfig != null && - it.menuIconConfig?.type == ICON_TYPE_REMOTE && - !it.menuIconConfig!!.reference.isNullOrEmpty() - } - .decodeBinaryResourcesToBitmap(viewModelScope, registerRepository) - } fun retrieveAppMainUiState(refreshAll: Boolean = true) { if (refreshAll) { @@ -130,7 +140,7 @@ constructor( appTitle = applicationConfiguration.appTitle, currentLanguage = loadCurrentLanguage(), username = secureSharedPreference.retrieveSessionUsername() ?: "", - lastSyncTime = retrieveLastSyncTimestamp() ?: "", + lastSyncTime = getSyncTime(), languages = configurationRegistry.fetchLanguages(), navigationConfiguration = navigationConfiguration, registerCountMap = registerCountMap, @@ -139,6 +149,47 @@ constructor( countRegisterData() } + // todo - if we can move this method to somewhere else where it can be accessed easily on multiple + // view models + /** + * Retrieves the last sync time from shared preferences and returns it in a formatted way. This + * method handles both cases: + * 1. The time stored as a timestamp in milliseconds (preferred). + * 2. Backward compatibility where the time is stored in a formatted string. + * + * @return A formatted sync time string. + */ + fun getSyncTime(): String { + var result = "" + + // First, check if we have any previously stored sync time in SharedPreferences. + retrieveLastSyncTimestamp()?.let { storedDate -> + + // Try to treat the stored time as a timestamp (in milliseconds). + runCatching { + // Attempt to convert the stored date to Long (i.e., millis format) and format it. + result = + formatDate( + timeMillis = storedDate.toLong(), + desireFormat = applicationConfiguration.dateFormat, + ) + } + .onFailure { + // If conversion to Long fails, it's likely that the stored date is in a formatted string + // (backward compatibility). + // Reformat the stored date using the provided SYNC_TIMESTAMP_OUTPUT_FORMAT. + result = + reformatDate( + inputDateString = storedDate, + currentFormat = SYNC_TIMESTAMP_OUTPUT_FORMAT, + desiredFormat = applicationConfiguration.dateFormat, + ) + } + } + + // Return the result (either formatted time in millis or re-formatted backward-compatible date). + return result + } fun countRegisterData() { viewModelScope.launch { @@ -165,14 +216,23 @@ constructor( event.context.showToast(event.context.getString(R.string.sync_failed), Toast.LENGTH_LONG) } } + is AppMainEvent.CancelSyncData -> { + viewModelScope.launch { + workManager.cancelUniqueWork( + "org.smartregister.fhircore.engine.sync.AppSyncWorker-oneTimeSync", + ) + updateAppDrawerUIState(currentSyncJobStatus = CurrentSyncJobStatus.Cancelled) + } + } is AppMainEvent.OpenRegistersBottomSheet -> displayRegisterBottomSheet(event) is AppMainEvent.UpdateSyncState -> { if (event.state is CurrentSyncJobStatus.Succeeded) { sharedPreferencesHelper.write( SharedPreferenceKey.LAST_SYNC_TIMESTAMP.name, - formatLastSyncTimestamp(event.state.timestamp), + event.state.timestamp.toInstant().toEpochMilli().toString(), ) retrieveAppMainUiState() + viewModelScope.launch { retrieveAppMainUiState() } } } is AppMainEvent.TriggerWorkflow -> @@ -239,25 +299,159 @@ constructor( fun retrieveLastSyncTimestamp(): String? = sharedPreferencesHelper.read(SharedPreferenceKey.LAST_SYNC_TIMESTAMP.name, null) - /** This function is used to schedule tasks that are intended to run periodically */ - fun schedulePeriodicJobs() { + fun schedulePeriodicSync() { + viewModelScope.launch { + syncBroadcaster.schedulePeriodicSync(applicationConfiguration.syncInterval) + } + } + + fun getStartDestinationArgs(): Bundle { + val startDestinationConfig = applicationConfiguration.navigationStartDestination + + return when (startDestinationConfig.launcherType) { + LauncherType.REGISTER -> { + val topMenuConfig = navigationConfiguration.clientRegisters.first() + val clickAction = topMenuConfig.actions?.find { it.trigger == ActionTrigger.ON_CLICK } + bundleOf( + NavigationArg.SCREEN_TITLE to + if (startDestinationConfig.screenTitle.isNullOrEmpty()) { + topMenuConfig.display + } else { + startDestinationConfig.screenTitle + }, + NavigationArg.REGISTER_ID to + if (startDestinationConfig.id.isNullOrEmpty()) { + clickAction?.id ?: topMenuConfig.id + } else { + startDestinationConfig.id + }, + ) + } + LauncherType.MAP -> bundleOf(NavigationArg.GEO_WIDGET_ID to startDestinationConfig.id) + } + } + + suspend fun onQuestionnaireSubmission(questionnaireSubmission: QuestionnaireSubmission) { + questionnaireSubmission.questionnaireConfig.taskId?.let { taskId -> + val status: Task.TaskStatus = + when (questionnaireSubmission.questionnaireResponse.status) { + QuestionnaireResponse.QuestionnaireResponseStatus.INPROGRESS -> Task.TaskStatus.INPROGRESS + QuestionnaireResponse.QuestionnaireResponseStatus.COMPLETED -> Task.TaskStatus.COMPLETED + else -> Task.TaskStatus.COMPLETED + } + + withContext(dispatcherProvider.io()) { + fhirCarePlanGenerator.updateTaskDetailsByResourceId( + id = taskId.extractLogicalIdUuid(), + status = status, + ) + } + } + } + + fun calculatePercentageProgress( + progressSyncJobStatus: SyncJobStatus.InProgress, + ): Int { + val totalRecordsOverall = + sharedPreferencesHelper.read( + SharedPreferencesHelper.PREFS_SYNC_PROGRESS_TOTAL + + progressSyncJobStatus.syncOperation.name, + 1L, + ) + val isProgressTotalLess = progressSyncJobStatus.total <= totalRecordsOverall + val currentProgress: Int + val currentTotalRecords = + if (isProgressTotalLess) { + currentProgress = + totalRecordsOverall.toInt() - progressSyncJobStatus.total + + progressSyncJobStatus.completed + totalRecordsOverall.toInt() + } else { + sharedPreferencesHelper.write( + SharedPreferencesHelper.PREFS_SYNC_PROGRESS_TOTAL + + progressSyncJobStatus.syncOperation.name, + progressSyncJobStatus.total.toLong(), + ) + currentProgress = progressSyncJobStatus.completed + progressSyncJobStatus.total + } + + return getSyncProgress(currentProgress, currentTotalRecords) + } + + fun updateAppDrawerUIState( + isSyncUpload: Boolean? = null, + currentSyncJobStatus: CurrentSyncJobStatus?, + percentageProgress: Int? = null, + ) { + appDrawerUiState.value = + AppDrawerUIState( + isSyncUpload = isSyncUpload, + currentSyncJobStatus = currentSyncJobStatus, + percentageProgress = percentageProgress, + ) + } + + fun updateUnSyncedResourcesCount() { + viewModelScope.launch { + unSyncedResourcesCount.intValue = async { fhirEngine.countUnSyncedResources() }.await().size + } + } + + private fun getSyncProgress(completed: Int, total: Int) = + completed * 100 / if (total > 0) total else 1 + + suspend fun schedulePeriodicJobs(context: Context) { + if (context.isDeviceOnline()) { + // Do not schedule sync until location selected when strategy is RelatedEntityLocation + // Use applicationConfiguration.usePractitionerAssignedLocationOnSync to identify + // if we need to trigger sync based on assigned locations or not + if (applicationConfiguration.syncStrategy.contains(SyncStrategy.RelatedEntityLocation)) { + if ( + applicationConfiguration.usePractitionerAssignedLocationOnSync || + context + .retrieveRelatedEntitySyncLocationState(MultiSelectViewAction.SYNC_DATA) + .isNotEmpty() + ) { + schedulePeriodicSync() + } + } else { + schedulePeriodicSync() + } + } else { + with(context) { + withContext(dispatcherProvider.main()) { + showToast(getString(R.string.sync_failed), Toast.LENGTH_LONG) + } + } + } + workManager.run { schedulePeriodically( workId = FhirTaskStatusUpdateWorker.WORK_ID, duration = Duration.tryParse(applicationConfiguration.taskStatusUpdateJobDuration), requiresNetwork = false, + initialDelay = INITIAL_DELAY, ) schedulePeriodically( workId = FhirResourceExpireWorker.WORK_ID, duration = Duration.tryParse(applicationConfiguration.taskExpireJobDuration), requiresNetwork = false, + initialDelay = INITIAL_DELAY, ) schedulePeriodically( workId = FhirCompleteCarePlanWorker.WORK_ID, duration = Duration.tryParse(applicationConfiguration.taskCompleteCarePlanJobDuration), requiresNetwork = false, + initialDelay = INITIAL_DELAY, + ) + + schedulePeriodically( + workId = CustomSyncWorker.WORK_ID, + repeatInterval = applicationConfiguration.syncInterval, + initialDelay = 0, ) measureReportConfigurations.forEach { measureReportConfig -> @@ -270,37 +464,15 @@ constructor( workDataOf( MeasureReportMonthPeriodWorker.MEASURE_REPORT_CONFIG_ID to measureReportConfig.id, ), + initialDelay = INITIAL_DELAY, ) } } } } - fun triggerSync() { - viewModelScope.launch { - syncBroadcaster.schedulePeriodicSync(applicationConfiguration.syncInterval) - } - } - - suspend fun onQuestionnaireSubmission(questionnaireSubmission: QuestionnaireSubmission) { - questionnaireSubmission.questionnaireConfig.taskId?.let { taskId -> - val status: Task.TaskStatus = - when (questionnaireSubmission.questionnaireResponse.status) { - QuestionnaireResponse.QuestionnaireResponseStatus.INPROGRESS -> Task.TaskStatus.INPROGRESS - QuestionnaireResponse.QuestionnaireResponseStatus.COMPLETED -> Task.TaskStatus.COMPLETED - else -> Task.TaskStatus.COMPLETED - } - - withContext(dispatcherProvider.io()) { - fhirCarePlanGenerator.updateTaskDetailsByResourceId( - id = taskId.extractLogicalIdUuid(), - status = status, - ) - } - } - } - companion object { + private const val INITIAL_DELAY = 15L const val SYNC_TIMESTAMP_INPUT_FORMAT = "yyyy-MM-dd'T'HH:mm:ss" const val SYNC_TIMESTAMP_OUTPUT_FORMAT = "MMM d, hh:mm aa" } diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/main/components/AppDrawer.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/main/components/AppDrawer.kt index 2645b9773dc..f1578c313ff 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/main/components/AppDrawer.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/main/components/AppDrawer.kt @@ -16,6 +16,8 @@ package org.smartregister.fhircore.quest.ui.main.components +import android.content.Context +import android.graphics.Bitmap import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -23,6 +25,7 @@ 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.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size @@ -35,10 +38,19 @@ import androidx.compose.material.Icon import androidx.compose.material.Scaffold import androidx.compose.material.Text import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.KeyboardArrowRight +import androidx.compose.material.icons.filled.Error import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableIntState +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +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 @@ -47,11 +59,19 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.navigation.NavController import androidx.navigation.compose.rememberNavController +import com.google.android.fhir.sync.CurrentSyncJobStatus +import com.google.android.fhir.sync.SyncJobStatus +import com.google.android.fhir.sync.SyncOperation +import java.time.OffsetDateTime +import kotlin.time.Duration.Companion.seconds +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import org.smartregister.fhircore.engine.configuration.ConfigType import org.smartregister.fhircore.engine.configuration.navigation.ICON_TYPE_LOCAL import org.smartregister.fhircore.engine.configuration.navigation.ImageConfig @@ -59,13 +79,18 @@ import org.smartregister.fhircore.engine.configuration.navigation.NavigationConf import org.smartregister.fhircore.engine.configuration.navigation.NavigationMenuConfig import org.smartregister.fhircore.engine.configuration.view.ImageProperties import org.smartregister.fhircore.engine.domain.model.Language +import org.smartregister.fhircore.engine.ui.theme.AppTheme import org.smartregister.fhircore.engine.ui.theme.AppTitleColor +import org.smartregister.fhircore.engine.ui.theme.DangerColor import org.smartregister.fhircore.engine.ui.theme.MenuActionButtonTextColor import org.smartregister.fhircore.engine.ui.theme.MenuItemColor import org.smartregister.fhircore.engine.ui.theme.SideMenuBottomItemDarkColor import org.smartregister.fhircore.engine.ui.theme.SideMenuDarkColor import org.smartregister.fhircore.engine.ui.theme.SideMenuTopItemDarkColor import org.smartregister.fhircore.engine.ui.theme.SubtitleTextColor +import org.smartregister.fhircore.engine.ui.theme.SuccessColor +import org.smartregister.fhircore.engine.ui.theme.SyncBarBackgroundColor +import org.smartregister.fhircore.engine.ui.theme.WarningColor import org.smartregister.fhircore.engine.util.annotation.PreviewWithBackgroundExcludeGenerated import org.smartregister.fhircore.engine.util.extension.appVersion import org.smartregister.fhircore.quest.R @@ -73,6 +98,9 @@ import org.smartregister.fhircore.quest.ui.main.AppMainEvent import org.smartregister.fhircore.quest.ui.main.AppMainUiState import org.smartregister.fhircore.quest.ui.main.appMainUiStateOf import org.smartregister.fhircore.quest.ui.shared.components.Image +import org.smartregister.fhircore.quest.ui.shared.components.SyncStatusView +import org.smartregister.fhircore.quest.ui.shared.components.TRANSPARENCY +import org.smartregister.fhircore.quest.ui.shared.models.AppDrawerUIState import org.smartregister.fhircore.quest.util.extensions.handleClickEvent const val SIDE_MENU_ICON = "sideMenuIcon" @@ -84,23 +112,27 @@ const val SIDE_MENU_ITEM_MAIN_ROW_TEST_TAG = "sideMenuItemMainRowTestTag" const val SIDE_MENU_ITEM_INNER_ROW_TEST_TAG = "sideMenuItemInnerRowTestTag" const val SIDE_MENU_ITEM_END_ICON_TEST_TAG = "sideMenuItemEndIconTestTag" const val SIDE_MENU_ITEM_TEXT_TEST_TAG = "sideMenuItemTextTestTag" -const val NAV_BOTTOM_SECTION_SIDE_MENU_ITEM_TEST_TAG = "navBottomSectionSideMenuItemTestTag" -const val NAV_BOTTOM_SECTION_MAIN_BOX_TEST_TAG = "navBottomSectionMainBoxTestTag" private val DividerColor = MenuItemColor.copy(alpha = 0.2f) @Composable fun AppDrawer( modifier: Modifier = Modifier, appUiState: AppMainUiState, + appDrawerUIState: AppDrawerUIState = AppDrawerUIState(), navController: NavController, openDrawer: (Boolean) -> Unit, onSideMenuClick: (AppMainEvent) -> Unit, appVersionPair: Pair? = null, + unSyncedResourceCount: MutableIntState, + onCountUnSyncedResources: () -> Unit, + decodeImage: ((String) -> Bitmap?)?, ) { val context = LocalContext.current val (versionCode, versionName) = remember { appVersionPair ?: context.appVersion() } - val navigationConfiguration = appUiState.navigationConfiguration + + LaunchedEffect(Unit) { onCountUnSyncedResources() } + Scaffold( topBar = { Column(modifier = modifier.background(SideMenuDarkColor)) { @@ -118,15 +150,23 @@ fun AppDrawer( } }, bottomBar = { // Display bottom section of the nav (sync) - NavBottomSection(modifier, appUiState, onSideMenuClick, openDrawer) + NavBottomSection( + appUiState = appUiState, + appDrawerUIState = appDrawerUIState, + unSyncedResourceCount = unSyncedResourceCount, + onSideMenuClick = onSideMenuClick, + openDrawer = openDrawer, + decodeImage = decodeImage, + ) }, - backgroundColor = SideMenuDarkColor, ) { innerPadding -> - Box(modifier = modifier.padding(innerPadding)) { - LazyColumn(modifier = modifier.padding(horizontal = 16.dp)) { + Box( + modifier = modifier.padding(innerPadding).background(SideMenuDarkColor).fillMaxSize(), + ) { + LazyColumn(modifier = modifier) { item { - Column(modifier = modifier.background(SideMenuDarkColor)) { - if (navigationConfiguration.clientRegisters.isNotEmpty()) { + Column(modifier = modifier.padding(horizontal = 16.dp)) { + if (navigationConfiguration.clientRegisters.size > 1) { Text( text = stringResource(id = R.string.registers).uppercase(), fontSize = 14.sp, @@ -143,42 +183,55 @@ fun AppDrawer( imageConfig = navigationMenu.menuIconConfig, title = navigationMenu.display, endText = appUiState.registerCountMap[navigationMenu.id]?.toString() ?: "", + endTextColor = MenuItemColor, showEndText = navigationMenu.showCount, - ) { - openDrawer(false) - onSideMenuClick( - AppMainEvent.TriggerWorkflow(navController = navController, navMenu = navigationMenu), - ) - } + onSideMenuClick = { + openDrawer(false) + onSideMenuClick( + AppMainEvent.TriggerWorkflow( + navController = navController, + navMenu = navigationMenu, + ), + ) + }, + decodeImage = decodeImage, + ) } item { if (navigationConfiguration.bottomSheetRegisters?.registers?.isNotEmpty() == true) { - Column { - OtherPatientsItem( - navigationConfiguration = navigationConfiguration, - onSideMenuClick = onSideMenuClick, - openDrawer = openDrawer, - navController = navController, - ) - if (navigationConfiguration.staticMenu.isNotEmpty()) Divider(color = DividerColor) - } + OtherPatientsItem( + navigationConfiguration = navigationConfiguration, + onSideMenuClick = onSideMenuClick, + openDrawer = openDrawer, + navController = navController, + decodeImage = decodeImage, + ) + if (navigationConfiguration.staticMenu.isNotEmpty()) Divider(color = DividerColor) } } + item { Divider(color = DividerColor) } + // Display list of configurable static menu items(navigationConfiguration.staticMenu, { it.id }) { navigationMenu -> SideMenuItem( imageConfig = navigationMenu.menuIconConfig, title = navigationMenu.display, endText = appUiState.registerCountMap[navigationMenu.id]?.toString() ?: "", + endTextColor = MenuItemColor, showEndText = navigationMenu.showCount, - ) { - openDrawer(false) - onSideMenuClick( - AppMainEvent.TriggerWorkflow(navController = navController, navMenu = navigationMenu), - ) - } + onSideMenuClick = { + openDrawer(false) + onSideMenuClick( + AppMainEvent.TriggerWorkflow( + navController = navController, + navMenu = navigationMenu, + ), + ) + }, + decodeImage = decodeImage, + ) } } } @@ -187,30 +240,143 @@ fun AppDrawer( @Composable private fun NavBottomSection( - modifier: Modifier, appUiState: AppMainUiState, + appDrawerUIState: AppDrawerUIState, + unSyncedResourceCount: MutableIntState, onSideMenuClick: (AppMainEvent) -> Unit, openDrawer: (Boolean) -> Unit, + decodeImage: ((String) -> Bitmap?)?, ) { + val currentSyncJobStatus = appDrawerUIState.currentSyncJobStatus val context = LocalContext.current + val coroutineScope = rememberCoroutineScope() + var showDefaultSyncStatus by remember { mutableStateOf(false) } + val syncStatusBackgroundColor = + when (currentSyncJobStatus) { + is CurrentSyncJobStatus.Failed -> DangerColor.copy(alpha = TRANSPARENCY) + is CurrentSyncJobStatus.Running -> SyncBarBackgroundColor + is CurrentSyncJobStatus.Succeeded -> SuccessColor.copy(alpha = TRANSPARENCY) + else -> Color.Unspecified + } + Box( + modifier = Modifier.background(syncStatusBackgroundColor).fillMaxWidth(), + contentAlignment = Alignment.Center, + ) { + when (currentSyncJobStatus) { + is CurrentSyncJobStatus.Running -> { + SyncStatusView( + isSyncUpload = appDrawerUIState.isSyncUpload, + currentSyncJobStatus = currentSyncJobStatus, + minimized = false, + progressPercentage = appDrawerUIState.percentageProgress, + onCancel = { + onSideMenuClick(AppMainEvent.CancelSyncData(context)) + openDrawer(false) + }, + ) + SideEffect { showDefaultSyncStatus = false } + } + is CurrentSyncJobStatus.Failed -> { + SyncStatusView( + isSyncUpload = appDrawerUIState.isSyncUpload, + currentSyncJobStatus = currentSyncJobStatus, + minimized = false, + ) { + openDrawer(false) + onSideMenuClick(AppMainEvent.SyncData(context)) + } + } + is CurrentSyncJobStatus.Succeeded -> { + LaunchedEffect(Unit) { + coroutineScope.launch { + delay(7.seconds) + showDefaultSyncStatus = true + } + } + if (showDefaultSyncStatus) { + DefaultSyncStatus( + appUiState = appUiState, + context = context, + unSyncedResourceCount = unSyncedResourceCount, + openDrawer = openDrawer, + onSideMenuClick = onSideMenuClick, + decodeImage = decodeImage, + ) + } else { + SyncStatusView( + isSyncUpload = appDrawerUIState.isSyncUpload, + currentSyncJobStatus = currentSyncJobStatus, + minimized = false, + ) + } + } + else -> { + DefaultSyncStatus( + appUiState = appUiState, + context = context, + unSyncedResourceCount = unSyncedResourceCount, + openDrawer = openDrawer, + onSideMenuClick = onSideMenuClick, + decodeImage = decodeImage, + ) + } + } + } +} + +@Composable +private fun DefaultSyncStatus( + appUiState: AppMainUiState, + context: Context, + unSyncedResourceCount: MutableIntState, + openDrawer: (Boolean) -> Unit, + decodeImage: ((String) -> Bitmap?)?, + onSideMenuClick: (AppMainEvent) -> Unit, +) { + val allDataSynced = unSyncedResourceCount.intValue == 0 Box( modifier = - modifier - .testTag(NAV_BOTTOM_SECTION_MAIN_BOX_TEST_TAG) - .background(SideMenuBottomItemDarkColor) - .padding(horizontal = 16.dp, vertical = 4.dp), + Modifier.background( + if (allDataSynced) { + SideMenuBottomItemDarkColor + } else { + WarningColor.copy(alpha = TRANSPARENCY) + }, + ) + .padding(vertical = 16.dp), ) { SideMenuItem( - modifier.testTag(NAV_BOTTOM_SECTION_SIDE_MENU_ITEM_TEST_TAG), - imageConfig = ImageConfig(type = ICON_TYPE_LOCAL, "ic_sync"), - title = stringResource(org.smartregister.fhircore.engine.R.string.sync), + modifier = Modifier, + imageConfig = ImageConfig(type = ICON_TYPE_LOCAL, reference = "ic_sync"), + mainTextColor = if (allDataSynced) Color.White else Color.Unspecified, + title = + stringResource( + if (allDataSynced) { + org.smartregister.fhircore.engine.R.string.manual_sync + } else { + org.smartregister.fhircore.engine.R.string.sync + }, + ), + subTitle = + if (allDataSynced) { + null + } else { + stringResource(org.smartregister.fhircore.engine.R.string.unsynced_data_present) + }, + subTitleTextColor = SubtitleTextColor, endText = appUiState.lastSyncTime, + endTextColor = if (allDataSynced) SubtitleTextColor else Color.Unspecified, + padding = 0, showEndText = true, - endTextColor = SubtitleTextColor, - ) { - openDrawer(false) - onSideMenuClick(AppMainEvent.SyncData(context)) - } + mainTextBold = !allDataSynced, + startIcon = if (allDataSynced) null else Icons.Default.Error, + startIconColor = if (allDataSynced) null else WarningColor, + onSideMenuClick = { + openDrawer(false) + onSideMenuClick(AppMainEvent.SyncData(context)) + }, + decodeImage = decodeImage, + ) } } @@ -218,6 +384,7 @@ private fun NavBottomSection( private fun OtherPatientsItem( navigationConfiguration: NavigationConfiguration, onSideMenuClick: (AppMainEvent) -> Unit, + decodeImage: ((String) -> Bitmap?)?, openDrawer: (Boolean) -> Unit, navController: NavController, ) { @@ -229,24 +396,26 @@ private fun OtherPatientsItem( stringResource(org.smartregister.fhircore.engine.R.string.other_patients) }, endText = "", - showEndText = false, - endImageVector = Icons.Filled.KeyboardArrowRight, endTextColor = SubtitleTextColor, - ) { - openDrawer(false) - onSideMenuClick( - AppMainEvent.OpenRegistersBottomSheet( - registersList = navigationConfiguration.bottomSheetRegisters?.registers, - navController = navController, - title = - if (navigationConfiguration.bottomSheetRegisters?.display.isNullOrEmpty()) { - context.getString(org.smartregister.fhircore.engine.R.string.other_patients) - } else { - navigationConfiguration.bottomSheetRegisters?.display - }, - ), - ) - } + showEndText = false, + endImageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + onSideMenuClick = { + openDrawer(false) + onSideMenuClick( + AppMainEvent.OpenRegistersBottomSheet( + registersList = navigationConfiguration.bottomSheetRegisters?.registers, + navController = navController, + title = + if (navigationConfiguration.bottomSheetRegisters?.display.isNullOrEmpty()) { + context.getString(org.smartregister.fhircore.engine.R.string.other_patients) + } else { + navigationConfiguration.bottomSheetRegisters?.display + }, + ), + ) + }, + decodeImage = decodeImage, + ) } @Composable @@ -264,10 +433,11 @@ private fun NavTopSection( .background(SideMenuTopItemDarkColor) .padding(horizontal = 16.dp) .testTag(NAV_TOP_SECTION_TEST_TAG), + verticalAlignment = Alignment.CenterVertically, ) { Text( text = appUiState.appTitle, - fontSize = 22.sp, + fontSize = 18.sp, color = AppTitleColor, modifier = modifier.padding(top = 16.dp, bottom = 16.dp, end = 8.dp), maxLines = 1, @@ -275,7 +445,7 @@ private fun NavTopSection( ) Text( text = "$versionCode($versionName)", - fontSize = 22.sp, + fontSize = 14.sp, color = AppTitleColor, modifier = modifier.padding(vertical = 16.dp), maxLines = 1, @@ -322,7 +492,7 @@ private fun MenuActionButton( navigationConfiguration.menuActionButton?.display?.uppercase() ?: stringResource(id = org.smartregister.fhircore.engine.R.string.register_new_client), color = MenuActionButtonTextColor, - fontSize = 18.sp, + fontSize = 16.sp, ) } } @@ -332,12 +502,20 @@ private fun MenuActionButton( private fun SideMenuItem( modifier: Modifier = Modifier, imageConfig: ImageConfig? = null, + mainTextColor: Color = Color.White, title: String, + subTitle: String? = null, + subTitleTextColor: Color = SubtitleTextColor, endText: String = "", endTextColor: Color = Color.White, + padding: Int = 12, showEndText: Boolean, endImageVector: ImageVector? = null, + mainTextBold: Boolean = false, + startIcon: ImageVector? = null, + startIconColor: Color? = null, onSideMenuClick: () -> Unit, + decodeImage: ((String) -> Bitmap?)?, ) { Row( horizontalArrangement = Arrangement.SpaceBetween, @@ -345,23 +523,46 @@ private fun SideMenuItem( modifier .fillMaxWidth() .clickable { onSideMenuClick() } - .testTag(SIDE_MENU_ITEM_MAIN_ROW_TEST_TAG), + .padding(vertical = padding.dp) + .testTag(SIDE_MENU_ITEM_MAIN_ROW_TEST_TAG) + .padding(horizontal = 16.dp), verticalAlignment = Alignment.CenterVertically, ) { Row( - modifier = modifier.testTag(SIDE_MENU_ITEM_INNER_ROW_TEST_TAG).padding(vertical = 16.dp), + modifier = + modifier.padding(end = 16.dp).testTag(SIDE_MENU_ITEM_INNER_ROW_TEST_TAG).weight(1f), verticalAlignment = Alignment.CenterVertically, ) { - Image( - paddingEnd = 10, - imageProperties = ImageProperties(imageConfig = imageConfig, size = 32), - tint = MenuItemColor, - navController = rememberNavController(), - ) - SideMenuItemText(title = title, textColor = Color.White) + if (startIcon != null) { + Icon( + imageVector = startIcon, + contentDescription = null, + tint = startIconColor ?: MenuItemColor, + modifier = Modifier.padding(end = 16.dp), + ) + } else { + Image( + paddingEnd = 8, + imageProperties = ImageProperties(imageConfig = imageConfig, size = 32), + tint = MenuItemColor, + navController = rememberNavController(), + decodeImage = decodeImage, + ) + } + Column { + SideMenuItemText(title = title, textColor = mainTextColor, boldText = mainTextBold) + if (!subTitle.isNullOrBlank()) { + SideMenuItemText( + title = subTitle, + textColor = subTitleTextColor, + boldText = false, + textSize = 14, + ) + } + } } if (showEndText) { - SideMenuItemText(title = endText, textColor = endTextColor) + SideMenuItemText(title = endText, textColor = endTextColor, textSize = 14) } endImageVector?.let { imageVector -> Icon( @@ -375,11 +576,18 @@ private fun SideMenuItem( } @Composable -private fun SideMenuItemText(title: String, textColor: Color) { +private fun SideMenuItemText( + title: String, + textColor: Color, + textSize: Int = 16, + boldText: Boolean = false, +) { Text( text = title, color = textColor, - fontSize = 18.sp, + fontSize = textSize.sp, + overflow = TextOverflow.Ellipsis, + fontWeight = if (boldText) FontWeight.Bold else FontWeight.Normal, modifier = Modifier.testTag(SIDE_MENU_ITEM_TEXT_TEST_TAG), ) } @@ -387,27 +595,204 @@ private fun SideMenuItemText(title: String, textColor: Color) { @PreviewWithBackgroundExcludeGenerated @Composable fun AppDrawerPreview() { - AppDrawer( - appUiState = - appMainUiStateOf( - appTitle = "MOH VTS", - username = "Demo", - lastSyncTime = "05:30 PM, Mar 3", - currentLanguage = "English", - languages = listOf(Language("en", "English"), Language("sw", "Swahili")), - navigationConfiguration = - NavigationConfiguration( - appId = "appId", - configType = ConfigType.Navigation.name, - staticMenu = listOf(), - clientRegisters = listOf(), - menuActionButton = - NavigationMenuConfig(id = "id1", visible = true, display = "Register Household"), - ), - ), - navController = rememberNavController(), - openDrawer = {}, - onSideMenuClick = {}, - appVersionPair = Pair(1, "0.0.1"), - ) + AppTheme { + AppDrawer( + appUiState = + appMainUiStateOf( + appTitle = "MOH VTS", + username = "Demo", + lastSyncTime = "Mar 3, 05:30 PM", + currentLanguage = "English", + languages = listOf(Language("en", "English"), Language("sw", "Swahili")), + navigationConfiguration = + NavigationConfiguration( + appId = "appId", + configType = ConfigType.Navigation.name, + staticMenu = listOf(), + clientRegisters = + listOf( + NavigationMenuConfig(id = "id0", visible = true, display = "Households"), + ), + menuActionButton = + NavigationMenuConfig(id = "id1", visible = true, display = "Register Household"), + ), + ), + navController = rememberNavController(), + openDrawer = {}, + onSideMenuClick = {}, + appVersionPair = Pair(1, "0.0.1"), + unSyncedResourceCount = remember { mutableIntStateOf(0) }, + onCountUnSyncedResources = {}, + decodeImage = null, + ) + } +} + +@PreviewWithBackgroundExcludeGenerated +@Composable +fun AppDrawerWithUnSyncedDataPreview() { + AppTheme { + AppDrawer( + appUiState = + appMainUiStateOf( + appTitle = "MOH VTS", + username = "Demo", + lastSyncTime = "Aug 16, 06:54 PM", + currentLanguage = "English", + languages = listOf(Language("en", "English"), Language("sw", "Swahili")), + navigationConfiguration = + NavigationConfiguration( + appId = "appId", + configType = ConfigType.Navigation.name, + staticMenu = listOf(), + clientRegisters = + listOf( + NavigationMenuConfig(id = "id0", visible = true, display = "Households"), + NavigationMenuConfig(id = "id2", visible = true, display = "PNC"), + NavigationMenuConfig(id = "id3", visible = true, display = "ANC"), + NavigationMenuConfig(id = "id4", visible = true, display = "Family Planning"), + ), + menuActionButton = + NavigationMenuConfig(id = "id1", visible = true, display = "Register Household"), + ), + ), + navController = rememberNavController(), + openDrawer = {}, + onSideMenuClick = {}, + appVersionPair = Pair(1, "0.0.1"), + unSyncedResourceCount = remember { mutableIntStateOf(10) }, + onCountUnSyncedResources = {}, + decodeImage = null, + ) + } +} + +@PreviewWithBackgroundExcludeGenerated +@Composable +fun AppDrawerOnSyncCompletePreview() { + AppTheme { + AppDrawer( + appUiState = + appMainUiStateOf( + appTitle = "MOH VTS", + username = "Demo", + lastSyncTime = "Mar 3, 05:30 PM", + currentLanguage = "English", + languages = listOf(Language("en", "English"), Language("sw", "Swahili")), + navigationConfiguration = + NavigationConfiguration( + appId = "appId", + configType = ConfigType.Navigation.name, + staticMenu = listOf(), + clientRegisters = + listOf( + NavigationMenuConfig(id = "id0", visible = true, display = "Households"), + NavigationMenuConfig(id = "id2", visible = true, display = "PNC"), + NavigationMenuConfig(id = "id3", visible = true, display = "ANC"), + NavigationMenuConfig(id = "id4", visible = true, display = "Family Planning"), + ), + menuActionButton = + NavigationMenuConfig(id = "id1", visible = true, display = "Register Household"), + ), + ), + appDrawerUIState = + AppDrawerUIState( + currentSyncJobStatus = CurrentSyncJobStatus.Succeeded(OffsetDateTime.now()), + ), + navController = rememberNavController(), + openDrawer = {}, + onSideMenuClick = {}, + appVersionPair = Pair(1, "0.0.1"), + unSyncedResourceCount = remember { mutableIntStateOf(0) }, + onCountUnSyncedResources = {}, + decodeImage = null, + ) + } +} + +@PreviewWithBackgroundExcludeGenerated +@Composable +fun AppDrawerOnSyncFailedPreview() { + AppTheme { + AppDrawer( + appUiState = + appMainUiStateOf( + appTitle = "MOH VTS", + username = "Demo", + lastSyncTime = "Mar 3, 05:30 PM", + currentLanguage = "English", + languages = listOf(Language("en", "English"), Language("sw", "Swahili")), + navigationConfiguration = + NavigationConfiguration( + appId = "appId", + configType = ConfigType.Navigation.name, + staticMenu = listOf(), + clientRegisters = + listOf( + NavigationMenuConfig(id = "id0", visible = true, display = "Households"), + NavigationMenuConfig(id = "id2", visible = true, display = "PNC"), + NavigationMenuConfig(id = "id3", visible = true, display = "ANC"), + NavigationMenuConfig(id = "id4", visible = true, display = "Family Planning"), + ), + menuActionButton = + NavigationMenuConfig(id = "id1", visible = true, display = "Register Household"), + ), + ), + appDrawerUIState = + AppDrawerUIState( + currentSyncJobStatus = CurrentSyncJobStatus.Failed(OffsetDateTime.now()), + ), + navController = rememberNavController(), + openDrawer = {}, + onSideMenuClick = {}, + appVersionPair = Pair(1, "0.0.1"), + unSyncedResourceCount = remember { mutableIntStateOf(0) }, + onCountUnSyncedResources = {}, + decodeImage = null, + ) + } +} + +@PreviewWithBackgroundExcludeGenerated +@Composable +fun AppDrawerOnSyncRunningPreview() { + AppTheme { + AppDrawer( + appUiState = + appMainUiStateOf( + appTitle = "MOH VTS", + username = "Demo", + lastSyncTime = "Mar 3, 05:30 PM", + currentLanguage = "English", + languages = listOf(Language("en", "English"), Language("sw", "Swahili")), + navigationConfiguration = + NavigationConfiguration( + appId = "appId", + configType = ConfigType.Navigation.name, + staticMenu = listOf(), + clientRegisters = + listOf( + NavigationMenuConfig(id = "id0", visible = true, display = "Households"), + NavigationMenuConfig(id = "id2", visible = true, display = "PNC"), + NavigationMenuConfig(id = "id3", visible = true, display = "ANC"), + NavigationMenuConfig(id = "id4", visible = true, display = "Family Planning"), + ), + menuActionButton = + NavigationMenuConfig(id = "id1", visible = true, display = "Register Household"), + ), + ), + appDrawerUIState = + AppDrawerUIState( + currentSyncJobStatus = + CurrentSyncJobStatus.Running(SyncJobStatus.InProgress(SyncOperation.DOWNLOAD, 200, 35)), + ), + navController = rememberNavController(), + openDrawer = {}, + onSideMenuClick = {}, + appVersionPair = Pair(1, "0.0.1"), + unSyncedResourceCount = remember { mutableIntStateOf(0) }, + onCountUnSyncedResources = {}, + decodeImage = null, + ) + } } diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/main/components/TopScreenSection.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/main/components/TopScreenSection.kt index ad3ee1b198d..29c14d7acc6 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/main/components/TopScreenSection.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/main/components/TopScreenSection.kt @@ -16,18 +16,25 @@ package org.smartregister.fhircore.quest.ui.main.components +import android.graphics.Bitmap import androidx.compose.foundation.background import androidx.compose.foundation.clickable 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.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.Badge import androidx.compose.material.BadgedBox +import androidx.compose.material.DropdownMenu +import androidx.compose.material.DropdownMenuItem import androidx.compose.material.Icon import androidx.compose.material.IconButton import androidx.compose.material.MaterialTheme @@ -35,12 +42,14 @@ import androidx.compose.material.OutlinedTextField import androidx.compose.material.Text import androidx.compose.material.TextFieldDefaults import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Clear -import androidx.compose.material.icons.filled.FilterAlt import androidx.compose.material.icons.filled.Menu import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.outlined.FilterAlt +import androidx.compose.material.icons.outlined.MoreVert 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 @@ -49,22 +58,34 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.navigation.NavController import androidx.navigation.compose.rememberNavController +import kotlin.math.min import org.smartregister.fhircore.engine.R +import org.smartregister.fhircore.engine.configuration.navigation.ICON_TYPE_LOCAL import org.smartregister.fhircore.engine.configuration.navigation.ImageConfig import org.smartregister.fhircore.engine.configuration.view.ImageProperties import org.smartregister.fhircore.engine.domain.model.ToolBarHomeNavigation import org.smartregister.fhircore.engine.domain.model.TopScreenSectionConfig +import org.smartregister.fhircore.engine.ui.theme.DefaultColor import org.smartregister.fhircore.engine.ui.theme.GreyTextColor import org.smartregister.fhircore.engine.util.annotation.PreviewWithBackgroundExcludeGenerated +import org.smartregister.fhircore.engine.util.extension.getActivity +import org.smartregister.fhircore.engine.util.extension.parseColor import org.smartregister.fhircore.quest.event.ToolbarClickEvent import org.smartregister.fhircore.quest.ui.shared.components.Image +import org.smartregister.fhircore.quest.ui.shared.models.SearchMode +import org.smartregister.fhircore.quest.ui.shared.models.SearchQuery +import org.smartregister.fhircore.quest.util.QrCodeScanUtils const val DRAWER_MENU = "Drawer Menu" const val SEARCH = "Search" @@ -77,6 +98,8 @@ const val TOP_ROW_FILTER_ICON_TEST_TAG = "topRowFilterIconTestTag" const val OUTLINED_BOX_TEST_TAG = "outlinedBoxTestTag" const val TRAILING_ICON_TEST_TAG = "trailingIconTestTag" const val TRAILING_ICON_BUTTON_TEST_TAG = "trailingIconButtonTestTag" +const val TRAILING_QR_SCAN_ICON_TEST_TAG = "qrCodeScanTrailingIconTestTag" +const val TRAILING_QR_SCAN_ICON_BUTTON_TEST_TAG = "qrCodeScanTrailingIconButtonTestTag" const val LEADING_ICON_TEST_TAG = "leadingIconTestTag" const val SEARCH_FIELD_TEST_TAG = "searchFieldTestTag" const val TOP_ROW_TOGGLE_ICON_TEST_tAG = "topRowToggleIconTestTag" @@ -85,17 +108,30 @@ const val TOP_ROW_TOGGLE_ICON_TEST_tAG = "topRowToggleIconTestTag" fun TopScreenSection( modifier: Modifier = Modifier, title: String, + navController: NavController, isSearchBarVisible: Boolean, - searchText: String, + searchQuery: SearchQuery, + showSearchByQrCode: Boolean = false, filteredRecordsCount: Long? = null, searchPlaceholder: String? = null, + placeholderColor: String? = null, toolBarHomeNavigation: ToolBarHomeNavigation = ToolBarHomeNavigation.OPEN_DRAWER, - onSearchTextChanged: (String) -> Unit, + onSearchTextChanged: (SearchQuery, Boolean) -> Unit = { _, _ -> }, + performSearchOnValueChanged: Boolean = true, isFilterIconEnabled: Boolean = false, topScreenSection: TopScreenSectionConfig? = null, - navController: NavController, - onClick: (ToolbarClickEvent) -> Unit, + decodeImage: ((String) -> Bitmap?)?, + onClick: (ToolbarClickEvent) -> Unit = {}, ) { + val currentContext = LocalContext.current + + // Trigger search automatically on launch if text is not empty + LaunchedEffect(Unit) { + if (!searchQuery.isBlank()) { + onSearchTextChanged(searchQuery, true) + } + } + Column( modifier = modifier.fillMaxWidth().background(MaterialTheme.colors.primary), ) { @@ -108,66 +144,70 @@ fun TopScreenSection( TITLE_ROW_TEST_TAG, ), verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, ) { - Icon( - when (toolBarHomeNavigation) { - ToolBarHomeNavigation.OPEN_DRAWER -> Icons.Filled.Menu - ToolBarHomeNavigation.NAVIGATE_BACK -> Icons.Filled.ArrowBack - }, - contentDescription = DRAWER_MENU, - tint = Color.White, - modifier = - modifier.clickable { onClick(ToolbarClickEvent.Navigate) }.testTag(TOP_ROW_ICON_TEST_TAG), - ) - Text( - text = title, - fontSize = 20.sp, - color = Color.White, - modifier = modifier.padding(start = 8.dp).weight(1f).testTag(TOP_ROW_TEXT_TEST_TAG), - ) - - // if menu icons are more than two then we will add a overflow menu for other menu icons - // to support m3 guidelines - // https://m3.material.io/components/top-app-bar/guidelines#b1b64842-7d88-4c3f-8ffb-4183fe648c9e - SetupToolbarIcons(topScreenSection?.menuIcons, navController, modifier, onClick) - - if (isFilterIconEnabled) { - BadgedBox( - modifier = Modifier.padding(end = 8.dp), - badge = { - if (filteredRecordsCount != null && filteredRecordsCount > -1) { - Badge { - Text( - text = if (filteredRecordsCount > 99) "99+" else filteredRecordsCount.toString(), - overflow = TextOverflow.Clip, - maxLines = 1, - ) - } - } + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + when (toolBarHomeNavigation) { + ToolBarHomeNavigation.OPEN_DRAWER -> Icons.Filled.Menu + ToolBarHomeNavigation.NAVIGATE_BACK -> Icons.AutoMirrored.Filled.ArrowBack }, - ) { - Icon( - imageVector = Icons.Default.FilterAlt, - contentDescription = FILTER, - tint = Color.White, - modifier = - modifier - .clickable { onClick(ToolbarClickEvent.FilterData) } - .testTag(TOP_ROW_FILTER_ICON_TEST_TAG), - ) - } + contentDescription = DRAWER_MENU, + tint = Color.White, + modifier = + modifier + .clickable { onClick(ToolbarClickEvent.Navigate) } + .testTag(TOP_ROW_ICON_TEST_TAG), + ) + Text( + text = title, + fontSize = 20.sp, + color = Color.White, + modifier = modifier.padding(start = 16.dp).testTag(TOP_ROW_TEXT_TEST_TAG), + ) + } + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(start = 8.dp), + ) { + SetupToolbarIcons( + menuIcons = topScreenSection?.menuIcons, + isFilterIconEnabled = isFilterIconEnabled, + filteredRecordsCount = filteredRecordsCount, + navController = navController, + modifier = modifier, + onClick = onClick, + decodeImage = decodeImage, + ) } } if (isSearchBarVisible) { OutlinedTextField( colors = TextFieldDefaults.outlinedTextFieldColors(textColor = Color.DarkGray), - value = searchText, - onValueChange = { onSearchTextChanged(it) }, + value = searchQuery.query, + onValueChange = { + onSearchTextChanged( + SearchQuery(it, mode = SearchMode.KeyboardInput), + performSearchOnValueChanged, + ) + }, + keyboardOptions = + KeyboardOptions(keyboardType = KeyboardType.Text, imeAction = ImeAction.Search), + keyboardActions = + KeyboardActions( + onSearch = { + onSearchTextChanged( + SearchQuery(searchQuery.query, mode = SearchMode.KeyboardInput), + true, + ) + }, + ), maxLines = 1, singleLine = true, placeholder = { Text( - color = GreyTextColor, + color = placeholderColor?.parseColor() ?: GreyTextColor, text = searchPlaceholder ?: stringResource(R.string.search_hint), modifier = modifier.testTag(SEARCH_FIELD_TEST_TAG), ) @@ -187,17 +227,52 @@ fun TopScreenSection( ) }, trailingIcon = { - if (searchText.isNotEmpty()) { - IconButton( - onClick = { onSearchTextChanged("") }, - modifier = modifier.testTag(TRAILING_ICON_BUTTON_TEST_TAG), - ) { - Icon( - imageVector = Icons.Filled.Clear, - CLEAR, - tint = Color.Gray, - modifier = modifier.testTag(TRAILING_ICON_TEST_TAG), - ) + Box(contentAlignment = Alignment.CenterEnd) { + when { + !searchQuery.isBlank() -> { + IconButton( + onClick = { onSearchTextChanged(SearchQuery.emptyText, true) }, + modifier = modifier.testTag(TRAILING_ICON_BUTTON_TEST_TAG), + ) { + Icon( + imageVector = Icons.Filled.Clear, + contentDescription = CLEAR, + tint = Color.Gray, + modifier = modifier.testTag(TRAILING_ICON_TEST_TAG), + ) + } + } + showSearchByQrCode -> { + IconButton( + onClick = { + currentContext.getActivity()?.let { + QrCodeScanUtils.scanQrCode(it) { code -> + onSearchTextChanged( + SearchQuery( + code ?: "", + mode = SearchMode.QrCodeScan, + ), + performSearchOnValueChanged, + ) + } + } + }, + modifier = + modifier.testTag( + TRAILING_QR_SCAN_ICON_BUTTON_TEST_TAG, + ), + ) { + Icon( + painter = + painterResource(id = org.smartregister.fhircore.quest.R.drawable.ic_qr_code), + contentDescription = + stringResource( + id = org.smartregister.fhircore.quest.R.string.qr_code, + ), + modifier = modifier.testTag(TRAILING_QR_SCAN_ICON_TEST_TAG), + ) + } + } } } }, @@ -209,51 +284,125 @@ fun TopScreenSection( @Composable fun SetupToolbarIcons( menuIcons: List?, + isFilterIconEnabled: Boolean, + filteredRecordsCount: Long? = null, navController: NavController, modifier: Modifier, onClick: (ToolbarClickEvent) -> Unit, + decodeImage: ((String) -> Bitmap?)?, ) { - if (menuIcons?.isNotEmpty() == true && menuIcons.size > 2) { - var menuExpanded by remember { mutableStateOf(false) } - Row { - RenderMenuIcons( - menuIcons = menuIcons.take(2), - navController = navController, - modifier = modifier, - onClick = onClick, - ) - // FIXME - Do not use material 3 library for now. We have to use dropdown menu to render the - // other menu icons - } - } else { - menuIcons?.let { - RenderMenuIcons( - menuIcons = it, + var showOverflowMenu by remember { mutableStateOf(false) } + if (!menuIcons.isNullOrEmpty()) { + val iconsCount = remember { if (isFilterIconEnabled) 1 else 2 } + if (menuIcons.size <= iconsCount) { + RenderMenuIcon( + menuIcons = menuIcons.subList(0, min(iconsCount, menuIcons.size)), + isFilterIconEnabled = isFilterIconEnabled, + filteredRecordsCount = filteredRecordsCount, navController = navController, modifier = modifier, onClick = onClick, + decodeImage = decodeImage, ) + } else { + Row(verticalAlignment = Alignment.CenterVertically) { + RenderMenuIcon( + menuIcons = menuIcons.subList(0, iconsCount), + isFilterIconEnabled = false, + filteredRecordsCount = null, + navController = navController, + modifier = modifier, + onClick = onClick, + decodeImage = decodeImage, + ) + Icon( + imageVector = Icons.Outlined.MoreVert, + contentDescription = null, + tint = Color.White, + modifier = + Modifier.padding(start = 8.dp).size(22.dp).clickable { + showOverflowMenu = !showOverflowMenu + }, + ) + DropdownMenu( + expanded = showOverflowMenu, + onDismissRequest = { showOverflowMenu = false }, + ) { + menuIcons.subList(iconsCount, menuIcons.size).forEach { + DropdownMenuItem( + onClick = { + onClick(ToolbarClickEvent.Actions(it.actions)) + showOverflowMenu = !showOverflowMenu + }, + ) { + Image( + imageProperties = it, + navController = navController, + tint = it.tint?.parseColor() ?: DefaultColor, + modifier = + modifier + .clickable { onClick(ToolbarClickEvent.Actions(it.actions)) } + .testTag(TOP_ROW_TOGGLE_ICON_TEST_tAG), + decodeImage = decodeImage, + ) + } + } + } + } } } } @Composable -fun RenderMenuIcons( +private fun RenderMenuIcon( menuIcons: List, + isFilterIconEnabled: Boolean, + filteredRecordsCount: Long? = null, navController: NavController, modifier: Modifier, onClick: (ToolbarClickEvent) -> Unit, + decodeImage: ((String) -> Bitmap?)?, ) { - LazyRow(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + LazyRow(horizontalArrangement = Arrangement.spacedBy(14.dp)) { + item { + if (isFilterIconEnabled) { + BadgedBox( + modifier = Modifier.padding(end = 8.dp), + badge = { + if (filteredRecordsCount != null && filteredRecordsCount > -1) { + Badge { + Text( + text = if (filteredRecordsCount > 99) "99+" else filteredRecordsCount.toString(), + overflow = TextOverflow.Clip, + maxLines = 1, + ) + } + } + }, + ) { + Icon( + imageVector = Icons.Outlined.FilterAlt, + contentDescription = FILTER, + tint = Color.White, + modifier = + modifier + .size(22.dp) + .clickable { onClick(ToolbarClickEvent.FilterData) } + .testTag(TOP_ROW_FILTER_ICON_TEST_TAG), + ) + } + } + } items(menuIcons) { Image( - imageProperties = ImageProperties(imageConfig = it.imageConfig), + imageProperties = it, navController = navController, tint = Color.White, modifier = modifier .clickable { onClick(ToolbarClickEvent.Actions(it.actions)) } .testTag(TOP_ROW_TOGGLE_ICON_TEST_tAG), + decodeImage = decodeImage, ) } } @@ -264,14 +413,27 @@ fun RenderMenuIcons( fun TopScreenSectionWithFilterItemOverNinetyNinePreview() { TopScreenSection( title = "All Clients", - searchText = "Eddy", + searchQuery = SearchQuery("Eddy"), filteredRecordsCount = 120, - onSearchTextChanged = {}, + onSearchTextChanged = { _, _ -> }, toolBarHomeNavigation = ToolBarHomeNavigation.NAVIGATE_BACK, isFilterIconEnabled = true, onClick = {}, isSearchBarVisible = true, + topScreenSection = + TopScreenSectionConfig( + searchBar = null, + menuIcons = + listOf( + ImageProperties( + imageConfig = ImageConfig(ICON_TYPE_LOCAL, "ic_toggle_map_view"), + backgroundColor = "#FFFFFF", + size = 10, + ), + ), + ), navController = rememberNavController(), + decodeImage = null, ) } @@ -280,14 +442,15 @@ fun TopScreenSectionWithFilterItemOverNinetyNinePreview() { fun TopScreenSectionWithFilterCountNinetyNinePreview() { TopScreenSection( title = "All Clients", - searchText = "Eddy", + searchQuery = SearchQuery("Eddy"), filteredRecordsCount = 99, - onSearchTextChanged = {}, + onSearchTextChanged = { _, _ -> }, toolBarHomeNavigation = ToolBarHomeNavigation.NAVIGATE_BACK, isFilterIconEnabled = true, onClick = {}, isSearchBarVisible = true, navController = rememberNavController(), + decodeImage = null, ) } @@ -296,8 +459,8 @@ fun TopScreenSectionWithFilterCountNinetyNinePreview() { fun TopScreenSectionNoFilterIconPreview() { TopScreenSection( title = "All Clients", - searchText = "Eddy", - onSearchTextChanged = {}, + searchQuery = SearchQuery("Eddy"), + onSearchTextChanged = { _, _ -> }, toolBarHomeNavigation = ToolBarHomeNavigation.NAVIGATE_BACK, isFilterIconEnabled = false, onClick = {}, @@ -308,10 +471,11 @@ fun TopScreenSectionNoFilterIconPreview() { searchBar = null, title = "Service Point", menuIcons = - arrayListOf( + listOf( ImageProperties(imageConfig = ImageConfig(reference = "ic_service_points")), ), ), + decodeImage = null, ) } @@ -320,9 +484,9 @@ fun TopScreenSectionNoFilterIconPreview() { fun TopScreenSectionWithFilterIconAndToggleIconPreview() { TopScreenSection( title = "All Clients", - searchText = "Eddy", + searchQuery = SearchQuery("Eddy"), filteredRecordsCount = 120, - onSearchTextChanged = {}, + onSearchTextChanged = { _, _ -> }, toolBarHomeNavigation = ToolBarHomeNavigation.NAVIGATE_BACK, isFilterIconEnabled = true, onClick = {}, @@ -333,25 +497,27 @@ fun TopScreenSectionWithFilterIconAndToggleIconPreview() { searchBar = null, title = "Service Point", menuIcons = - arrayListOf( + listOf( ImageProperties(imageConfig = ImageConfig(reference = "ic_service_points")), ), ), + decodeImage = null, ) } @PreviewWithBackgroundExcludeGenerated @Composable -fun TopScreenSectionWithToggleIconPreview() { +fun TopScreenSectionWithOpenDrawerIconPreview() { TopScreenSection( title = "All Clients", - searchText = "Eddy", + searchQuery = SearchQuery("Eddy"), filteredRecordsCount = 120, - onSearchTextChanged = {}, - toolBarHomeNavigation = ToolBarHomeNavigation.NAVIGATE_BACK, + onSearchTextChanged = { _, _ -> }, + toolBarHomeNavigation = ToolBarHomeNavigation.OPEN_DRAWER, isFilterIconEnabled = false, onClick = {}, isSearchBarVisible = true, navController = rememberNavController(), + decodeImage = null, ) } diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/multiselect/MultiSelectBottomSheetFragment.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/multiselect/MultiSelectBottomSheetFragment.kt index 066f74acb71..7afb0d5a4f9 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/multiselect/MultiSelectBottomSheetFragment.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/multiselect/MultiSelectBottomSheetFragment.kt @@ -20,9 +20,12 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import android.widget.Toast +import androidx.compose.foundation.layout.Box +import androidx.compose.material.Text import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.ui.Alignment import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.res.stringResource import androidx.fragment.app.activityViewModels import androidx.fragment.app.viewModels import androidx.lifecycle.lifecycleScope @@ -31,45 +34,51 @@ import com.google.android.material.bottomsheet.BottomSheetDialogFragment import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject import kotlinx.coroutines.launch +import org.smartregister.fhircore.engine.domain.model.MultiSelectViewAction import org.smartregister.fhircore.engine.ui.theme.AppTheme -import org.smartregister.fhircore.engine.util.extension.isDeviceOnline -import org.smartregister.fhircore.engine.util.extension.showToast +import org.smartregister.fhircore.engine.util.DefaultDispatcherProvider +import org.smartregister.fhircore.quest.R import org.smartregister.fhircore.quest.event.AppEvent import org.smartregister.fhircore.quest.event.EventBus +import org.smartregister.fhircore.quest.ui.main.AppMainEvent import org.smartregister.fhircore.quest.ui.main.AppMainViewModel @AndroidEntryPoint -class MultiSelectBottomSheetFragment() : BottomSheetDialogFragment() { +class MultiSelectBottomSheetFragment : BottomSheetDialogFragment() { + + @Inject lateinit var dispatcherProvider: DefaultDispatcherProvider @Inject lateinit var eventBus: EventBus - val bottomSheetArgs by navArgs() - val multiSelectViewModel by viewModels() + private val bottomSheetArgs by navArgs() + private val multiSelectViewModel by viewModels() private val appMainViewModel by activityViewModels() override fun onViewCreated(view: View, savedInstanceState: Bundle?) { isCancelable = false - val multiSelectViewConfig = bottomSheetArgs?.multiSelectViewConfig + val multiSelectViewConfig = bottomSheetArgs.multiSelectViewConfig if (multiSelectViewConfig != null) { multiSelectViewModel.populateLookupMap(requireContext(), multiSelectViewConfig) } } - private fun onSelectionDone() { + private fun onSelectionDone(viewActions: List) { + val context = requireContext() lifecycleScope.launch { - multiSelectViewModel.saveSelectedLocations(requireContext()) - appMainViewModel.run { - if (requireContext().isDeviceOnline()) { - triggerSync() - } else { - requireContext() - .showToast( - getString(org.smartregister.fhircore.engine.R.string.sync_failed), - Toast.LENGTH_LONG, - ) - eventBus.triggerEvent(AppEvent.RefreshRegisterData) + multiSelectViewModel.saveSelectedLocations(context, viewActions) { + viewActions.distinct().forEach { viewAction -> + when (viewAction) { + MultiSelectViewAction.SYNC_DATA -> + appMainViewModel.onEvent(AppMainEvent.SyncData(context)) + MultiSelectViewAction.FILTER_DATA -> + lifecycleScope.launch { + eventBus.triggerEvent( + AppEvent.RefreshData, + ) + } + } } + dismiss() } - dismiss() } } @@ -81,17 +90,26 @@ class MultiSelectBottomSheetFragment() : BottomSheetDialogFragment() { return ComposeView(requireContext()).apply { setContent { AppTheme { - MultiSelectBottomSheetView( - rootTreeNodes = multiSelectViewModel.rootTreeNodes, - selectedNodes = multiSelectViewModel.selectedNodes, - title = bottomSheetArgs.screenTitle, - onDismiss = { dismiss() }, - searchTextState = multiSelectViewModel.searchTextState, - onSearchTextChanged = multiSelectViewModel::onTextChanged, - onSelectionDone = ::onSelectionDone, - search = multiSelectViewModel::search, - isLoading = multiSelectViewModel.flag.observeAsState(), - ) + val multiSelectViewConfig = bottomSheetArgs.multiSelectViewConfig + if (multiSelectViewConfig != null) { + MultiSelectBottomSheetView( + rootTreeNodes = multiSelectViewModel.rootTreeNodes, + syncLocationStateMap = multiSelectViewModel.selectedNodes, + title = bottomSheetArgs.screenTitle, + onDismiss = { dismiss() }, + searchTextState = multiSelectViewModel.searchTextState, + onSearchTextChanged = multiSelectViewModel::onTextChanged, + onSelectionDone = ::onSelectionDone, + search = multiSelectViewModel::search, + isLoading = multiSelectViewModel.isLoading.observeAsState(), + multiSelectViewAction = multiSelectViewConfig.viewActions, + mutuallyExclusive = multiSelectViewConfig.mutuallyExclusive, + ) + } else { + Box(contentAlignment = Alignment.Center) { + Text(text = stringResource(R.string.missing_multi_select_view_configs)) + } + } } } } diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/multiselect/MultiSelectBottomSheetView.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/multiselect/MultiSelectBottomSheetView.kt index f2cafb92468..4c8a451baac 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/multiselect/MultiSelectBottomSheetView.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/multiselect/MultiSelectBottomSheetView.kt @@ -47,7 +47,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState import androidx.compose.runtime.State import androidx.compose.runtime.snapshots.SnapshotStateList -import androidx.compose.runtime.snapshots.SnapshotStateMap import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -62,21 +61,27 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import org.smartregister.fhircore.engine.R +import org.smartregister.fhircore.engine.domain.model.MultiSelectViewAction +import org.smartregister.fhircore.engine.domain.model.SyncLocationState import org.smartregister.fhircore.engine.ui.multiselect.MultiSelectView import org.smartregister.fhircore.engine.ui.multiselect.TreeNode +import org.smartregister.fhircore.engine.ui.multiselect.updateNestedCheckboxState import org.smartregister.fhircore.engine.ui.theme.DividerColor +import org.smartregister.fhircore.engine.util.extension.isIn @Composable fun MultiSelectBottomSheetView( rootTreeNodes: SnapshotStateList>, - selectedNodes: SnapshotStateMap, + syncLocationStateMap: MutableMap, title: String?, onDismiss: () -> Unit, searchTextState: MutableState, onSearchTextChanged: (String) -> Unit, - onSelectionDone: () -> Unit, + onSelectionDone: (List) -> Unit, search: () -> Unit, isLoading: State, + multiSelectViewAction: List, + mutuallyExclusive: Boolean, ) { val keyboardController = LocalSoftwareKeyboardController.current Scaffold( @@ -172,24 +177,62 @@ fun MultiSelectBottomSheetView( LazyColumn( modifier = Modifier.padding(horizontal = 8.dp), ) { - items(rootTreeNodes, key = { item -> item.id }) { + items(rootTreeNodes, key = { item -> item.id }) { rootTreeNode -> Column { MultiSelectView( - rootTreeNode = it, - selectedNodes = selectedNodes, + rootTreeNode = rootTreeNode, + syncLocationStateMap = syncLocationStateMap, + onChecked = { + if (mutuallyExclusive) { + rootTreeNodes.forEach { currentNode -> + val currentNodeToggleableState = + syncLocationStateMap[currentNode.id]?.toggleableState + if ( + currentNode.id != rootTreeNode.id && + currentNodeToggleableState != null && + currentNodeToggleableState.isIn( + ToggleableState.On, + ToggleableState.Indeterminate, + ) + ) { + // De-select the root and its children + syncLocationStateMap[currentNode.id] = + SyncLocationState( + locationId = currentNode.id, + parentLocationId = currentNode.parent?.id, + toggleableState = ToggleableState.Off, + ) + + updateNestedCheckboxState( + currentTreeNode = currentNode, + syncLocationStateMap = syncLocationStateMap, + checked = false, + ) + } + } + } + }, ) { treeNode -> Column { Text(text = treeNode.data) } } } } item { - if (selectedNodes.isNotEmpty() && rootTreeNodes.isNotEmpty()) { + if (syncLocationStateMap.isNotEmpty() && rootTreeNodes.isNotEmpty()) { Button( - onClick = { onSelectionDone() }, + onClick = { onSelectionDone(multiSelectViewAction) }, modifier = Modifier.fillMaxWidth().padding(vertical = 16.dp, horizontal = 8.dp), ) { Text( - text = stringResource(id = R.string.sync_data).uppercase(), + text = + stringResource( + id = + when (multiSelectViewAction.first()) { + MultiSelectViewAction.SYNC_DATA -> R.string.sync_data + MultiSelectViewAction.FILTER_DATA -> R.string.apply_filter + }, + ) + .uppercase(), modifier = Modifier.padding(8.dp), ) } diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/multiselect/MultiSelectViewModel.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/multiselect/MultiSelectViewModel.kt index cec786c9019..c7e7bb1b3ba 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/multiselect/MultiSelectViewModel.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/multiselect/MultiSelectViewModel.kt @@ -21,24 +21,26 @@ import androidx.compose.runtime.MutableState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.snapshots.SnapshotStateList import androidx.compose.runtime.snapshots.SnapshotStateMap -import androidx.compose.ui.state.ToggleableState import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.google.android.fhir.datacapture.extensions.logicalId import dagger.hilt.android.lifecycle.HiltViewModel -import java.util.LinkedList +import java.io.IOException import javax.inject.Inject -import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.launch import org.smartregister.fhircore.engine.data.local.DefaultRepository +import org.smartregister.fhircore.engine.datastore.dataFilterLocationIdsProtoStore import org.smartregister.fhircore.engine.datastore.syncLocationIdsProtoStore +import org.smartregister.fhircore.engine.domain.model.MultiSelectViewAction import org.smartregister.fhircore.engine.domain.model.MultiSelectViewConfig -import org.smartregister.fhircore.engine.domain.model.SyncLocationToggleableState +import org.smartregister.fhircore.engine.domain.model.SyncLocationState import org.smartregister.fhircore.engine.ui.multiselect.TreeBuilder import org.smartregister.fhircore.engine.ui.multiselect.TreeNode import org.smartregister.fhircore.engine.util.extension.extractLogicalIdUuid +import org.smartregister.fhircore.engine.util.extension.retrieveRelatedEntitySyncLocationState import org.smartregister.fhircore.engine.util.fhirpath.FhirPathDataExtractor +import timber.log.Timber @HiltViewModel class MultiSelectViewModel @@ -50,29 +52,40 @@ constructor( val searchTextState: MutableState = mutableStateOf("") val rootTreeNodes: SnapshotStateList> = SnapshotStateList() - val selectedNodes: SnapshotStateMap = SnapshotStateMap() - val flag = MutableLiveData(false) + val selectedNodes: SnapshotStateMap = SnapshotStateMap() + val isLoading = MutableLiveData(false) private var _rootTreeNodes: List> = mutableListOf() fun populateLookupMap(context: Context, multiSelectViewConfig: MultiSelectViewConfig) { - // Mark previously selected nodes viewModelScope.launch { - flag.postValue(true) - val previouslySelectedNodes = context.syncLocationIdsProtoStore.data.firstOrNull() - if (!previouslySelectedNodes.isNullOrEmpty()) { - previouslySelectedNodes.forEach { selectedNodes[it.locationId] = it.toggleableState } + isLoading.postValue(true) + // Populate previously selected nodes for every Multi-Select view action + multiSelectViewConfig.viewActions.forEach { + val previouslySelectedNodes = + context.retrieveRelatedEntitySyncLocationState( + multiSelectViewAction = it, + filterToggleableStateOn = false, + ) + previouslySelectedNodes.forEach { syncLocationState -> + selectedNodes[syncLocationState.locationId] = syncLocationState + } } + val repositoryResourceData = + defaultRepository.searchResourcesRecursively( + filterByRelatedEntityLocationMetaTag = false, + fhirResourceConfig = multiSelectViewConfig.resourceConfig, + filterActiveResources = null, + secondaryResourceConfigs = null, + configRules = null, + ) + val resourcesMap = - defaultRepository - .searchResourcesRecursively( - filterByRelatedEntityLocationMetaTag = false, - fhirResourceConfig = multiSelectViewConfig.resourceConfig, - filterActiveResources = null, - secondaryResourceConfigs = null, - configRules = null, - ) - .associateByTo(mutableMapOf(), { it.resource.logicalId }, { it.resource }) + repositoryResourceData.associateByTo( + mutableMapOf(), + { it.resource.logicalId }, + { it.resource }, + ) val rootNodeIds = mutableSetOf() val lookupItems: List> = @@ -103,7 +116,6 @@ constructor( } val parentResource = resourcesMap[parentId] - TreeNode( id = resource.logicalId, parent = @@ -125,7 +137,7 @@ constructor( data = data, ) } - flag.postValue(false) + isLoading.postValue(false) _rootTreeNodes = TreeBuilder.buildTrees(lookupItems, rootNodeIds) rootTreeNodes.addAll(_rootTreeNodes) } @@ -141,9 +153,24 @@ constructor( } } - suspend fun saveSelectedLocations(context: Context) { - context.syncLocationIdsProtoStore.updateData { - selectedNodes.map { SyncLocationToggleableState(it.key, it.value) } + suspend fun saveSelectedLocations( + context: Context, + viewActions: List, + onSaveDone: () -> Unit, + ) { + try { + viewActions.forEach { + when (it) { + MultiSelectViewAction.SYNC_DATA -> + context.syncLocationIdsProtoStore.updateData { selectedNodes } + MultiSelectViewAction.FILTER_DATA -> + context.dataFilterLocationIdsProtoStore.updateData { selectedNodes } + } + } + + onSaveDone() + } catch (ioException: IOException) { + Timber.e("Error saving selected locations", ioException) } } @@ -160,9 +187,9 @@ constructor( rootTreeNodeMap[rootTreeNode.id] = rootTreeNode return@forEach } - val childrenList = LinkedList(rootTreeNode.children) - while (childrenList.isNotEmpty()) { - val currentNode = childrenList.removeFirst() + val treeNodeArrayDeque = ArrayDeque(rootTreeNode.children) + while (treeNodeArrayDeque.isNotEmpty()) { + val currentNode = treeNodeArrayDeque.removeFirst() if (currentNode.data.contains(other = searchTerm, ignoreCase = true)) { when { rootTreeNodeMap.containsKey(rootTreeNode.id) -> return@forEach @@ -172,7 +199,7 @@ constructor( } } } - currentNode.children.forEach { childrenList.add(it) } + currentNode.children.forEach { treeNodeArrayDeque.addLast(it) } } } rootTreeNodes.addAll(rootTreeNodeMap.values) diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/pdf/PdfGenerator.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/pdf/PdfGenerator.kt similarity index 59% rename from android/engine/src/main/java/org/smartregister/fhircore/engine/pdf/PdfGenerator.kt rename to android/quest/src/main/java/org/smartregister/fhircore/quest/ui/pdf/PdfGenerator.kt index 8d897558f2c..5427aead727 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/pdf/PdfGenerator.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/pdf/PdfGenerator.kt @@ -14,23 +14,31 @@ * limitations under the License. */ -package org.smartregister.fhircore.engine.pdf +package org.smartregister.fhircore.quest.ui.pdf import android.content.Context import android.print.PrintAttributes import android.print.PrintManager +import android.webkit.WebResourceRequest import android.webkit.WebView +import android.webkit.WebViewClient +import org.jetbrains.annotations.VisibleForTesting /** * PdfGenerator creates PDF files from HTML content using Android's WebView and PrintManager. Must * be initialized on the Main thread. * * @param context Application context for initializing WebView and PrintManager. - * @param webView Optional WebView for testing purposes. + * @param webView WebView instance for loading HTML content (Visible for testing). */ -class PdfGenerator(context: Context, private val webView: WebView = WebView(context)) { +class PdfGenerator( + private val context: Context, + @VisibleForTesting private val webView: WebView = WebView(context), +) { - private val printManager = context.getSystemService(Context.PRINT_SERVICE) as PrintManager + private var mWebView: WebView? = null + private val printManager: PrintManager = + context.getSystemService(Context.PRINT_SERVICE) as PrintManager /** * Generates a PDF file from the provided HTML content. @@ -47,10 +55,26 @@ class PdfGenerator(context: Context, private val webView: WebView = WebView(cont * * @param html The HTML content to be converted into a PDF. * @param pdfTitle The title of the PDF document. + * @param onPdfPrinted Callback to be invoked when the PDF is printed. */ - fun generatePdfWithHtml(html: String, pdfTitle: String) { + fun generatePdfWithHtml(html: String, pdfTitle: String, onPdfPrinted: () -> Unit) { + webView.webViewClient = + object : WebViewClient() { + + override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest) = false + + override fun onPageFinished(view: WebView, url: String) { + printPdf(view, pdfTitle) + mWebView = null + onPdfPrinted.invoke() + } + } webView.loadDataWithBaseURL(null, html, "text/HTML", "UTF-8", null) - val printAdapter = webView.createPrintDocumentAdapter(pdfTitle) + mWebView = webView + } + + private fun printPdf(view: WebView, pdfTitle: String) { + val printAdapter = view.createPrintDocumentAdapter(pdfTitle) printManager.print( pdfTitle, printAdapter, diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/pdf/PdfLauncherFragment.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/pdf/PdfLauncherFragment.kt new file mode 100644 index 00000000000..6d9d0376b59 --- /dev/null +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/pdf/PdfLauncherFragment.kt @@ -0,0 +1,137 @@ +/* + * Copyright 2021-2024 Ona Systems, Inc + * + * 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 org.smartregister.fhircore.quest.ui.pdf + +import android.os.Bundle +import androidx.appcompat.app.AppCompatActivity +import androidx.core.os.bundleOf +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.viewModels +import androidx.lifecycle.lifecycleScope +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.hl7.fhir.r4.model.Binary +import org.hl7.fhir.r4.model.QuestionnaireResponse +import org.jetbrains.annotations.VisibleForTesting +import org.smartregister.fhircore.engine.R +import org.smartregister.fhircore.engine.configuration.PdfConfig +import org.smartregister.fhircore.engine.pdf.HtmlPopulator +import org.smartregister.fhircore.engine.util.extension.decodeJson +import org.smartregister.fhircore.engine.util.extension.extractLogicalIdUuid + +/** + * A fragment for generating and displaying a PDF based on a questionnaire response. + * + * This fragment uses the provided [PdfConfig] to retrieve a questionnaire response, populate an + * HTML template with the response data, and generate a PDF. + */ +@AndroidEntryPoint +class PdfLauncherFragment : DialogFragment() { + + private val pdfLauncherViewModel by viewModels() + + @VisibleForTesting lateinit var pdfGenerator: PdfGenerator + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + if (!this::pdfGenerator.isInitialized) pdfGenerator = PdfGenerator(requireContext()) + + val pdfConfig = getPdfConfig() + + val structureId = pdfConfig.structureReference!!.extractLogicalIdUuid() + val title = StringBuilder().append(pdfConfig.title ?: getString(R.string.default_html_title)) + val titleSuffix = pdfConfig.titleSuffix + val subjectReference = pdfConfig.subjectReference!! + val questionnaireIds = + pdfConfig.questionnaireReferences.map { it.extractLogicalIdUuid() } ?: emptyList() + + lifecycleScope.launch(Dispatchers.IO) { + val questionnaireResponses = + questionnaireIds.mapNotNull { questionnaireId -> + pdfLauncherViewModel.retrieveQuestionnaireResponse( + questionnaireId, + subjectReference, + ) + } + val htmlBinary = pdfLauncherViewModel.retrieveBinary(structureId) + + if (titleSuffix != null) title.append(" - $titleSuffix") + + generatePdf(questionnaireResponses, htmlBinary, title.toString()) + } + } + + /** + * Retrieves and decodes the questionnaire configuration from the fragment arguments. + * + * @return the decoded [PdfConfig] object. + * @throws IllegalArgumentException if the questionnaire config is not found in arguments. + */ + private fun getPdfConfig(): PdfConfig { + val jsonConfig = + requireArguments().getString(EXTRA_PDF_CONFIG_KEY) + ?: throw IllegalArgumentException("Questionnaire config not found in arguments") + return jsonConfig.decodeJson() + } + + /** + * Generates a PDF using the provided questionnaire response and HTML template. + * + * @param questionnaireResponses containing user responses. + * @param htmlBinary the [Binary] object containing the HTML template. + * @param htmlTitle the title to be used for the generated PDF. + */ + private suspend fun generatePdf( + questionnaireResponses: List, + htmlBinary: Binary?, + htmlTitle: String, + ) { + if (questionnaireResponses.isEmpty() || htmlBinary == null) { + dismiss() + return + } + + val htmlContent = htmlBinary.content.decodeToString() + val populatedHtml = HtmlPopulator(questionnaireResponses).populateHtml(htmlContent) + + withContext(Dispatchers.Main) { + pdfGenerator.generatePdfWithHtml(populatedHtml, htmlTitle) { dismiss() } + } + } + + companion object { + + /** + * Launches the PdfLauncherFragment. + * + * This method creates a new instance of PdfLauncherFragment, sets the provided questionnaire + * configuration JSON as an argument, and displays the fragment. + * + * @param appCompatActivity The activity from which the fragment is launched. + * @param questionnaireConfigJson The JSON string representing the questionnaire configuration. + */ + fun launch(appCompatActivity: AppCompatActivity, questionnaireConfigJson: String) { + PdfLauncherFragment() + .apply { arguments = bundleOf(EXTRA_PDF_CONFIG_KEY to questionnaireConfigJson) } + .show(appCompatActivity.supportFragmentManager, PdfLauncherFragment::class.java.simpleName) + } + + @VisibleForTesting const val EXTRA_PDF_CONFIG_KEY = "pdf_config" + } +} diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/pdf/PdfLauncherViewModel.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/pdf/PdfLauncherViewModel.kt new file mode 100644 index 00000000000..5c922807b6a --- /dev/null +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/pdf/PdfLauncherViewModel.kt @@ -0,0 +1,89 @@ +/* + * Copyright 2021-2024 Ona Systems, Inc + * + * 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 org.smartregister.fhircore.quest.ui.pdf + +import androidx.lifecycle.ViewModel +import com.google.android.fhir.search.Search +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import org.hl7.fhir.r4.model.Binary +import org.hl7.fhir.r4.model.QuestionnaireResponse +import org.hl7.fhir.r4.model.ResourceType +import org.smartregister.fhircore.engine.data.local.DefaultRepository + +/** + * ViewModel for managing PDF generation related operations. + * + * This ViewModel provides methods for retrieving [QuestionnaireResponse] and [Binary] resources + * required for generating PDFs. + * + * @param defaultRepository The repository for accessing local data. + */ +@HiltViewModel +class PdfLauncherViewModel +@Inject +constructor( + val defaultRepository: DefaultRepository, +) : ViewModel() { + + /** + * Retrieve the [QuestionnaireResponse] for the given questionnaire and subject. + * + * @param questionnaireId The ID of the questionnaire. + * @param subjectReference The reference of the subject e.g. Patient/123. + * @return The [QuestionnaireResponse] if found, otherwise null. + */ + suspend fun retrieveQuestionnaireResponse( + questionnaireId: String, + subjectReference: String, + ): QuestionnaireResponse? { + val searchQuery = createQuestionnaireResponseSearchQuery(questionnaireId, subjectReference) + return defaultRepository.search(searchQuery).maxByOrNull { + it.meta.lastUpdated + } + } + + /** + * Create a search query for [QuestionnaireResponse]. + * + * @param questionnaireId The ID of the questionnaire. + * @param subjectReference The reference of the subject e.g. Patient/123. + * @return The search query for [QuestionnaireResponse]. + */ + private fun createQuestionnaireResponseSearchQuery( + questionnaireId: String, + subjectReference: String, + ): Search { + return Search(ResourceType.QuestionnaireResponse).apply { + filter(QuestionnaireResponse.SUBJECT, { value = subjectReference }) + filter( + QuestionnaireResponse.QUESTIONNAIRE, + { value = "${ResourceType.Questionnaire}/$questionnaireId" }, + ) + } + } + + /** + * Retrieve the [Binary] resource for the given binary ID. + * + * @param binaryId The ID of the binary resource. + * @return The [Binary] resource if found, otherwise null. + */ + suspend fun retrieveBinary(binaryId: String): Binary? { + return defaultRepository.loadResource(binaryId) + } +} diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/pin/PinLoginActivity.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/pin/PinLoginActivity.kt index 332142ee7ef..a8856bc61d0 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/pin/PinLoginActivity.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/pin/PinLoginActivity.kt @@ -22,12 +22,16 @@ import android.os.Bundle import androidx.activity.compose.setContent import androidx.activity.viewModels import androidx.compose.material.ExperimentalMaterialApi +import androidx.lifecycle.lifecycleScope import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import org.smartregister.fhircore.engine.p2p.dao.P2PReceiverTransferDao import org.smartregister.fhircore.engine.p2p.dao.P2PSenderTransferDao import org.smartregister.fhircore.engine.ui.base.BaseMultiLanguageActivity import org.smartregister.fhircore.engine.ui.theme.AppTheme +import org.smartregister.fhircore.engine.util.DispatcherProvider import org.smartregister.fhircore.engine.util.SecureSharedPreference import org.smartregister.fhircore.engine.util.extension.applyWindowInsetListener import org.smartregister.fhircore.engine.util.extension.launchActivityWithNoBackStackHistory @@ -44,6 +48,9 @@ class PinLoginActivity : BaseMultiLanguageActivity() { @Inject lateinit var p2pSenderTransferDao: P2PSenderTransferDao @Inject lateinit var p2pReceiverTransferDao: P2PReceiverTransferDao + + @Inject lateinit var dispatcherProvider: DispatcherProvider + val pinViewModel by viewModels() override fun onCreate(savedInstanceState: Bundle?) { @@ -61,7 +68,11 @@ class PinLoginActivity : BaseMultiLanguageActivity() { if (it) pinLoginActivity.navigateToHome() finish() } - launchDialPad.observe(pinLoginActivity) { if (!it.isNullOrEmpty()) launchDialPad(it) } + launchDialPad.observe(pinLoginActivity) { phone -> + if (!phone.isNullOrBlank()) { + startActivity(Intent(Intent.ACTION_DIAL).apply { data = Uri.parse("tel:$phone") }) + } + } navigateToLogin.observe(pinLoginActivity) { if (it) pinLoginActivity.launchActivityWithNoBackStackHistory() finish() @@ -73,24 +84,24 @@ class PinLoginActivity : BaseMultiLanguageActivity() { @OptIn(ExperimentalMaterialApi::class) private fun navigateToHome() { startActivity(Intent(this, AppMainActivity::class.java)) - // Initialize P2P only when username is provided then launch main activity - val username = secureSharedPreference.retrieveSessionUsername() - if (!username.isNullOrEmpty()) { - P2PLibrary.init( - P2PLibrary.Options( - context = applicationContext, - dbPassphrase = username, - username = username, - senderTransferDao = p2pSenderTransferDao, - receiverTransferDao = p2pReceiverTransferDao, - ), - ) - } - finish() - } - private fun launchDialPad(phone: String) { - startActivity(Intent(Intent.ACTION_DIAL).apply { data = Uri.parse(phone) }) + lifecycleScope.launch { + // Initialize P2P only when username is provided then launch main activity + val username = secureSharedPreference.retrieveSessionUsername() + if (!username.isNullOrEmpty()) { + withContext(dispatcherProvider.main()) { + P2PLibrary.init( + P2PLibrary.Options( + context = applicationContext, + dbPassphrase = username, + username = username, + senderTransferDao = p2pSenderTransferDao, + receiverTransferDao = p2pReceiverTransferDao, + ), + ) + } + } + } } companion object { diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/pin/PinLoginScreen.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/pin/PinLoginScreen.kt index 6a645863bf7..3fb69929919 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/pin/PinLoginScreen.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/pin/PinLoginScreen.kt @@ -16,6 +16,7 @@ package org.smartregister.fhircore.quest.ui.pin +import android.content.Context import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.Image import androidx.compose.foundation.clickable @@ -56,6 +57,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -64,6 +66,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import org.smartregister.fhircore.engine.R +import org.smartregister.fhircore.engine.configuration.app.ApplicationConfiguration import org.smartregister.fhircore.engine.ui.components.CircularProgressBar import org.smartregister.fhircore.engine.ui.components.PinInput import org.smartregister.fhircore.engine.ui.theme.DangerColor @@ -71,13 +74,18 @@ import org.smartregister.fhircore.engine.util.annotation.PreviewWithBackgroundEx const val CIRCULAR_PROGRESS_INDICATOR = "progress_indicator" const val PIN_LOGO_IMAGE = "pin_logo_image" +const val FORGOT_PIN_TEST_TAG = "FORGOT_PIN_TEXT" @Composable fun PinLoginScreen(viewModel: PinViewModel) { val showError by viewModel.showError.observeAsState(initial = false) + val showProgressBar by viewModel.showProgressBar.observeAsState(initial = false) val pinUiState = viewModel.pinUiState.value + val applicationConfiguration = remember { viewModel.applicationConfiguration } PinLoginPage( + applicationConfiguration = applicationConfiguration, + showProgressBar = showProgressBar, showError = showError, pinUiState = pinUiState, onMenuLoginClicked = viewModel::onMenuItemClicked, @@ -91,20 +99,21 @@ fun PinLoginScreen(viewModel: PinViewModel) { @OptIn(ExperimentalFoundationApi::class) @Composable fun PinLoginPage( + applicationConfiguration: ApplicationConfiguration, modifier: Modifier = Modifier, + showProgressBar: Boolean, showError: Boolean, pinUiState: PinUiState, onSetPin: (CharArray) -> Unit, onMenuLoginClicked: (Boolean) -> Unit, onShowPinError: (Boolean) -> Unit, - forgotPin: () -> Unit, + forgotPin: (Context) -> Unit, onPinEntered: (CharArray, (Boolean) -> Unit) -> Unit, ) { var showMenu by remember { mutableStateOf(false) } var showForgotPinDialog by remember { mutableStateOf(false) } var newPin by remember { mutableStateOf(charArrayOf()) } val bringIntoViewRequester = remember { BringIntoViewRequester() } - LaunchedEffect(Unit) { bringIntoViewRequester.bringIntoView() } Scaffold( @@ -120,8 +129,15 @@ fun PinLoginPage( }, ) { innerPadding -> Box(modifier = modifier.padding(innerPadding)) { - if (showForgotPinDialog) { - ForgotPinDialog(forgotPin = forgotPin, onDismissDialog = { showForgotPinDialog = false }) + if ( + showForgotPinDialog && + !applicationConfiguration.loginConfig.supervisorContactNumber.isNullOrBlank() + ) { + ForgotPinDialog( + supervisorContactNumber = applicationConfiguration.loginConfig.supervisorContactNumber, + forgotPin = forgotPin, + onDismissDialog = { showForgotPinDialog = false }, + ) } Column { Spacer(modifier = modifier.fillMaxHeight(0.22f)) @@ -153,7 +169,7 @@ fun PinLoginPage( // Only show error message and forgot password when not setting the pin if (!pinUiState.setupPin) { - if (pinUiState.showProgressBar) CircularProgressBar() + if (showProgressBar) CircularProgressBar() if (showError) { Text( @@ -174,14 +190,15 @@ fun PinLoginPage( .padding(top = 24.dp) .align(Alignment.CenterHorizontally) .clickable { showForgotPinDialog = !showForgotPinDialog } - .bringIntoViewRequester(bringIntoViewRequester), + .bringIntoViewRequester(bringIntoViewRequester) + .testTag(FORGOT_PIN_TEST_TAG), // <-- Added test tag here ) } } else { // Enable button when a new PIN of required length is entered Button( onClick = { onSetPin(newPin) }, - enabled = newPin.size == pinUiState.pinLength && !pinUiState.showProgressBar, + enabled = newPin.size == pinUiState.pinLength && !showProgressBar, modifier = modifier .bringIntoViewRequester(bringIntoViewRequester) @@ -195,7 +212,7 @@ fun PinLoginPage( elevation = null, ) { Box(modifier = Modifier.padding(8.dp), contentAlignment = Alignment.Center) { - if (pinUiState.showProgressBar) { + if (showProgressBar) { CircularProgressIndicator( modifier = modifier.size(18.dp).testTag(CIRCULAR_PROGRESS_INDICATOR), strokeWidth = 1.6.dp, @@ -265,10 +282,13 @@ private fun PinTopBar( @Composable fun ForgotPinDialog( - forgotPin: () -> Unit, + supervisorContactNumber: String?, + forgotPin: (Context) -> Unit, onDismissDialog: () -> Unit, modifier: Modifier = Modifier, ) { + val context = LocalContext.current + AlertDialog( onDismissRequest = onDismissDialog, title = { @@ -278,7 +298,20 @@ fun ForgotPinDialog( fontSize = 20.sp, ) }, - text = { Text(text = stringResource(R.string.please_contact_supervisor), fontSize = 16.sp) }, + text = { + Column( + modifier = modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp), + ) { + Text( + text = stringResource(R.string.call_supervisor), + fontSize = 16.sp, + ) + Text( + text = supervisorContactNumber?.takeIf { it.isNotBlank() } ?: "", + fontSize = 16.sp, + ) + } + }, buttons = { Row( modifier = modifier.fillMaxWidth().padding(vertical = 20.dp), @@ -294,7 +327,7 @@ fun ForgotPinDialog( modifier = modifier.padding(horizontal = 10.dp).clickable { onDismissDialog() - forgotPin() + forgotPin(context) }, ) } @@ -306,6 +339,13 @@ fun ForgotPinDialog( @PreviewWithBackgroundExcludeGenerated private fun PinSetupPreview() { PinLoginPage( + applicationConfiguration = + ApplicationConfiguration( + appId = "appId", + configType = "application", + appTitle = "FHIRCore App", + ), + showProgressBar = false, showError = false, pinUiState = PinUiState( @@ -327,6 +367,13 @@ private fun PinSetupPreview() { @PreviewWithBackgroundExcludeGenerated private fun PinSetupPreviewWithProgress() { PinLoginPage( + applicationConfiguration = + ApplicationConfiguration( + appId = "appId", + configType = "application", + appTitle = "FHIRCore App", + ), + showProgressBar = true, showError = false, pinUiState = PinUiState( @@ -335,7 +382,6 @@ private fun PinSetupPreviewWithProgress() { setupPin = true, pinLength = 4, showLogo = true, - showProgressBar = true, ), onSetPin = {}, onMenuLoginClicked = {}, @@ -349,6 +395,13 @@ private fun PinSetupPreviewWithProgress() { @PreviewWithBackgroundExcludeGenerated private fun PinLoginPreview() { PinLoginPage( + applicationConfiguration = + ApplicationConfiguration( + appId = "appId", + configType = "application", + appTitle = "FHIRCore App", + ), + showProgressBar = false, showError = false, pinUiState = PinUiState( diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/pin/PinUiState.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/pin/PinUiState.kt index ffc4db8e6bb..98d51329fff 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/pin/PinUiState.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/pin/PinUiState.kt @@ -22,5 +22,4 @@ data class PinUiState( val setupPin: Boolean, val pinLength: Int, val showLogo: Boolean, - val showProgressBar: Boolean = false, ) diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/pin/PinViewModel.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/pin/PinViewModel.kt index 90dfb155a0a..43e84cc3157 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/pin/PinViewModel.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/pin/PinViewModel.kt @@ -16,7 +16,9 @@ package org.smartregister.fhircore.quest.ui.pin +import android.annotation.SuppressLint import android.content.Context +import android.widget.Toast import androidx.compose.runtime.MutableState import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.LiveData @@ -26,6 +28,7 @@ import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import java.util.Base64 import javax.inject.Inject +import kotlinx.coroutines.async import kotlinx.coroutines.launch import org.smartregister.fhircore.engine.R import org.smartregister.fhircore.engine.configuration.ConfigType @@ -36,8 +39,11 @@ import org.smartregister.fhircore.engine.util.SecureSharedPreference import org.smartregister.fhircore.engine.util.SharedPreferenceKey import org.smartregister.fhircore.engine.util.SharedPreferencesHelper import org.smartregister.fhircore.engine.util.clearPasswordInMemory +import org.smartregister.fhircore.engine.util.extension.formatPhoneNumber +import org.smartregister.fhircore.engine.util.extension.showToast import org.smartregister.fhircore.engine.util.toPasswordHash +@Suppress("UNUSED_EXPRESSION") @HiltViewModel class PinViewModel @Inject @@ -68,15 +74,20 @@ constructor( val showError get() = _showError + private val _showProgressBar = MutableLiveData(false) + val showProgressBar + get() = _showProgressBar + val pinUiState: MutableState = mutableStateOf( PinUiState(message = "", appName = "", setupPin = false, pinLength = 0, showLogo = false), ) - private val applicationConfiguration: ApplicationConfiguration by lazy { + val applicationConfiguration: ApplicationConfiguration by lazy { configurationRegistry.retrieveConfiguration(ConfigType.Application) } + @SuppressLint("StringFormatInvalid") fun setPinUiState(setupPin: Boolean = false, context: Context) { val username = secureSharedPreference.retrieveSessionUsername() pinUiState.value = @@ -97,24 +108,28 @@ constructor( fun onPinVerified(validPin: Boolean) { if (validPin) { - pinUiState.value = pinUiState.value.copy(showProgressBar = false) + showProgressBar(false) _navigateToHome.postValue(true) } } fun onShowPinError(showError: Boolean) { - pinUiState.value = pinUiState.value.copy(showProgressBar = false) + showProgressBar(false) _showError.postValue(showError) } fun onSetPin(newPin: CharArray) { - viewModelScope.launch(dispatcherProvider.io()) { - pinUiState.value = pinUiState.value.copy(showProgressBar = true) - secureSharedPreference.saveSessionPin(newPin) - pinUiState.value = pinUiState.value.copy(showProgressBar = false) + viewModelScope.launch { + showProgressBar(true) + secureSharedPreference.saveSessionPin(newPin) { + showProgressBar(false) + _navigateToHome.postValue(true) + } } + } - _navigateToHome.postValue(true) + fun showProgressBar(showProgressBar: Boolean) { + _showProgressBar.postValue(showProgressBar) } fun onMenuItemClicked(launchAppSettingScreen: Boolean) { @@ -129,25 +144,31 @@ constructor( } } - fun forgotPin() { - // TODO use valid supervisor (Practitioner) telephone number - _launchDialPad.value = "tel:####" + fun forgotPin(context: Context) { + val formattedNumber = + applicationConfiguration.loginConfig.supervisorContactNumber.formatPhoneNumber(context) + if (!formattedNumber.isNullOrBlank()) { + _launchDialPad.value = formattedNumber + } else { + context.showToast(context.getString(R.string.missing_supervisor_contact), Toast.LENGTH_LONG) + } } fun pinLogin(enteredPin: CharArray, callback: (Boolean) -> Unit) { - viewModelScope.launch(dispatcherProvider.io()) { - pinUiState.value = pinUiState.value.copy(showProgressBar = true) - + viewModelScope.launch { + showProgressBar(true) val storedPinHash = secureSharedPreference.retrieveSessionPin() val salt = secureSharedPreference.retrievePinSalt() - val generatedHash = enteredPin.toPasswordHash(Base64.getDecoder().decode(salt)) + val generatedHash = + async(dispatcherProvider.io()) { + enteredPin.toPasswordHash(Base64.getDecoder().decode(salt)) + } + .await() val validPin = generatedHash == storedPinHash - if (validPin) clearPasswordInMemory(enteredPin) - callback.invoke(validPin) - onPinVerified(validPin) + showProgressBar(false) } } } diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/profile/ProfileFragment.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/profile/ProfileFragment.kt index c674c4f4a9d..f627153a43b 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/profile/ProfileFragment.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/profile/ProfileFragment.kt @@ -63,20 +63,17 @@ class ProfileFragment : Fragment() { with(profileFragmentArgs) { lifecycleScope.launch { profileViewModel.run { - decodeBinaryResourceIconsToBitmap(profileId) retrieveProfileUiState(profileId, resourceId, resourceConfig, params) } } } profileViewModel.refreshProfileDataLiveData.observe(viewLifecycleOwner) { - viewLifecycleOwner.lifecycleScope.launch { - if (it == true) { - with(profileFragmentArgs) { - profileViewModel.retrieveProfileUiState(profileId, resourceId, resourceConfig, params) - } - profileViewModel.refreshProfileDataLiveData.value = null + if (it == true) { + with(profileFragmentArgs) { + profileViewModel.retrieveProfileUiState(profileId, resourceId, resourceConfig, params) } + profileViewModel.refreshProfileDataLiveData.value = null } } @@ -87,8 +84,9 @@ class ProfileFragment : Fragment() { ProfileScreen( navController = findNavController(), profileUiState = profileViewModel.profileUiState.value, - onEvent = profileViewModel::onEvent, snackStateFlow = profileViewModel.snackBarStateFlow, + onEvent = profileViewModel::onEvent, + decodeImage = { profileViewModel.getImageBitmap(it) }, ) } } diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/profile/ProfileScreen.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/profile/ProfileScreen.kt index 3f47fa0359c..1bcc672eddb 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/profile/ProfileScreen.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/profile/ProfileScreen.kt @@ -16,6 +16,7 @@ package org.smartregister.fhircore.quest.ui.profile +import android.graphics.Bitmap import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box @@ -42,7 +43,7 @@ import androidx.compose.material.Scaffold import androidx.compose.material.Text import androidx.compose.material.TopAppBar import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.outlined.MoreVert import androidx.compose.material.rememberScaffoldState import androidx.compose.runtime.Composable @@ -89,6 +90,7 @@ fun ProfileScreen( profileUiState: ProfileUiState, snackStateFlow: SharedFlow, onEvent: (ProfileEvent) -> Unit, + decodeImage: ((String) -> Bitmap?)?, ) { val scaffoldState = rememberScaffoldState() val lazyListState = rememberLazyListState() @@ -109,6 +111,7 @@ fun ProfileScreen( lazyListState = lazyListState, onEvent = onEvent, collapsible = false, + decodeImage = decodeImage, ) } else { CustomProfileTopAppBar( @@ -116,6 +119,7 @@ fun ProfileScreen( profileUiState = profileUiState, onEvent = onEvent, lazyListState = lazyListState, + decodeImage = decodeImage, ) } }, @@ -127,6 +131,7 @@ fun ProfileScreen( resourceData = profileUiState.resourceData, navController = navController, lazyListState = lazyListState, + decodeImage = decodeImage, ) } }, @@ -161,7 +166,9 @@ fun ProfileScreen( bottom = if (!fabActions.isNullOrEmpty() && fabActions.first().visible) { PADDING_BOTTOM_WITH_FAB.dp - } else PADDING_BOTTOM_WITHOUT_FAB.dp, + } else { + PADDING_BOTTOM_WITHOUT_FAB.dp + }, ), ) { item(key = profileUiState.resourceData?.baseResourceId) { @@ -170,6 +177,7 @@ fun ProfileScreen( resourceData = profileUiState.resourceData ?: ResourceData("", ResourceType.Patient, emptyMap()), navController = navController, + decodeImage = decodeImage, ) } } @@ -184,6 +192,7 @@ fun CustomProfileTopAppBar( profileUiState: ProfileUiState, onEvent: (ProfileEvent) -> Unit, lazyListState: LazyListState, + decodeImage: ((String) -> Bitmap?)?, ) { val topBarConfig = remember { profileUiState.profileConfiguration?.topAppBar ?: TopBarConfig() } @@ -200,6 +209,7 @@ fun CustomProfileTopAppBar( collapsible = topBarConfig.collapsible, onEvent = onEvent, lazyListState = lazyListState, + decodeImage = decodeImage, ) if (topBarConfig.collapsible) { AnimatedVisibility(visible = lazyListState.isScrollingDown()) { @@ -209,6 +219,7 @@ fun CustomProfileTopAppBar( profileUiState = profileUiState, navController = navController, titleContentPadding = 16, + decodeImage = decodeImage, ) } } else { @@ -218,6 +229,7 @@ fun CustomProfileTopAppBar( profileUiState = profileUiState, navController = navController, titleContentPadding = 0, + decodeImage = decodeImage, ) } } @@ -230,6 +242,7 @@ private fun RenderSimpleAppTopBar( profileUiState: ProfileUiState, navController: NavController, titleContentPadding: Int, + decodeImage: ((String) -> Bitmap?)?, ) { Column( modifier = @@ -244,6 +257,7 @@ private fun RenderSimpleAppTopBar( resourceData = profileUiState.resourceData ?: ResourceData("", ResourceType.Patient, emptyMap()), navController = navController, + decodeImage = decodeImage, ) } } @@ -257,6 +271,7 @@ private fun SimpleTopAppBar( profileUiState: ProfileUiState, lazyListState: LazyListState, collapsible: Boolean, + decodeImage: ((String) -> Bitmap?)?, onEvent: (ProfileEvent) -> Unit, ) { TopAppBar( @@ -283,7 +298,7 @@ private fun SimpleTopAppBar( navigationIcon = { IconButton(onClick = { navController.popBackStack() }) { Icon( - Icons.Filled.ArrowBack, + Icons.AutoMirrored.Filled.ArrowBack, null, modifier = modifier.testTag(PROFILE_TOP_BAR_ICON_TEST_TAG), ) @@ -294,6 +309,7 @@ private fun SimpleTopAppBar( profileUiState = profileUiState, onEvent = onEvent, navController = navController, + decodeImage = decodeImage, ) }, elevation = elevation.dp, @@ -306,6 +322,7 @@ private fun ProfileTopAppBarMenuAction( onEvent: (ProfileEvent) -> Unit, navController: NavController, modifier: Modifier = Modifier, + decodeImage: ((String) -> Bitmap?)?, ) { if (!profileUiState.profileConfiguration?.overFlowMenuItems.isNullOrEmpty()) { var showOverflowMenu by remember { mutableStateOf(false) } @@ -357,6 +374,7 @@ private fun ProfileTopAppBarMenuAction( tint = contentColor, navController = navController, resourceData = profileUiState.resourceData!!, + decodeImage = decodeImage, ) if (overflowMenuItemConfig.icon != null) Spacer(modifier = Modifier.width(4.dp)) Text(text = overflowMenuItemConfig.title, color = contentColor) diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/profile/ProfileViewModel.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/profile/ProfileViewModel.kt index 680094ebb75..ceec5ca81db 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/profile/ProfileViewModel.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/profile/ProfileViewModel.kt @@ -16,6 +16,7 @@ package org.smartregister.fhircore.quest.ui.profile +import android.graphics.Bitmap import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.snapshots.SnapshotStateList @@ -30,8 +31,8 @@ import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext -import org.hl7.fhir.r4.model.Binary import org.hl7.fhir.r4.model.Group import org.hl7.fhir.r4.model.ResourceType import org.smartregister.fhircore.engine.configuration.ConfigType @@ -55,9 +56,8 @@ import org.smartregister.fhircore.engine.util.fhirpath.FhirPathDataExtractor import org.smartregister.fhircore.quest.R import org.smartregister.fhircore.quest.ui.profile.bottomSheet.ProfileBottomSheetFragment import org.smartregister.fhircore.quest.ui.profile.model.EligibleManagingEntity -import org.smartregister.fhircore.quest.util.extensions.decodeBinaryResourcesToBitmap import org.smartregister.fhircore.quest.util.extensions.handleClickEvent -import org.smartregister.fhircore.quest.util.extensions.loadRemoteImagesBitmaps +import org.smartregister.fhircore.quest.util.extensions.referenceToBitmap import org.smartregister.fhircore.quest.util.extensions.toParamDataMap import timber.log.Timber @@ -84,72 +84,44 @@ constructor( private val listResourceDataStateMap = mutableStateMapOf>() - /** - * This function retrieves an image that was synced from the backend as a [Binary] resource, the - * content of the Binary resource is a base64 encoding of the actual image. The encoded imaged is - * then transformed into bitmap for use in an Image Composable (returns null if the referenced - * resource doesn't exist) - */ - fun decodeBinaryResourceIconsToBitmap(profileId: String) { - val profileConfig = - configurationRegistry.retrieveConfiguration( - configId = profileId, - configType = ConfigType.Profile, - ) - profileConfig.overFlowMenuItems - .filter { it.icon != null && !it.icon!!.reference.isNullOrEmpty() } - .decodeBinaryResourcesToBitmap(viewModelScope, registerRepository) - } + private val decodedImageMap = mutableStateMapOf() - suspend fun retrieveProfileUiState( + fun retrieveProfileUiState( profileId: String, resourceId: String, fhirResourceConfig: FhirResourceConfig? = null, paramsList: Array? = emptyArray(), ) { - if (resourceId.isNotEmpty()) { - val repositoryResourceData = - registerRepository.loadProfileData(profileId, resourceId, fhirResourceConfig, paramsList) - val paramsMap: Map = paramsList.toParamDataMap() - val profileConfigs = retrieveProfileConfiguration(profileId, paramsMap) - val resourceData = - resourceDataRulesExecutor - .processResourceData( - repositoryResourceData = repositoryResourceData, - ruleConfigs = profileConfigs.rules, - params = paramsMap, - ) - .copy(listResourceDataMap = listResourceDataStateMap) + viewModelScope.launch { + if (resourceId.isNotEmpty()) { + val repositoryResourceData = + registerRepository.loadProfileData(profileId, resourceId, fhirResourceConfig, paramsList) + val paramsMap: Map = paramsList.toParamDataMap() + val profileConfigs = retrieveProfileConfiguration(profileId, paramsMap) + val resourceData = + resourceDataRulesExecutor + .processResourceData( + repositoryResourceData = repositoryResourceData, + ruleConfigs = profileConfigs.rules, + params = paramsMap, + ) + .copy(listResourceDataMap = listResourceDataStateMap) - profileUiState.value = - ProfileUiState( - resourceData = resourceData, - profileConfiguration = profileConfigs, - snackBarTheme = applicationConfiguration.snackBarTheme, - showDataLoadProgressIndicator = false, - ) + profileUiState.value = + ProfileUiState( + resourceData = resourceData, + profileConfiguration = profileConfigs, + snackBarTheme = applicationConfiguration.snackBarTheme, + showDataLoadProgressIndicator = false, + ) - profileConfigs.views.retrieveListProperties().forEach { listProperties -> - resourceDataRulesExecutor.processListResourceData( - listProperties = listProperties, - relatedResourcesMap = repositoryResourceData.relatedResourcesMap, - computedValuesMap = resourceData.computedValuesMap.plus(paramsMap), - listResourceDataStateMap = listResourceDataStateMap, - ) - if ( - listResourceDataStateMap[listProperties.id] != null && - listResourceDataStateMap[listProperties.id]?.size!! > 0 - ) { - val computedMap = listResourceDataStateMap[listProperties.id]?.get(0)?.computedValuesMap - viewModelScope.launch(dispatcherProvider.io()) { - if (computedMap != null) { - loadRemoteImagesBitmaps( - profileConfiguration.views, - registerRepository, - computedMap, - ) - } - } + profileConfigs.views.retrieveListProperties().forEach { listProperties -> + resourceDataRulesExecutor.processListResourceData( + listProperties = listProperties, + relatedResourcesMap = repositoryResourceData.relatedResourcesMap, + computedValuesMap = resourceData.computedValuesMap.plus(paramsMap), + listResourceDataStateMap = listResourceDataStateMap, + ) } } } @@ -292,4 +264,8 @@ constructor( } } } + + fun getImageBitmap(reference: String) = runBlocking { + reference.referenceToBitmap(registerRepository.fhirEngine, decodedImageMap) + } } diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/profile/components/ChangeManagingEntityView.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/profile/components/ChangeManagingEntityView.kt index d7b6c97d24d..229dc75593f 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/profile/components/ChangeManagingEntityView.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/profile/components/ChangeManagingEntityView.kt @@ -154,7 +154,9 @@ private fun ChangeManagingEntityBottomBar( id = if (isEnabled) { org.smartregister.fhircore.engine.R.color.colorPrimary - } else org.smartregister.fhircore.engine.R.color.white, + } else { + org.smartregister.fhircore.engine.R.color.white + }, ), ), ) { @@ -165,7 +167,9 @@ private fun ChangeManagingEntityBottomBar( id = if (isEnabled) { org.smartregister.fhircore.engine.R.color.white - } else org.smartregister.fhircore.engine.R.color.colorPrimary, + } else { + org.smartregister.fhircore.engine.R.color.colorPrimary + }, ), text = stringResource(id = org.smartregister.fhircore.engine.R.string.str_save).uppercase(), ) diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/profile/components/MemberProfileBottomSheetView.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/profile/components/MemberProfileBottomSheetView.kt index e88d5162a61..bbd421e62cb 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/profile/components/MemberProfileBottomSheetView.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/profile/components/MemberProfileBottomSheetView.kt @@ -18,6 +18,7 @@ package org.smartregister.fhircore.quest.ui.profile.components +import android.graphics.Bitmap import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -72,6 +73,7 @@ fun MemberProfileBottomSheetView( resourceData: ResourceData, navController: NavController = rememberNavController(), onViewProfile: () -> Unit, + decodeImage: ((String) -> Bitmap?)?, ) { Column { // Top section displays the name, gender and age for member @@ -115,6 +117,7 @@ fun MemberProfileBottomSheetView( buttonProperties = it, resourceData = resourceData, navController = navController, + decodeImage = decodeImage, ) } Spacer(modifier = modifier.height(8.dp)) @@ -147,6 +150,7 @@ private fun MemberProfileBottomSheetViewPreview() { navController = rememberNavController(), onViewProfile = { /*Do nothing*/}, resourceData = ResourceData("id", ResourceType.Patient, emptyMap()), + decodeImage = null, ) } @@ -166,5 +170,6 @@ private fun MemberProfileBottomSheetViewWithFormDataPreview() { navController = rememberNavController(), onViewProfile = { /*Do nothing*/}, resourceData = ResourceData("id", ResourceType.Patient, emptyMap()), + decodeImage = null, ) } diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireActivity.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireActivity.kt index e8baa96045c..2bb53a31e05 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireActivity.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireActivity.kt @@ -27,7 +27,9 @@ import android.provider.Settings import android.view.View import android.widget.Toast import androidx.activity.OnBackPressedCallback +import androidx.activity.result.ActivityResult import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.viewModels import androidx.core.os.bundleOf import androidx.fragment.app.commit @@ -38,6 +40,7 @@ import com.google.android.gms.location.LocationServices import dagger.hilt.android.AndroidEntryPoint import java.io.Serializable import javax.inject.Inject +import kotlinx.coroutines.async import kotlinx.coroutines.launch import org.hl7.fhir.r4.model.Questionnaire import org.hl7.fhir.r4.model.QuestionnaireResponse @@ -46,6 +49,7 @@ import org.smartregister.fhircore.engine.configuration.QuestionnaireConfig import org.smartregister.fhircore.engine.configuration.app.LocationLogOptions import org.smartregister.fhircore.engine.domain.model.ActionParameter import org.smartregister.fhircore.engine.domain.model.isReadOnly +import org.smartregister.fhircore.engine.domain.model.isSummary import org.smartregister.fhircore.engine.ui.base.AlertDialogue import org.smartregister.fhircore.engine.ui.base.BaseMultiLanguageActivity import org.smartregister.fhircore.engine.util.DispatcherProvider @@ -57,6 +61,8 @@ import org.smartregister.fhircore.engine.util.location.LocationUtils import org.smartregister.fhircore.engine.util.location.PermissionUtils import org.smartregister.fhircore.quest.R import org.smartregister.fhircore.quest.databinding.QuestionnaireActivityBinding +import org.smartregister.fhircore.quest.ui.shared.ActivityOnResultType +import org.smartregister.fhircore.quest.ui.shared.ON_RESULT_TYPE import org.smartregister.fhircore.quest.util.ResourceUtils import timber.log.Timber @@ -71,9 +77,30 @@ class QuestionnaireActivity : BaseMultiLanguageActivity() { private var questionnaire: Questionnaire? = null private var alertDialog: AlertDialog? = null private lateinit var fusedLocationClient: FusedLocationProviderClient - var currentLocation: Location? = null - private lateinit var locationPermissionLauncher: ActivityResultLauncher> - private lateinit var activityResultLauncher: ActivityResultLauncher + private var currentLocation: Location? = null + private val locationPermissionLauncher: ActivityResultLauncher> = + registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { + permissions: Map -> + PermissionUtils.getLocationPermissionLauncher( + permissions = permissions, + onFineLocationPermissionGranted = { fetchLocation() }, + onCoarseLocationPermissionGranted = { fetchLocation() }, + onLocationPermissionDenied = { + showToast( + getString(R.string.location_permissions_denied), + Toast.LENGTH_SHORT, + ) + }, + ) + } + + private val activityResultLauncher: ActivityResultLauncher = + registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { + activityResult: ActivityResult -> + if (activityResult.resultCode == Activity.RESULT_OK) { + fetchLocation() + } + } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -122,48 +149,39 @@ class QuestionnaireActivity : BaseMultiLanguageActivity() { ) } - fun setupLocationServices() { + private fun setupLocationServices() { if ( viewModel.applicationConfiguration.logGpsLocation.contains(LocationLogOptions.QUESTIONNAIRE) ) { fusedLocationClient = LocationServices.getFusedLocationProviderClient(this) if (!LocationUtils.isLocationEnabled(this)) { - openLocationServicesSettings() + showLocationSettingsDialog( + Intent(Settings.ACTION_LOCATION_SOURCE_SETTINGS).apply { + putExtra(ON_RESULT_TYPE, ActivityOnResultType.LOCATION.name) + }, + ) } - if (!hasLocationPermissions()) { - launchLocationPermissionsDialog() + if (!PermissionUtils.hasLocationPermissions(this)) { + locationPermissionLauncher.launch( + arrayOf( + Manifest.permission.ACCESS_FINE_LOCATION, + Manifest.permission.ACCESS_COARSE_LOCATION, + ), + ) } - if (LocationUtils.isLocationEnabled(this) && hasLocationPermissions()) { - fetchLocation(true) + if ( + currentLocation == null && + LocationUtils.isLocationEnabled(this) && + PermissionUtils.hasLocationPermissions(this) + ) { + fetchLocation() } } } - fun hasLocationPermissions(): Boolean { - return PermissionUtils.checkPermissions( - this, - listOf( - Manifest.permission.ACCESS_COARSE_LOCATION, - Manifest.permission.ACCESS_FINE_LOCATION, - ), - ) - } - - fun openLocationServicesSettings() { - activityResultLauncher = - PermissionUtils.getStartActivityForResultLauncher(this) { resultCode, _ -> - if (resultCode == RESULT_OK || hasLocationPermissions()) { - fetchLocation() - } - } - - val intent = Intent(Settings.ACTION_LOCATION_SOURCE_SETTINGS) - showLocationSettingsDialog(intent) - } - private fun showLocationSettingsDialog(intent: Intent) { viewModel.setProgressState(QuestionnaireProgressState.QuestionnaireLaunch(false)) AlertDialog.Builder(this) @@ -174,45 +192,21 @@ class QuestionnaireActivity : BaseMultiLanguageActivity() { .show() } - fun launchLocationPermissionsDialog() { - locationPermissionLauncher = - PermissionUtils.getLocationPermissionLauncher( - this, - onFineLocationPermissionGranted = { fetchLocation(true) }, - onCoarseLocationPermissionGranted = { fetchLocation(false) }, - onLocationPermissionDenied = { - Toast.makeText( - this, - getString(R.string.location_permissions_denied), - Toast.LENGTH_SHORT, - ) - .show() - Timber.e("Location permissions denied") - }, - ) - - locationPermissionLauncher.launch( - arrayOf( - Manifest.permission.ACCESS_FINE_LOCATION, - Manifest.permission.ACCESS_COARSE_LOCATION, - ), - ) - } - fun fetchLocation(highAccuracy: Boolean = true) { lifecycleScope.launch { try { - if (highAccuracy) { - currentLocation = LocationUtils.getAccurateLocation(fusedLocationClient) - } else { - currentLocation = LocationUtils.getApproximateLocation(fusedLocationClient) - } + currentLocation = + async(dispatcherProvider.io()) { + if (highAccuracy) { + LocationUtils.getAccurateLocation(fusedLocationClient) + } else { + LocationUtils.getApproximateLocation(fusedLocationClient) + } + } + .await() } catch (e: Exception) { Timber.e(e, "Failed to get GPS location for questionnaire: ${questionnaireConfig.id}") - } finally { - if (currentLocation == null) { - this@QuestionnaireActivity.showToast("Failed to get GPS location", Toast.LENGTH_LONG) - } + showToast("Failed to get GPS location", Toast.LENGTH_LONG) } } } @@ -223,59 +217,53 @@ class QuestionnaireActivity : BaseMultiLanguageActivity() { } private fun renderQuestionnaire() { + if (supportFragmentManager.findFragmentByTag(QUESTIONNAIRE_FRAGMENT_TAG) != null) return + lifecycleScope.launch { - var questionnaireFragment: QuestionnaireFragment? = null - if (supportFragmentManager.findFragmentByTag(QUESTIONNAIRE_FRAGMENT_TAG) == null) { - viewModel.setProgressState(QuestionnaireProgressState.QuestionnaireLaunch(true)) - with(viewBinding) { - questionnaireToolbar.apply { - setNavigationIcon(R.drawable.ic_arrow_back) - setNavigationOnClickListener { handleBackPress() } - } - questionnaireTitle.apply { text = questionnaireConfig.title } - clearAll.apply { - visibility = if (questionnaireConfig.showClearAll) View.VISIBLE else View.GONE - setOnClickListener { questionnaireFragment?.clearAllAnswers() } - } - } + viewModel.setProgressState(QuestionnaireProgressState.QuestionnaireLaunch(true)) - questionnaire = viewModel.retrieveQuestionnaire(questionnaireConfig, actionParameters) + viewBinding.questionnaireToolbar.setNavigationIcon(R.drawable.ic_cancel) + viewBinding.questionnaireToolbar.setNavigationOnClickListener { handleBackPress() } + viewBinding.questionnaireTitle.text = questionnaireConfig.title + viewBinding.clearAll.visibility = + if (questionnaireConfig.showClearAll) View.VISIBLE else View.GONE - try { - val questionnaireFragmentBuilder = - buildQuestionnaireFragment( - questionnaire = questionnaire!!, - questionnaireConfig = questionnaireConfig, - ) + questionnaire = viewModel.retrieveQuestionnaire(questionnaireConfig) - questionnaireFragment = questionnaireFragmentBuilder.build() - supportFragmentManager.commit { - setReorderingAllowed(true) - add(R.id.container, questionnaireFragment, QUESTIONNAIRE_FRAGMENT_TAG) - } + if (questionnaire == null) { + showToast(getString(R.string.questionnaire_not_found)) + finish() + return@launch + } + if (questionnaire!!.subjectType.isNullOrEmpty()) { + val subjectRequiredMessage = getString(R.string.missing_subject_type) + showToast(subjectRequiredMessage) + Timber.e(subjectRequiredMessage) + finish() + return@launch + } - registerFragmentResultListener() - } catch (nullPointerException: NullPointerException) { - showToast(getString(R.string.questionnaire_not_found)) - finish() - } finally { - viewModel.setProgressState(QuestionnaireProgressState.QuestionnaireLaunch(false)) - } + val questionnaireFragment = + getQuestionnaireFragmentBuilder( + questionnaire = questionnaire!!, + questionnaireConfig = questionnaireConfig, + ) + .build() + viewBinding.clearAll.setOnClickListener { questionnaireFragment.clearAllAnswers() } + supportFragmentManager.commit { + setReorderingAllowed(true) + add(R.id.container, questionnaireFragment, QUESTIONNAIRE_FRAGMENT_TAG) } + registerFragmentResultListener() + + viewModel.setProgressState(QuestionnaireProgressState.QuestionnaireLaunch(false)) } } - private suspend fun buildQuestionnaireFragment( + private suspend fun getQuestionnaireFragmentBuilder( questionnaire: Questionnaire, questionnaireConfig: QuestionnaireConfig, ): QuestionnaireFragment.Builder { - if (questionnaire.subjectType.isNullOrEmpty()) { - val subjectRequiredMessage = getString(R.string.missing_subject_type) - showToast(subjectRequiredMessage) - Timber.e(subjectRequiredMessage) - finish() - } - val (questionnaireResponse, launchContextResources) = viewModel.populateQuestionnaire(questionnaire, this.questionnaireConfig, actionParameters) @@ -289,6 +277,8 @@ class QuestionnaireActivity : BaseMultiLanguageActivity() { ) .showAsterisk(this.questionnaireConfig.showRequiredTextAsterisk) .showRequiredText(this.questionnaireConfig.showRequiredText) + .setIsReadOnly(questionnaireConfig.isSummary()) + .setShowSubmitAnywayButton(questionnaireConfig.showSubmitAnywayButton.toBooleanStrict()) .apply { if (questionnaireResponse != null) { questionnaireResponse @@ -346,6 +336,7 @@ class QuestionnaireActivity : BaseMultiLanguageActivity() { putExtra(QUESTIONNAIRE_RESPONSE, questionnaireResponse as Serializable) putExtra(QUESTIONNAIRE_SUBMISSION_EXTRACTED_RESOURCE_IDS, idTypes as Serializable) putExtra(QUESTIONNAIRE_CONFIG, questionnaireConfig as Parcelable) + putExtra(ON_RESULT_TYPE, ActivityOnResultType.QUESTIONNAIRE.name) }, ) finish() @@ -369,16 +360,20 @@ class QuestionnaireActivity : BaseMultiLanguageActivity() { confirmButtonListener = { lifecycleScope.launch { retrieveQuestionnaireResponse()?.let { questionnaireResponse -> - viewModel.saveDraftQuestionnaire(questionnaireResponse) + viewModel.saveDraftQuestionnaire(questionnaireResponse, questionnaireConfig) + finish() } } }, confirmButtonText = org.smartregister.fhircore.engine.R.string .questionnaire_alert_back_pressed_save_draft_button_title, - neutralButtonListener = { finish() }, + neutralButtonListener = {}, neutralButtonText = - org.smartregister.fhircore.engine.R.string.questionnaire_alert_back_pressed_button_title, + org.smartregister.fhircore.engine.R.string.questionnaire_alert_neutral_button_title, + negativeButtonListener = { finish() }, + negativeButtonText = + org.smartregister.fhircore.engine.R.string.questionnaire_alert_negative_button_title, ) } else { AlertDialogue.showConfirmAlert( diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireItemViewHolderFactoryMatchersProviderFactoryImpl.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireItemViewHolderFactoryMatchersProviderFactoryImpl.kt index ec979d06f5d..734d275686b 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireItemViewHolderFactoryMatchersProviderFactoryImpl.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireItemViewHolderFactoryMatchersProviderFactoryImpl.kt @@ -22,7 +22,8 @@ import com.google.android.fhir.datacapture.contrib.views.barcode.BarCodeReaderVi import com.google.android.fhir.datacapture.contrib.views.locationwidget.LocationGpsCoordinateViewHolderFactory import com.google.android.fhir.datacapture.contrib.views.locationwidget.LocationWidgetViewHolderFactory import com.google.android.fhir.datacapture.extensions.asStringValue -import org.smartregister.fhircore.quest.ui.sdc.PasswordViewHolderFactory +import org.smartregister.fhircore.quest.ui.sdc.password.PasswordViewHolderFactory +import org.smartregister.fhircore.quest.ui.sdc.qrCode.EditTextQrCodeViewHolderFactory const val OPENSRP_ITEM_VIEWHOLDER_FACTORY_MATCHERS_PROVIDER = "org.smartregister.fhircore.quest.QuestionnaireItemViewHolderFactoryMatchersProvider" @@ -64,6 +65,10 @@ object QuestionnaireItemViewHolderFactoryMatchersProviderFactoryImpl : if (it == null) false else it.value.asStringValue() == BARCODE_NAME } }, + QuestionnaireFragment.QuestionnaireItemViewHolderFactoryMatcher( + factory = EditTextQrCodeViewHolderFactory, + matches = EditTextQrCodeViewHolderFactory::matcher, + ), QuestionnaireFragment.QuestionnaireItemViewHolderFactoryMatcher( factory = LocationGpsCoordinateViewHolderFactory, matches = LocationGpsCoordinateViewHolderFactory::matcher, diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireViewModel.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireViewModel.kt index 08e9721af8a..b2430fe56e2 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireViewModel.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireViewModel.kt @@ -32,8 +32,8 @@ import com.google.android.fhir.datacapture.validation.Valid import com.google.android.fhir.db.ResourceNotFoundException import com.google.android.fhir.search.Search import com.google.android.fhir.search.filter.TokenParamFilterCriterion -import com.google.android.fhir.search.search import com.google.android.fhir.workflow.FhirOperator +import dagger.Lazy import dagger.hilt.android.lifecycle.HiltViewModel import java.util.Date import java.util.LinkedList @@ -59,7 +59,7 @@ import org.hl7.fhir.r4.model.QuestionnaireResponse.QuestionnaireResponseItemComp import org.hl7.fhir.r4.model.RelatedPerson import org.hl7.fhir.r4.model.Resource import org.hl7.fhir.r4.model.ResourceType -import org.hl7.fhir.r4.model.StringType +import org.hl7.fhir.r4.model.StructureMap import org.smartregister.fhircore.engine.BuildConfig import org.smartregister.fhircore.engine.configuration.ConfigType import org.smartregister.fhircore.engine.configuration.ConfigurationRegistry @@ -78,11 +78,11 @@ import org.smartregister.fhircore.engine.task.FhirCarePlanGenerator import org.smartregister.fhircore.engine.util.DispatcherProvider import org.smartregister.fhircore.engine.util.SharedPreferenceKey import org.smartregister.fhircore.engine.util.SharedPreferencesHelper -import org.smartregister.fhircore.engine.util.extension.DEFAULT_PLACEHOLDER_PREFIX import org.smartregister.fhircore.engine.util.extension.appendOrganizationInfo import org.smartregister.fhircore.engine.util.extension.appendPractitionerInfo import org.smartregister.fhircore.engine.util.extension.appendRelatedEntityLocation import org.smartregister.fhircore.engine.util.extension.asReference +import org.smartregister.fhircore.engine.util.extension.batchedSearch import org.smartregister.fhircore.engine.util.extension.clearText import org.smartregister.fhircore.engine.util.extension.cqfLibraryUrls import org.smartregister.fhircore.engine.util.extension.extractByStructureMap @@ -91,13 +91,15 @@ import org.smartregister.fhircore.engine.util.extension.extractLogicalIdUuid import org.smartregister.fhircore.engine.util.extension.find import org.smartregister.fhircore.engine.util.extension.generateMissingId import org.smartregister.fhircore.engine.util.extension.isIn -import org.smartregister.fhircore.engine.util.extension.prePopulateInitialValues -import org.smartregister.fhircore.engine.util.extension.prepareQuestionsForEditing -import org.smartregister.fhircore.engine.util.extension.prepareQuestionsForReadingOrEditing +import org.smartregister.fhircore.engine.util.extension.packRepeatedGroups +import org.smartregister.fhircore.engine.util.extension.prepopulateWithComputedConfigValues +import org.smartregister.fhircore.engine.util.extension.questionnaireResponseStatus import org.smartregister.fhircore.engine.util.extension.showToast import org.smartregister.fhircore.engine.util.extension.updateLastUpdated import org.smartregister.fhircore.engine.util.fhirpath.FhirPathDataExtractor import org.smartregister.fhircore.engine.util.helper.TransformSupportServices +import org.smartregister.fhircore.engine.util.validation.ResourceValidationRequest +import org.smartregister.fhircore.engine.util.validation.ResourceValidationRequestHandler import org.smartregister.fhircore.quest.R import timber.log.Timber @@ -112,11 +114,10 @@ constructor( val transformSupportServices: TransformSupportServices, val sharedPreferencesHelper: SharedPreferencesHelper, val fhirOperator: FhirOperator, + val fhirValidatorRequestHandlerProvider: Lazy, val fhirPathDataExtractor: FhirPathDataExtractor, val configurationRegistry: ConfigurationRegistry, ) : ViewModel() { - private val parser = FhirContext.forR4Cached().newJsonParser() - private val authenticatedOrganizationIds by lazy { sharedPreferencesHelper.read>(ResourceType.Organization.name) } @@ -139,94 +140,13 @@ constructor( /** * This function retrieves the [Questionnaire] as configured via the [QuestionnaireConfig]. The - * retrieved [Questionnaire] can be pre-populated with computed values from the Rules engine as - * well as include initial values set on configured [QuestionnaireConfig.barcodeLinkId] or - * [QuestionnaireConfig.uniqueIdAssignment] properties. + * retrieved [Questionnaire] can then be pre-populated. */ suspend fun retrieveQuestionnaire( questionnaireConfig: QuestionnaireConfig, - actionParameters: List?, ): Questionnaire? { if (questionnaireConfig.id.isEmpty() || questionnaireConfig.id.isBlank()) return null - - // Compute questionnaire config rules and add extra questionnaire params to action parameters - val questionnaireComputedValues = - questionnaireConfig.configRules?.let { - resourceDataRulesExecutor.computeResourceDataRules(it, null, emptyMap()) - } ?: emptyMap() - - val allActionParameters = - actionParameters?.plus( - questionnaireConfig.extraParams?.map { it.interpolate(questionnaireComputedValues) } - ?: emptyList(), - ) - - val questionnaire = - defaultRepository.loadResource(questionnaireConfig.id)?.apply { - if (questionnaireConfig.isReadOnly() || questionnaireConfig.isEditable()) { - item.prepareQuestionsForReadingOrEditing( - readOnly = questionnaireConfig.isReadOnly(), - readOnlyLinkIds = - questionnaireConfig.readOnlyLinkIds - ?: questionnaireConfig.linkIds - ?.filter { it.type == LinkIdType.READ_ONLY } - ?.map { it.linkId }, - ) - } - - if (questionnaireConfig.isEditable()) { - item.prepareQuestionsForEditing(readOnlyLinkIds = questionnaireConfig.readOnlyLinkIds) - } - - // Pre-populate questionnaire items with configured values - allActionParameters - ?.filter { (it.paramType == ActionParameterType.PREPOPULATE && it.value.isNotEmpty()) } - ?.let { actionParam -> - item.prePopulateInitialValues(DEFAULT_PLACEHOLDER_PREFIX, actionParam) - } - - // Set barcode to the configured linkId default: "patient-barcode" - if (!questionnaireConfig.resourceIdentifier.isNullOrEmpty()) { - (questionnaireConfig.barcodeLinkId - ?: questionnaireConfig.linkIds?.firstOrNull { it.type == LinkIdType.BARCODE }?.linkId) - ?.let { barcodeLinkId -> - find(barcodeLinkId)?.apply { - initial = - mutableListOf( - Questionnaire.QuestionnaireItemInitialComponent() - .setValue(StringType(questionnaireConfig.resourceIdentifier)), - ) // TODO should this be resource identifier or OpenSrp unique ID? - readOnly = true - } - } - } - - // Set configured openSrpId on Questionnaire - questionnaireConfig.uniqueIdAssignment?.let { uniqueIdAssignmentConfig -> - find(uniqueIdAssignmentConfig.linkId)?.apply { - // Extract ID from a Group, should be modified in future to support other resources - val uniqueIdResource = - defaultRepository.retrieveUniqueIdAssignmentResource( - questionnaireConfig.uniqueIdAssignment, - ) - - val extractedId = - fhirPathDataExtractor.extractValue( - base = uniqueIdResource, - expression = uniqueIdAssignmentConfig.idFhirPathExpression, - ) - if (uniqueIdResource != null && extractedId.isNotEmpty()) { - initial = - mutableListOf( - Questionnaire.QuestionnaireItemInitialComponent() - .setValue(StringType(extractedId)), - ) - } - readOnly = extractedId.isNotEmpty() && uniqueIdAssignmentConfig.readOnly - } - } - } - return questionnaire + return defaultRepository.loadResourceFromCache(questionnaireConfig.id) } /** @@ -261,7 +181,11 @@ constructor( return@launch } - currentQuestionnaireResponse.processMetadata(questionnaire, questionnaireConfig, context) + currentQuestionnaireResponse.processMetadata( + questionnaire = questionnaire, + questionnaireConfig = questionnaireConfig, + context = context, + ) val bundle = performExtraction( @@ -271,62 +195,105 @@ constructor( context = context, ) - saveExtractedResources( - bundle = bundle, - questionnaire = questionnaire, - questionnaireConfig = questionnaireConfig, - questionnaireResponse = currentQuestionnaireResponse, - context = context, + defaultRepository.applyDbTransaction { + performSave( + bundle, + questionnaire, + questionnaireConfig, + currentQuestionnaireResponse, + context, + actionParameters, + ) + } + + val idTypes = + bundle.entry?.map { IdType(it.resource.resourceType.name, it.resource.logicalId) } + ?: emptyList() + + onSuccessfulSubmission( + idTypes, + currentQuestionnaireResponse, ) + } + } - updateResourcesLastUpdatedProperty(actionParameters) + private suspend fun performSave( + bundle: Bundle, + questionnaire: Questionnaire, + questionnaireConfig: QuestionnaireConfig, + currentQuestionnaireResponse: QuestionnaireResponse, + context: Context, + actionParameters: List, + ) { + saveExtractedResources( + bundle = bundle, + questionnaire = questionnaire, + questionnaireConfig = questionnaireConfig, + questionnaireResponse = currentQuestionnaireResponse, + context = context, + ) - // Important to load subject resource to retrieve ID (as reference) correctly - val subjectIdType: IdType? = - if (currentQuestionnaireResponse.subject.reference.isNullOrEmpty()) { - null - } else { - IdType(currentQuestionnaireResponse.subject.reference) - } + updateResourcesLastUpdatedProperty(actionParameters) - if (subjectIdType != null) { - val subject = - loadResource(ResourceType.valueOf(subjectIdType.resourceType), subjectIdType.idPart) + // Important to load subject resource to retrieve ID (as reference) correctly + val subjectIdType: IdType? = + if (currentQuestionnaireResponse.subject.reference.isNullOrEmpty()) { + null + } else { + IdType(currentQuestionnaireResponse.subject.reference) + } - if (subject != null && !questionnaireConfig.isReadOnly()) { - val newBundle = bundle.copyBundle(currentQuestionnaireResponse) + if (subjectIdType != null) { + val subject = + loadResource( + ResourceType.valueOf(subjectIdType.resourceType), + subjectIdType.idPart, + ) - generateCarePlan( - subject = subject, - bundle = newBundle, - questionnaireConfig = questionnaireConfig, - ) + if (subject != null && !questionnaireConfig.isReadOnly()) { + val newBundle = bundle.copyBundle(currentQuestionnaireResponse) - withContext(dispatcherProvider.io()) { - executeCql( - subject = subject, - bundle = newBundle, - questionnaire = questionnaire, - questionnaireConfig = questionnaireConfig, - ) - } + val extractedResources = newBundle.entry.map { it.resource } + validateWithFhirValidator(*extractedResources.toTypedArray()) - fhirCarePlanGenerator.conditionallyUpdateResourceStatus( - questionnaireConfig = questionnaireConfig, + generateCarePlan( + subject = subject, + bundle = newBundle, + questionnaireConfig = questionnaireConfig, + ) + + withContext(dispatcherProvider.io()) { + executeCql( subject = subject, bundle = newBundle, + questionnaire = questionnaire, + questionnaireConfig = questionnaireConfig, ) } + + fhirCarePlanGenerator.conditionallyUpdateResourceStatus( + questionnaireConfig = questionnaireConfig, + subject = subject, + bundle = newBundle, + ) } + } - softDeleteResources(questionnaireConfig) + softDeleteResources(questionnaireConfig) - retireUsedQuestionnaireUniqueId(questionnaireConfig, currentQuestionnaireResponse) + retireUsedQuestionnaireUniqueId(questionnaireConfig, currentQuestionnaireResponse) + } - val idTypes = - bundle.entry?.map { IdType(it.resource.resourceType.name, it.resource.logicalId) } - ?: emptyList() - onSuccessfulSubmission(idTypes, currentQuestionnaireResponse) + fun validateWithFhirValidator(vararg resource: Resource) { + if (BuildConfig.DEBUG) { + fhirValidatorRequestHandlerProvider + .get() + .handleResourceValidationRequest( + request = + ResourceValidationRequest( + *resource, + ), + ) } } @@ -406,6 +373,7 @@ constructor( ) { questionnaireResponse.subject = this.logicalId.asReference(subjectType) } + if (questionnaireConfig.isEditable()) { if (resourceType == subjectType) { this.id = questionnaireResponse.subject.extractId() @@ -421,19 +389,23 @@ constructor( .fhirPathExpression val currentResourceIdentifier = - fhirPathDataExtractor.extractValue( - base = this, - expression = fhirPathExpression, - ) + withContext(dispatcherProvider.default()) { + fhirPathDataExtractor.extractValue( + base = this@run, + expression = fhirPathExpression, + ) + } // Search for resource with property value matching extracted value val resource = previouslyExtractedResources.getValue(resourceType).find { val extractedValue = - fhirPathDataExtractor.extractValue( - base = it, - expression = fhirPathExpression, - ) + withContext(dispatcherProvider.default()) { + fhirPathDataExtractor.extractValue( + base = it, + expression = fhirPathExpression, + ) + } extractedValue.isNotEmpty() && extractedValue.equals(currentResourceIdentifier, true) } @@ -448,6 +420,11 @@ constructor( } } + // Set Encounter on QR if the ResourceType is Encounter + if (this.resourceType == ResourceType.Encounter) { + questionnaireResponse.setEncounter(this.asReference()) + } + // Set the Group's Related Entity Location metadata tag on Resource before saving. this.applyRelatedEntityLocationMetaTag(questionnaireConfig, context, subjectType) @@ -594,10 +571,12 @@ constructor( !questionnaireConfig.resourceIdentifier.isNullOrEmpty() && subjectType != null ) { - searchLatestQuestionnaireResponse( + searchQuestionnaireResponse( resourceId = questionnaireConfig.resourceIdentifier!!, resourceType = questionnaireConfig.resourceType ?: subjectType, questionnaireId = questionnaire.logicalId, + encounterId = questionnaireConfig.encounterId, + questionnaireResponseStatus = questionnaireConfig.questionnaireResponseStatus(), ) ?.contained ?.asSequence() @@ -617,7 +596,9 @@ constructor( private fun Bundle.copyBundle(currentQuestionnaireResponse: QuestionnaireResponse): Bundle = this.copy().apply { - addEntry(Bundle.BundleEntryComponent().apply { resource = currentQuestionnaireResponse }) + addEntry( + Bundle.BundleEntryComponent().apply { resource = currentQuestionnaireResponse }, + ) } private fun QuestionnaireResponse.processMetadata( @@ -689,8 +670,8 @@ constructor( StructureMapExtractionContext( transformSupportServices = transformSupportServices, structureMapProvider = { structureMapUrl: String?, _: IWorkerContext -> - structureMapUrl?.substringAfterLast("/")?.let { - defaultRepository.loadResource(it) + structureMapUrl?.substringAfterLast("/")?.let { structureMapId -> + defaultRepository.loadResourceFromCache(structureMapId) } }, ), @@ -724,15 +705,41 @@ constructor( * This function saves [QuestionnaireResponse] as draft if any of the [QuestionnaireResponse.item] * has an answer. */ - fun saveDraftQuestionnaire(questionnaireResponse: QuestionnaireResponse) { + fun saveDraftQuestionnaire( + questionnaireResponse: QuestionnaireResponse, + questionnaireConfig: QuestionnaireConfig, + ) { viewModelScope.launch { + val hasPages = questionnaireResponse.item.any { it.hasItem() } val questionnaireHasAnswer = questionnaireResponse.item.any { - it.answer.any { answerComponent -> answerComponent.hasValue() } + if (!hasPages) { + it.answer.any { answerComponent -> answerComponent.hasValue() } + } else { + questionnaireResponse.item.any { page -> + page.item.any { pageItem -> + pageItem.answer.any { answerComponent -> answerComponent.hasValue() } + } + } + } } + questionnaireResponse.questionnaire = + questionnaireConfig.id.asReference(ResourceType.Questionnaire).reference + if ( + !questionnaireConfig.resourceIdentifier.isNullOrBlank() && + questionnaireConfig.resourceType != null + ) { + questionnaireResponse.subject = + questionnaireConfig.resourceIdentifier!!.asReference( + questionnaireConfig.resourceType!!, + ) + } if (questionnaireHasAnswer) { questionnaireResponse.status = QuestionnaireResponse.QuestionnaireResponseStatus.INPROGRESS - defaultRepository.addOrUpdate(addMandatoryTags = true, resource = questionnaireResponse) + defaultRepository.addOrUpdate( + addMandatoryTags = true, + resource = questionnaireResponse, + ) } } } @@ -759,7 +766,11 @@ constructor( val valueResourceType = param.value.substringBefore("/") val valueResourceId = param.value.substringAfter("/") addOrUpdate( - resource = loadResource(valueResourceId, ResourceType.valueOf(valueResourceType)), + resource = + loadResource( + valueResourceId, + ResourceType.valueOf(valueResourceType), + ), ) } } @@ -767,7 +778,11 @@ constructor( Timber.e("Unable to update resource's _lastUpdated", resourceNotFoundException) } catch (illegalArgumentException: IllegalArgumentException) { Timber.e( - "No enum constant org.hl7.fhir.r4.model.ResourceType.${param.value.substringBefore("/")}", + "No enum constant org.hl7.fhir.r4.model.ResourceType.${ + param.value.substringBefore( + "/", + ) + }", ) } } @@ -788,7 +803,7 @@ constructor( val questionnaireItemsMap = questionnaire.item.associateBy { it.linkId } // Only validate items that are present on both Questionnaire and the QuestionnaireResponse - questionnaireResponse.item.forEach { + questionnaireResponse.copy().item.forEach { if (questionnaireItemsMap.containsKey(it.linkId)) { val questionnaireItem = questionnaireItemsMap.getValue(it.linkId) validQuestionnaireResponseItems.add(it) @@ -796,15 +811,20 @@ constructor( } } - return QuestionnaireResponseValidator.validateQuestionnaireResponse( - questionnaire = Questionnaire().apply { item = validQuestionnaireItems }, - questionnaireResponse = - QuestionnaireResponse().apply { item = validQuestionnaireResponseItems }, - context = context, - ) - .values - .flatten() - .all { it is Valid || it is NotValidated } + return withContext(dispatcherProvider.default()) { + QuestionnaireResponseValidator.validateQuestionnaireResponse( + questionnaire = Questionnaire().apply { item = validQuestionnaireItems }, + questionnaireResponse = + QuestionnaireResponse().apply { + item = validQuestionnaireResponseItems + packRepeatedGroups() + }, + context = context, + ) + .values + .flatten() + .all { it is Valid || it is NotValidated } + } } suspend fun executeCql( @@ -826,7 +846,7 @@ constructor( if (libraryFilters.isNotEmpty()) { defaultRepository.fhirEngine - .search { + .batchedSearch { filter( Resource.RES_ID, *libraryFilters.toTypedArray(), @@ -842,33 +862,44 @@ constructor( null, ) as Parameters - result.parameter.mapNotNull { cqlResultParameterComponent -> - (cqlResultParameterComponent.value ?: cqlResultParameterComponent.resource)?.let { - resultParameterResource -> - if ( - cqlResultParameterComponent.name.equals(OUTPUT_PARAMETER_KEY) && - resultParameterResource.isResource - ) { - defaultRepository.create(true, resultParameterResource as Resource) - } - - if (BuildConfig.DEBUG) { - Timber.d( - "CQL :: Param found: ${cqlResultParameterComponent.name} with value: ${ - getStringRepresentation( - resultParameterResource, - ) - }", - ) + val resources = + result.parameter.mapNotNull { cqlResultParameterComponent -> + (cqlResultParameterComponent.value ?: cqlResultParameterComponent.resource)?.let { + resultParameterResource -> + if (BuildConfig.DEBUG) { + Timber.d( + "CQL :: Param found: ${cqlResultParameterComponent.name} with value: ${ + getStringRepresentation( + resultParameterResource, + ) + }", + ) + } + + if ( + cqlResultParameterComponent.name.equals(OUTPUT_PARAMETER_KEY) && + resultParameterResource.isResource + ) { + defaultRepository.create( + true, + resultParameterResource as Resource, + ) + resultParameterResource + } else { + null + } } } - } + + validateWithFhirValidator(*resources.toTypedArray()) } } } private fun getStringRepresentation(base: Base): String = - if (base.isResource) parser.encodeResourceToString(base as Resource) else base.toString() + if (base.isResource) { + FhirContext.forR4Cached().newJsonParser().encodeResourceToString(base as Resource) + } else base.toString() /** * This function generates CarePlans for the [QuestionnaireResponse.subject] using the configured @@ -883,12 +914,15 @@ constructor( if (planId.isNotEmpty()) { kotlin .runCatching { - fhirCarePlanGenerator.generateOrUpdateCarePlan( - planDefinitionId = planId, - subject = subject, - data = bundle, - generateCarePlanWithWorkflowApi = questionnaireConfig.generateCarePlanWithWorkflowApi, - ) + val carePlan = + fhirCarePlanGenerator.generateOrUpdateCarePlan( + planDefinitionId = planId, + subject = subject, + data = bundle, + generateCarePlanWithWorkflowApi = + questionnaireConfig.generateCarePlanWithWorkflowApi, + ) + carePlan?.let { validateWithFhirValidator(it) } } .onFailure { Timber.e(it) } } @@ -1035,18 +1069,38 @@ constructor( * [resourceId] that was extracted from the [Questionnaire] identified as [questionnaireId]. * Returns null if non is found. */ - suspend fun searchLatestQuestionnaireResponse( + suspend fun searchQuestionnaireResponse( resourceId: String, resourceType: ResourceType, questionnaireId: String, + encounterId: String?, + questionnaireResponseStatus: String? = null, ): QuestionnaireResponse? { val search = Search(ResourceType.QuestionnaireResponse).apply { - filter(QuestionnaireResponse.SUBJECT, { value = "$resourceType/$resourceId" }) + filter( + QuestionnaireResponse.SUBJECT, + { value = resourceId.asReference(resourceType).reference }, + ) filter( QuestionnaireResponse.QUESTIONNAIRE, - { value = "${ResourceType.Questionnaire}/$questionnaireId" }, + { value = questionnaireId.asReference(ResourceType.Questionnaire).reference }, ) + if (!encounterId.isNullOrBlank()) { + filter( + QuestionnaireResponse.ENCOUNTER, + { + value = + encounterId.extractLogicalIdUuid().asReference(ResourceType.Encounter).reference + }, + ) + } + if (!questionnaireResponseStatus.isNullOrBlank()) { + filter( + QuestionnaireResponse.STATUS, + { value = of(questionnaireResponseStatus) }, + ) + } } val questionnaireResponses: List = defaultRepository.search(search) return questionnaireResponses.maxByOrNull { it.meta.lastUpdated } @@ -1059,7 +1113,7 @@ constructor( ): List { return when { subjectResourceType != null && subjectResourceIdentifier != null -> - LinkedList().apply { + mutableListOf().apply { loadResource(subjectResourceType, subjectResourceIdentifier)?.let { add(it) } val actionParametersExcludingSubject = actionParameters.filterNot { @@ -1069,7 +1123,7 @@ constructor( } addAll(retrievePopulationResources(actionParametersExcludingSubject)) } - else -> LinkedList(retrievePopulationResources(actionParameters)) + else -> retrievePopulationResources(actionParameters) } } @@ -1092,20 +1146,46 @@ constructor( launchContexts = launchContextResources.associateBy { it.resourceType.name.lowercase() }, ) + questionnaire.prepopulateWithComputedConfigValues( + questionnaireConfig, + actionParameters, + { resourceDataRulesExecutor.computeResourceDataRules(it, null, emptyMap()) }, + { uniqueIdAssignmentConfig, computedValues -> + // Extract ID from a Group, should be modified in future to support other resources + uniqueIdResource = + defaultRepository.retrieveUniqueIdAssignmentResource( + uniqueIdAssignmentConfig, + computedValues, + ) + + withContext(dispatcherProvider.default()) { + fhirPathDataExtractor.extractValue( + base = uniqueIdResource, + expression = uniqueIdAssignmentConfig.idFhirPathExpression, + ) + } + }, + ) + // Populate questionnaire with latest QuestionnaireResponse val questionnaireResponse = if ( resourceType != null && !resourceIdentifier.isNullOrEmpty() && - questionnaireConfig.isEditable() + (questionnaireConfig.isEditable() || + questionnaireConfig.isReadOnly() || + questionnaireConfig.saveDraft) ) { - searchLatestQuestionnaireResponse( + searchQuestionnaireResponse( resourceId = resourceIdentifier, resourceType = resourceType, questionnaireId = questionnaire.logicalId, + encounterId = questionnaireConfig.encounterId, + questionnaireResponseStatus = questionnaireConfig.questionnaireResponseStatus(), ) ?.let { QuestionnaireResponse().apply { + id = it.id item = it.item.removeUnAnsweredItems() // Clearing the text prompts the SDK to re-process the content, which includes HTML clearText() @@ -1115,14 +1195,52 @@ constructor( null } + // Exclude the configured fields from QR + if (questionnaireResponse != null) { + val exclusionLinkIdsMap: Map = + questionnaireConfig.linkIds + ?.asSequence() + ?.filter { it.type == LinkIdType.PREPOPULATION_EXCLUSION } + ?.associateBy { it.linkId } + ?.mapValues { it.value.type == LinkIdType.PREPOPULATION_EXCLUSION } ?: emptyMap() + + questionnaireResponse.item = + excludePrepopulationFields( + questionnaireResponse.item.toMutableList(), + exclusionLinkIdsMap, + ) + } return Pair(questionnaireResponse, launchContextResources) } + fun excludePrepopulationFields( + items: MutableList, + exclusionMap: Map, + ): MutableList { + val stack = LinkedList>() + stack.push(items) + while (stack.isNotEmpty()) { + val currentItems = stack.pop() + val iterator = currentItems.iterator() + while (iterator.hasNext()) { + val item = iterator.next() + if (exclusionMap.containsKey(item.linkId)) { + iterator.remove() + } else if (item.item.isNotEmpty()) { + stack.push(item.item) + } + } + } + return items + } + private fun List.removeUnAnsweredItems(): List { - return this.filter { it.hasAnswer() || it.item.isNotEmpty() } + return this.asSequence() + .filter { it.hasAnswer() || it.item.isNotEmpty() } .onEach { it.item = it.item.removeUnAnsweredItems() } .filter { it.hasAnswer() || it.item.isNotEmpty() } + .toList() } /** @@ -1139,13 +1257,8 @@ constructor( it.resourceType != null && it.value.isNotEmpty() } - .mapNotNull { - try { - loadResource(it.resourceType!!, it.value) - } catch (resourceNotFoundException: ResourceNotFoundException) { - null - } - } + .distinctBy { "${it.resourceType?.name}${it.value}" } + .mapNotNull { loadResource(it.resourceType!!, it.value) } } /** Load [Resource] of type [ResourceType] for the provided [resourceIdentifier] */ diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/register/RegisterEvent.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/register/RegisterEvent.kt index 3cf37e27938..d63b1afa626 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/register/RegisterEvent.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/register/RegisterEvent.kt @@ -16,8 +16,10 @@ package org.smartregister.fhircore.quest.ui.register +import org.smartregister.fhircore.quest.ui.shared.models.SearchQuery + sealed class RegisterEvent { - data class SearchRegister(val searchText: String = "") : RegisterEvent() + data class SearchRegister(val searchQuery: SearchQuery = SearchQuery.emptyText) : RegisterEvent() data object MoveToNextPage : RegisterEvent() diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/register/RegisterFragment.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/register/RegisterFragment.kt index 5a289f6565a..9e6aff8fb8d 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/register/RegisterFragment.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/register/RegisterFragment.kt @@ -24,7 +24,6 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.padding import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.Scaffold -import androidx.compose.material.SnackbarDuration import androidx.compose.material.rememberScaffoldState import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -53,20 +52,20 @@ import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import org.hl7.fhir.r4.model.QuestionnaireResponse -import org.smartregister.fhircore.engine.R -import org.smartregister.fhircore.engine.domain.model.SnackBarMessageConfig import org.smartregister.fhircore.engine.sync.OnSyncListener import org.smartregister.fhircore.engine.sync.SyncListenerManager import org.smartregister.fhircore.engine.ui.theme.AppTheme -import org.smartregister.fhircore.engine.util.SharedPreferencesHelper import org.smartregister.fhircore.quest.event.AppEvent import org.smartregister.fhircore.quest.event.EventBus import org.smartregister.fhircore.quest.navigation.MainNavigationScreen +import org.smartregister.fhircore.quest.ui.main.AppMainEvent import org.smartregister.fhircore.quest.ui.main.AppMainUiState import org.smartregister.fhircore.quest.ui.main.AppMainViewModel import org.smartregister.fhircore.quest.ui.main.components.AppDrawer import org.smartregister.fhircore.quest.ui.shared.components.SnackBarMessage import org.smartregister.fhircore.quest.ui.shared.models.QuestionnaireSubmission +import org.smartregister.fhircore.quest.ui.shared.models.SearchQuery +import org.smartregister.fhircore.quest.ui.shared.viewmodels.SearchViewModel import org.smartregister.fhircore.quest.util.extensions.handleClickEvent import org.smartregister.fhircore.quest.util.extensions.hookSnackBar import org.smartregister.fhircore.quest.util.extensions.rememberLifecycleEvent @@ -78,26 +77,23 @@ class RegisterFragment : Fragment(), OnSyncListener { @Inject lateinit var syncListenerManager: SyncListenerManager @Inject lateinit var eventBus: EventBus - private val appMainViewModel by activityViewModels() private val registerFragmentArgs by navArgs() private val registerViewModel by viewModels() + private val appMainViewModel by activityViewModels() + private val searchViewModel by activityViewModels() override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?, ): View { - appMainViewModel.retrieveIconsAsBitmap() - with(registerFragmentArgs) { - lifecycleScope.launchWhenCreated { - registerViewModel.retrieveRegisterUiState( - registerId = registerId, - screenTitle = screenTitle, - params = params, - clearCache = false, - ) - } + registerViewModel.retrieveRegisterUiState( + registerId = registerId, + screenTitle = screenTitle, + params = params, + clearCache = false, + ) } return ComposeView(requireContext()).apply { setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) @@ -128,20 +124,29 @@ class RegisterFragment : Fragment(), OnSyncListener { AppTheme { val pagingItems = - registerViewModel.paginatedRegisterData + registerViewModel.registerData .collectAsState(emptyFlow()) .value .collectAsLazyPagingItems() - // Register screen provides access to the side navigation + Scaffold( drawerGesturesEnabled = scaffoldState.drawerState.isOpen, scaffoldState = scaffoldState, drawerContent = { AppDrawer( appUiState = uiState, + appDrawerUIState = appMainViewModel.appDrawerUiState.value, openDrawer = openDrawer, - onSideMenuClick = appMainViewModel::onEvent, + onSideMenuClick = { + if (it is AppMainEvent.TriggerWorkflow) { + searchViewModel.searchQuery.value = SearchQuery.emptyText + } + appMainViewModel.onEvent(it) + }, navController = findNavController(), + unSyncedResourceCount = appMainViewModel.unSyncedResourcesCount, + onCountUnSyncedResources = appMainViewModel::updateUnSyncedResourcesCount, + decodeImage = { registerViewModel.getImageBitmap(it) }, ) }, bottomBar = { @@ -165,11 +170,15 @@ class RegisterFragment : Fragment(), OnSyncListener { openDrawer = openDrawer, onEvent = registerViewModel::onEvent, registerUiState = registerViewModel.registerUiState.value, - searchText = registerViewModel.searchText, + registerUiCountState = registerViewModel.registerUiCountState.value, + appDrawerUIState = appMainViewModel.appDrawerUiState.value, + onAppMainEvent = { appMainViewModel.onEvent(it) }, + searchQuery = searchViewModel.searchQuery, currentPage = registerViewModel.currentPage, pagingItems = pagingItems, navController = findNavController(), toolBarHomeNavigation = registerFragmentArgs.toolBarHomeNavigation, + decodeImage = { registerViewModel.getImageBitmap(it) }, ) } } @@ -183,56 +192,31 @@ class RegisterFragment : Fragment(), OnSyncListener { syncListenerManager.registerSyncListener(this, lifecycle) } - override fun onStop() { - super.onStop() - registerViewModel.searchText.value = "" // Clear the search term - } - override fun onSync(syncJobStatus: CurrentSyncJobStatus) { when (syncJobStatus) { - is CurrentSyncJobStatus.Running -> - if (syncJobStatus.inProgressSyncJob is SyncJobStatus.Started) { + is CurrentSyncJobStatus.Running -> { + if (syncJobStatus.inProgressSyncJob is SyncJobStatus.InProgress) { + val inProgressSyncJob = syncJobStatus.inProgressSyncJob as SyncJobStatus.InProgress + val isSyncUpload = inProgressSyncJob.syncOperation == SyncOperation.UPLOAD + val progressPercentage = appMainViewModel.calculatePercentageProgress(inProgressSyncJob) lifecycleScope.launch { - registerViewModel.emitSnackBarState( - SnackBarMessageConfig(message = getString(R.string.syncing)), + appMainViewModel.updateAppDrawerUIState( + isSyncUpload = isSyncUpload, + currentSyncJobStatus = syncJobStatus, + percentageProgress = progressPercentage, ) } - } else { - emitPercentageProgress( - syncJobStatus.inProgressSyncJob as SyncJobStatus.InProgress, - (syncJobStatus.inProgressSyncJob as SyncJobStatus.InProgress).syncOperation == - SyncOperation.UPLOAD, - ) } + } is CurrentSyncJobStatus.Succeeded -> { refreshRegisterData() - lifecycleScope.launch { - registerViewModel.emitSnackBarState( - SnackBarMessageConfig( - message = getString(R.string.sync_completed), - actionLabel = getString(R.string.ok).uppercase(), - duration = SnackbarDuration.Long, - ), - ) - } + appMainViewModel.updateAppDrawerUIState(currentSyncJobStatus = syncJobStatus) } is CurrentSyncJobStatus.Failed -> { refreshRegisterData() - syncJobStatus.toString() - // Show error message in snackBar message - lifecycleScope.launch { - registerViewModel.emitSnackBarState( - SnackBarMessageConfig( - message = getString(R.string.sync_completed_with_errors), - duration = SnackbarDuration.Long, - actionLabel = getString(R.string.ok).uppercase(), - ), - ) - } - } - else -> { - // Do nothing + appMainViewModel.updateAppDrawerUIState(currentSyncJobStatus = syncJobStatus) } + else -> appMainViewModel.updateAppDrawerUIState(currentSyncJobStatus = syncJobStatus) } } @@ -243,16 +227,16 @@ class RegisterFragment : Fragment(), OnSyncListener { updateRegisterFilterState(registerId, questionnaireResponse) } - pagesDataCache.clear() - retrieveRegisterUiState( registerId = registerId, screenTitle = screenTitle, params = params, - clearCache = false, + clearCache = true, ) } } + + appMainViewModel.updateUnSyncedResourcesCount() } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { @@ -265,7 +249,7 @@ class RegisterFragment : Fragment(), OnSyncListener { when (appEvent) { is AppEvent.OnSubmitQuestionnaire -> handleQuestionnaireSubmission(appEvent.questionnaireSubmission) - is AppEvent.RefreshRegisterData -> { + is AppEvent.RefreshData -> { appMainViewModel.countRegisterData() refreshRegisterData() } @@ -274,6 +258,24 @@ class RegisterFragment : Fragment(), OnSyncListener { .launchIn(lifecycleScope) } } + + appMainViewModel.resetRegisterFilters.observe(viewLifecycleOwner) { resetFilters -> + if (resetFilters) { + registerViewModel.registerFilterState.value = RegisterFilterState() + refreshRegisterData() + appMainViewModel.resetRegisterFilters.value = false + } + } + } + + override fun onPause() { + super.onPause() + appMainViewModel.updateAppDrawerUIState(false, null, 0) + } + + override fun onDestroy() { + super.onDestroy() + appMainViewModel.updateAppDrawerUIState(false, null, 0) } suspend fun handleQuestionnaireSubmission(questionnaireSubmission: QuestionnaireSubmission) { @@ -297,49 +299,6 @@ class RegisterFragment : Fragment(), OnSyncListener { } } - fun emitPercentageProgress( - progressSyncJobStatus: SyncJobStatus.InProgress, - isUploadSync: Boolean, - ) { - lifecycleScope.launch { - val percentageProgress: Int = calculateActualPercentageProgress(progressSyncJobStatus) - registerViewModel.emitPercentageProgressState(percentageProgress, isUploadSync) - } - } - - private fun getSyncProgress(completed: Int, total: Int) = - completed * 100 / if (total > 0) total else 1 - - private fun calculateActualPercentageProgress( - progressSyncJobStatus: SyncJobStatus.InProgress, - ): Int { - val totalRecordsOverall = - registerViewModel.sharedPreferencesHelper.read( - SharedPreferencesHelper.PREFS_SYNC_PROGRESS_TOTAL + - progressSyncJobStatus.syncOperation.name, - 1L, - ) - val isProgressTotalLess = progressSyncJobStatus.total <= totalRecordsOverall - val currentProgress: Int - val currentTotalRecords = - if (isProgressTotalLess) { - currentProgress = - totalRecordsOverall.toInt() - progressSyncJobStatus.total + - progressSyncJobStatus.completed - totalRecordsOverall.toInt() - } else { - registerViewModel.sharedPreferencesHelper.write( - SharedPreferencesHelper.PREFS_SYNC_PROGRESS_TOTAL + - progressSyncJobStatus.syncOperation.name, - progressSyncJobStatus.total.toLong(), - ) - currentProgress = progressSyncJobStatus.completed - progressSyncJobStatus.total - } - - return getSyncProgress(currentProgress, currentTotalRecords) - } - companion object { const val REGISTER_SCREEN_BOX_TAG = "fragmentRegisterScreenTestTag" } diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/register/RegisterScreen.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/register/RegisterScreen.kt index b182395bf0d..4db108fa4f7 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/register/RegisterScreen.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/register/RegisterScreen.kt @@ -16,10 +16,13 @@ package org.smartregister.fhircore.quest.ui.register +import android.graphics.Bitmap +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.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.rememberLazyListState @@ -31,7 +34,9 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState -import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -42,19 +47,33 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.navigation.NavController +import androidx.navigation.compose.rememberNavController +import androidx.paging.PagingData import androidx.paging.compose.LazyPagingItems +import androidx.paging.compose.collectAsLazyPagingItems +import kotlinx.coroutines.flow.flowOf +import org.hl7.fhir.r4.model.ResourceType import org.smartregister.fhircore.engine.R -import org.smartregister.fhircore.engine.configuration.navigation.NavigationMenuConfig +import org.smartregister.fhircore.engine.configuration.ConfigType import org.smartregister.fhircore.engine.configuration.register.NoResultsConfig +import org.smartregister.fhircore.engine.configuration.register.RegisterConfiguration +import org.smartregister.fhircore.engine.domain.model.FhirResourceConfig +import org.smartregister.fhircore.engine.domain.model.ResourceConfig import org.smartregister.fhircore.engine.domain.model.ResourceData import org.smartregister.fhircore.engine.domain.model.ToolBarHomeNavigation import org.smartregister.fhircore.engine.ui.components.register.LoaderDialog import org.smartregister.fhircore.engine.ui.components.register.RegisterHeader +import org.smartregister.fhircore.engine.ui.theme.AppTheme import org.smartregister.fhircore.engine.util.annotation.PreviewWithBackgroundExcludeGenerated import org.smartregister.fhircore.quest.event.ToolbarClickEvent +import org.smartregister.fhircore.quest.ui.main.AppMainEvent import org.smartregister.fhircore.quest.ui.main.components.TopScreenSection import org.smartregister.fhircore.quest.ui.register.components.RegisterCardList import org.smartregister.fhircore.quest.ui.shared.components.ExtendedFab +import org.smartregister.fhircore.quest.ui.shared.components.SyncBottomBar +import org.smartregister.fhircore.quest.ui.shared.models.AppDrawerUIState +import org.smartregister.fhircore.quest.ui.shared.models.SearchMode +import org.smartregister.fhircore.quest.ui.shared.models.SearchQuery import org.smartregister.fhircore.quest.util.extensions.handleClickEvent const val NO_REGISTER_VIEW_COLUMN_TEST_TAG = "noRegisterViewColumnTestTag" @@ -74,39 +93,44 @@ fun RegisterScreen( openDrawer: (Boolean) -> Unit, onEvent: (RegisterEvent) -> Unit, registerUiState: RegisterUiState, - searchText: MutableState, + registerUiCountState: RegisterUiCountState, + appDrawerUIState: AppDrawerUIState = AppDrawerUIState(), + onAppMainEvent: (AppMainEvent) -> Unit, + searchQuery: MutableState, currentPage: MutableState, pagingItems: LazyPagingItems, navController: NavController, toolBarHomeNavigation: ToolBarHomeNavigation = ToolBarHomeNavigation.OPEN_DRAWER, + decodeImage: ((String) -> Bitmap?)?, ) { val lazyListState: LazyListState = rememberLazyListState() - Scaffold( topBar = { Column { - /* - * Top section has toolbar and a results counts view - * by default isSearchBarVisible is visible - * */ val filterActions = registerUiState.registerConfiguration?.registerFilter?.dataFilterActions TopScreenSection( modifier = modifier.testTag(TOP_REGISTER_SCREEN_TEST_TAG), title = registerUiState.screenTitle.ifEmpty { registerUiState.registerConfiguration?.topScreenSection?.title ?: "" - }, // backward compatibility for screen title - searchText = searchText.value, - filteredRecordsCount = registerUiState.filteredRecordsCount, + }, + searchQuery = searchQuery.value, + filteredRecordsCount = registerUiCountState.filteredRecordsCount, isSearchBarVisible = registerUiState.registerConfiguration?.searchBar?.visible ?: true, searchPlaceholder = registerUiState.registerConfiguration?.searchBar?.display, + placeholderColor = registerUiState.registerConfiguration?.searchBar?.placeholderColor, + showSearchByQrCode = registerUiState.registerConfiguration?.showSearchByQrCode ?: false, toolBarHomeNavigation = toolBarHomeNavigation, - onSearchTextChanged = { searchText -> - onEvent(RegisterEvent.SearchRegister(searchText = searchText)) + onSearchTextChanged = { uiSearchQuery, performSearchOnValueChanged -> + searchQuery.value = uiSearchQuery + if (performSearchOnValueChanged) { + onEvent(RegisterEvent.SearchRegister(searchQuery = uiSearchQuery)) + } }, isFilterIconEnabled = filterActions?.isNotEmpty() ?: false, topScreenSection = registerUiState.registerConfiguration?.topScreenSection, navController = navController, + decodeImage = decodeImage, ) { event -> when (event) { ToolbarClickEvent.Navigate -> @@ -118,15 +142,10 @@ fun RegisterScreen( onEvent(RegisterEvent.ResetFilterRecordsCount) filterActions?.handleClickEvent(navController) } - is ToolbarClickEvent.Actions -> { - event.actions.handleClickEvent( - navController = navController, - ) - } + is ToolbarClickEvent.Actions -> event.actions.handleClickEvent(navController) } } - // Only show counter during search - if (searchText.value.isNotEmpty()) RegisterHeader(resultCount = pagingItems.itemCount) + if (!searchQuery.value.isBlank()) RegisterHeader(resultCount = pagingItems.itemCount) } }, floatingActionButton = { @@ -137,42 +156,78 @@ fun RegisterScreen( fabActions = fabActions, navController = navController, lazyListState = lazyListState, + decodeImage = decodeImage, ) } }, + bottomBar = { + SyncBottomBar( + isFirstTimeSync = registerUiState.isFirstTimeSync, + appDrawerUIState = appDrawerUIState, + onAppMainEvent = onAppMainEvent, + openDrawer = openDrawer, + ) + }, ) { innerPadding -> Box(modifier = modifier.padding(innerPadding)) { if (registerUiState.isFirstTimeSync) { - val isSyncUpload = registerUiState.isSyncUpload.collectAsState(initial = false).value LoaderDialog( modifier = modifier.testTag(FIRST_TIME_SYNC_DIALOG), - percentageProgressFlow = registerUiState.progressPercentage, + percentageProgressFlow = flowOf(appDrawerUIState.percentageProgress ?: 0), dialogMessage = stringResource( - id = if (isSyncUpload) R.string.syncing_up else R.string.syncing_down, + id = + if (appDrawerUIState.isSyncUpload == true) { + R.string.syncing_up + } else { + R.string.syncing_down + }, ), showPercentageProgress = true, ) } - if ( - registerUiState.totalRecordsCount > 0 && - registerUiState.registerConfiguration?.registerCard != null - ) { - RegisterCardList( - modifier = modifier.testTag(REGISTER_CARD_TEST_TAG), - registerCardConfig = registerUiState.registerConfiguration.registerCard, - pagingItems = pagingItems, - navController = navController, - lazyListState = lazyListState, - onEvent = onEvent, - registerUiState = registerUiState, - currentPage = currentPage, - showPagination = searchText.value.isEmpty(), - ) - } else { - registerUiState.registerConfiguration?.noResults?.let { noResultConfig -> - NoRegisterDataView(modifier = modifier, noResults = noResultConfig) { - noResultConfig.actionButton?.actions?.handleClickEvent(navController) + + Column(modifier = Modifier.fillMaxSize()) { + Box( + modifier = Modifier.fillMaxWidth().background(Color.White).weight(1f), + ) { + if (registerUiState.registerConfiguration?.registerCard != null) { + RegisterCardList( + modifier = modifier.fillMaxSize().testTag(REGISTER_CARD_TEST_TAG), + registerCardConfig = registerUiState.registerConfiguration.registerCard, + pagingItems = pagingItems, + navController = navController, + lazyListState = lazyListState, + onEvent = onEvent, + registerUiState = registerUiState, + registerUiCountState = registerUiCountState, + currentPage = currentPage, + showPagination = + !registerUiState.registerConfiguration.infiniteScroll && + searchQuery.value.isBlank(), + onSearchByQrSingleResultAction = { resourceData -> + if ( + !searchQuery.value.isBlank() && searchQuery.value.mode == SearchMode.QrCodeScan + ) { + registerUiState.registerConfiguration.onSearchByQrSingleResultValidActions + ?.apply { + handleClickEvent( + navController, + resourceData, + context = navController.context, + ) + searchQuery.value = searchQuery.value.copy(mode = SearchMode.KeyboardInput) + } + } + }, + decodeImage = decodeImage, + ) + } else { + registerUiState.registerConfiguration?.noResults?.let { noResultConfig -> + NoRegisterDataView(modifier = modifier, noResults = noResultConfig) { + noResultConfig.actionButton?.actions?.handleClickEvent(navController) + } + } } } } @@ -224,15 +279,51 @@ fun NoRegisterDataView( } } -@PreviewWithBackgroundExcludeGenerated @Composable -private fun PreviewNoRegistersView() { - NoRegisterDataView( - noResults = - NoResultsConfig( - title = "Title", - message = "This is message", - actionButton = NavigationMenuConfig(display = "Button Text", id = "1"), - ), - ) {} +@PreviewWithBackgroundExcludeGenerated +fun RegisterScreenWithDataPreview() { + val registerUiState = + RegisterUiState( + screenTitle = "Sample Register", + isFirstTimeSync = false, + registerConfiguration = + RegisterConfiguration( + "app", + configType = ConfigType.Register.name, + id = "register", + fhirResource = + FhirResourceConfig(baseResource = ResourceConfig(resource = ResourceType.Patient)), + ), + registerId = "register101", + progressPercentage = flowOf(0), + isSyncUpload = flowOf(false), + params = emptyList(), + ) + + val registerUiCountState = + RegisterUiCountState( + totalRecordsCount = 1, + filteredRecordsCount = 0, + pagesCount = 1, + ) + val searchText = remember { mutableStateOf(SearchQuery.emptyText) } + val currentPage = remember { mutableIntStateOf(0) } + val data = listOf(ResourceData("1", ResourceType.Patient, emptyMap())) + val pagingItems = flowOf(PagingData.from(data)).collectAsLazyPagingItems() + + AppTheme { + RegisterScreen( + modifier = Modifier, + openDrawer = {}, + onEvent = {}, + registerUiState = registerUiState, + registerUiCountState = registerUiCountState, + onAppMainEvent = {}, + searchQuery = searchText, + currentPage = currentPage, + pagingItems = pagingItems, + navController = rememberNavController(), + decodeImage = null, + ) + } } diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/launcher/GeoWidgetEvent.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/register/RegisterUiCountState.kt similarity index 76% rename from android/quest/src/main/java/org/smartregister/fhircore/quest/ui/launcher/GeoWidgetEvent.kt rename to android/quest/src/main/java/org/smartregister/fhircore/quest/ui/register/RegisterUiCountState.kt index a1af519bab8..d319d0de2c1 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/launcher/GeoWidgetEvent.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/register/RegisterUiCountState.kt @@ -14,8 +14,10 @@ * limitations under the License. */ -package org.smartregister.fhircore.quest.ui.launcher +package org.smartregister.fhircore.quest.ui.register -sealed class GeoWidgetEvent { - data class SearchServicePoints(val searchText: String = "") : GeoWidgetEvent() -} +data class RegisterUiCountState( + val totalRecordsCount: Long = 0, + val filteredRecordsCount: Long? = null, + val pagesCount: Int = 1, +) diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/register/RegisterUiState.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/register/RegisterUiState.kt index d43914e7f20..eabfe3e00d7 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/register/RegisterUiState.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/register/RegisterUiState.kt @@ -16,19 +16,19 @@ package org.smartregister.fhircore.quest.ui.register +import com.google.android.fhir.sync.CurrentSyncJobStatus import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flowOf import org.smartregister.fhircore.engine.configuration.register.RegisterConfiguration +import org.smartregister.fhircore.engine.domain.model.ActionParameter data class RegisterUiState( val screenTitle: String = "", val isFirstTimeSync: Boolean = false, val registerConfiguration: RegisterConfiguration? = null, val registerId: String = "", - val totalRecordsCount: Long = 0, - val filteredRecordsCount: Long = 0, - val pagesCount: Int = 1, val progressPercentage: Flow = flowOf(0), val isSyncUpload: Flow = flowOf(false), - val params: Map = emptyMap(), + val currentSyncJobStatus: Flow = flowOf(null), + val params: List = emptyList(), ) diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/register/RegisterViewModel.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/register/RegisterViewModel.kt index 976800ab4b8..ec82084b82b 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/register/RegisterViewModel.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/register/RegisterViewModel.kt @@ -16,9 +16,12 @@ package org.smartregister.fhircore.quest.ui.register +import android.graphics.Bitmap +import androidx.annotation.VisibleForTesting import androidx.compose.runtime.MutableState import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -27,19 +30,37 @@ import androidx.paging.PagingConfig import androidx.paging.PagingData import androidx.paging.cachedIn import androidx.paging.filter +import com.google.android.fhir.sync.CurrentSyncJobStatus import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import kotlin.math.ceil +import kotlin.time.Duration.Companion.milliseconds +import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import org.hl7.fhir.r4.model.CodeType +import org.hl7.fhir.r4.model.CodeableConcept import org.hl7.fhir.r4.model.Coding +import org.hl7.fhir.r4.model.DateTimeType +import org.hl7.fhir.r4.model.DateType +import org.hl7.fhir.r4.model.DecimalType import org.hl7.fhir.r4.model.Enumerations.DataType +import org.hl7.fhir.r4.model.IntegerType +import org.hl7.fhir.r4.model.Quantity import org.hl7.fhir.r4.model.QuestionnaireResponse +import org.hl7.fhir.r4.model.QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent +import org.hl7.fhir.r4.model.Reference +import org.hl7.fhir.r4.model.StringType +import org.hl7.fhir.r4.model.TimeType +import org.hl7.fhir.r4.model.UriType +import org.hl7.fhir.r4.model.UrlType import org.smartregister.fhircore.engine.configuration.ConfigType import org.smartregister.fhircore.engine.configuration.ConfigurationRegistry import org.smartregister.fhircore.engine.configuration.app.ApplicationConfiguration @@ -51,6 +72,7 @@ import org.smartregister.fhircore.engine.domain.model.Code import org.smartregister.fhircore.engine.domain.model.DataQuery import org.smartregister.fhircore.engine.domain.model.FhirResourceConfig import org.smartregister.fhircore.engine.domain.model.FilterCriterionConfig +import org.smartregister.fhircore.engine.domain.model.NestedSearchConfig import org.smartregister.fhircore.engine.domain.model.ResourceConfig import org.smartregister.fhircore.engine.domain.model.ResourceData import org.smartregister.fhircore.engine.domain.model.SnackBarMessageConfig @@ -61,9 +83,12 @@ import org.smartregister.fhircore.engine.util.SharedPreferencesHelper import org.smartregister.fhircore.engine.util.extension.encodeJson import org.smartregister.fhircore.quest.data.register.RegisterPagingSource import org.smartregister.fhircore.quest.data.register.model.RegisterPagingSourceState +import org.smartregister.fhircore.quest.ui.shared.models.SearchQuery +import org.smartregister.fhircore.quest.util.extensions.referenceToBitmap import org.smartregister.fhircore.quest.util.extensions.toParamDataMap import timber.log.Timber +@OptIn(FlowPreview::class) @HiltViewModel class RegisterViewModel @Inject @@ -71,29 +96,53 @@ constructor( val registerRepository: RegisterRepository, val configurationRegistry: ConfigurationRegistry, val sharedPreferencesHelper: SharedPreferencesHelper, - val dispatcherProvider: DispatcherProvider, val resourceDataRulesExecutor: ResourceDataRulesExecutor, + val dispatcherProvider: DispatcherProvider, ) : ViewModel() { private val _snackBarStateFlow = MutableSharedFlow() val snackBarStateFlow = _snackBarStateFlow.asSharedFlow() val registerUiState = mutableStateOf(RegisterUiState()) + val registerUiCountState = mutableStateOf(RegisterUiCountState()) val currentPage: MutableState = mutableIntStateOf(0) - val searchText = mutableStateOf("") - val paginatedRegisterData: MutableStateFlow>> = - MutableStateFlow(emptyFlow()) + val registerData: MutableStateFlow>> = MutableStateFlow(emptyFlow()) val pagesDataCache = mutableMapOf>>() val registerFilterState = mutableStateOf(RegisterFilterState()) private val _totalRecordsCount = mutableLongStateOf(0L) private val _filteredRecordsCount = mutableLongStateOf(-1L) private lateinit var registerConfiguration: RegisterConfiguration - private var allPatientRegisterData: Flow>? = null + private var completeRegisterData: Flow>? = null private val _percentageProgress: MutableSharedFlow = MutableSharedFlow(0) private val _isUploadSync: MutableSharedFlow = MutableSharedFlow(0) - + private val _currentSyncJobStatusFlow: MutableSharedFlow = + MutableSharedFlow(0) val applicationConfiguration: ApplicationConfiguration by lazy { configurationRegistry.retrieveConfiguration(ConfigType.Application, paramsMap = emptyMap()) } + private val decodedImageMap = mutableStateMapOf() + + private val _searchQueryFlow: MutableSharedFlow = MutableSharedFlow() + + @VisibleForTesting + val debouncedSearchQueryFlow = + _searchQueryFlow.debounce { + val searchText = it.query + when (searchText.length) { + 0 -> 2.milliseconds // when search is cleared + 1, + 2, -> 1000.milliseconds + else -> 500.milliseconds + } + } + + init { + viewModelScope.launch { + debouncedSearchQueryFlow.collect { + val registerId = registerUiState.value.registerId + performSearch(registerId, it) + } + } + } /** * This function paginates the register data. An optional [clearCache] resets the data in the @@ -107,9 +156,9 @@ constructor( ) { if (clearCache) { pagesDataCache.clear() - allPatientRegisterData = null + completeRegisterData = null } - paginatedRegisterData.value = + registerData.value = pagesDataCache.getOrPut(currentPage.value) { getPager(registerId, loadAll).flow.cachedIn(viewModelScope) } @@ -118,7 +167,7 @@ constructor( private fun getPager(registerId: String, loadAll: Boolean = false): Pager { val currentRegisterConfigs = retrieveRegisterConfiguration(registerId) val ruleConfigs = currentRegisterConfigs.registerCard.rules - val pageSize = currentRegisterConfigs.pageSize // Default 10 + val pageSize = currentRegisterConfigs.pageSize return Pager( config = PagingConfig(pageSize = pageSize, enablePlaceholders = false), @@ -128,7 +177,7 @@ constructor( resourceDataRulesExecutor = resourceDataRulesExecutor, ruleConfigs = ruleConfigs, fhirResourceConfig = registerFilterState.value.fhirResourceConfig, - actionParameters = registerUiState.value.params, + actionParameters = registerUiState.value.params.toTypedArray().toParamDataMap(), ) .apply { setPatientPagingSourceState( @@ -150,60 +199,221 @@ constructor( // Ensures register configuration is initialized once if (!::registerConfiguration.isInitialized) { registerConfiguration = - configurationRegistry.retrieveConfiguration(ConfigType.Register, registerId, paramMap) + configurationRegistry.retrieveConfiguration( + ConfigType.Register, + registerId, + paramMap, + ) } return registerConfiguration } - private fun retrieveAllPatientRegisterData(registerId: String): Flow> { - // Ensure that we only initialize this flow once - if (allPatientRegisterData == null) { - allPatientRegisterData = getPager(registerId, true).flow.cachedIn(viewModelScope) + private fun retrieveCompleteRegisterData( + registerId: String, + forceRefresh: Boolean, + ): Flow> { + if (completeRegisterData == null || forceRefresh) { + completeRegisterData = getPager(registerId, true).flow.cachedIn(viewModelScope) } - return allPatientRegisterData!! + return completeRegisterData!! } - fun onEvent(event: RegisterEvent) = + fun onEvent(event: RegisterEvent) { + val registerId = registerUiState.value.registerId when (event) { // Search using name or patient logicalId or identifier. Modify to add more search params is RegisterEvent.SearchRegister -> { - searchText.value = event.searchText - if (event.searchText.isEmpty()) { - paginateRegisterData(registerUiState.value.registerId) - } else { - filterRegisterData(event) - } + viewModelScope.launch { _searchQueryFlow.emit(event.searchQuery) } } is RegisterEvent.MoveToNextPage -> { currentPage.value = currentPage.value.plus(1) - paginateRegisterData(registerUiState.value.registerId) + paginateRegisterData(registerId) } is RegisterEvent.MoveToPreviousPage -> { currentPage.value.let { if (it > 0) currentPage.value = it.minus(1) } - paginateRegisterData(registerUiState.value.registerId) + paginateRegisterData(registerId) } RegisterEvent.ResetFilterRecordsCount -> _filteredRecordsCount.longValue = -1 } + } + + @VisibleForTesting + fun performSearch(registerId: String, searchQuery: SearchQuery) { + if (searchQuery.isBlank()) { + val regConfig = retrieveRegisterConfiguration(registerId) + val searchByDynamicQueries = !regConfig.searchBar?.dataFilterFields.isNullOrEmpty() + if (searchByDynamicQueries) { + registerFilterState.value = RegisterFilterState() // Reset queries + } + when { + regConfig.infiniteScroll -> + registerData.value = retrieveCompleteRegisterData(registerId, searchByDynamicQueries) + else -> + retrieveRegisterUiState( + registerId = registerId, + screenTitle = registerUiState.value.screenTitle, + params = registerUiState.value.params.toTypedArray(), + clearCache = searchByDynamicQueries, + ) + } + } else { + filterRegisterData(searchQuery.query) + } + } - fun filterRegisterData(event: RegisterEvent.SearchRegister) { + fun filterRegisterData(searchText: String) { val searchBar = registerUiState.value.registerConfiguration?.searchBar - // computedRules (names of pre-computed rules) must be provided for search to work. - if (searchBar?.computedRules != null) { - paginatedRegisterData.value = - retrieveAllPatientRegisterData(registerUiState.value.registerId).map { - pagingData: PagingData -> - pagingData.filter { resourceData: ResourceData -> - searchBar.computedRules!!.any { ruleName -> - // if ruleName not found in map return {-1}; check always return false hence no data - val value = resourceData.computedValuesMap[ruleName]?.toString() ?: "{-1}" - value.contains(other = event.searchText, ignoreCase = true) + val registerId = registerUiState.value.registerId + if (!searchBar?.dataFilterFields.isNullOrEmpty()) { + val dataFilterFields = searchBar?.dataFilterFields + updateRegisterFilterState( + registerId = registerId, + questionnaireResponse = + constructSearchQuestionnaireResponse( + searchText = searchText, + dataFilterFields = searchBar?.dataFilterFields ?: emptyList(), + ), + dataFilterFields = dataFilterFields, + ) + paginateRegisterData(registerId = registerId, loadAll = true, clearCache = true) + } else if (searchBar?.computedRules != null) { + registerData.value = + retrieveCompleteRegisterData( + registerId = registerId, + forceRefresh = false, + ) + .map { pagingData: PagingData, + -> + pagingData.filter { resourceData: ResourceData -> + searchBar.computedRules!!.any { ruleName -> + // if ruleName not found in map return {-1}; check always return false hence no data + val value = resourceData.computedValuesMap[ruleName]?.toString() ?: "{-1}" + value.contains(other = searchText, ignoreCase = true) + } } } - } } } - fun updateRegisterFilterState(registerId: String, questionnaireResponse: QuestionnaireResponse) { + private fun constructSearchQuestionnaireResponse( + searchText: String, + dataFilterFields: List, + ): QuestionnaireResponse { + val questionnaireResponse = QuestionnaireResponse() + dataFilterFields.forEach { + it.dataQueries.mapToQRItems(questionnaireResponse, searchText) + it.nestedSearchResources?.forEach { nestedSearchConfig -> + nestedSearchConfig.dataQueries.mapToQRItems(questionnaireResponse, searchText) + } + } + return questionnaireResponse + } + + private fun List?.mapToQRItems( + questionnaireResponse: QuestionnaireResponse, + searchText: String, + ) { + this?.forEach { dataQuery -> + dataQuery.filterCriteria.map { filterCriterionConfig -> + questionnaireResponse.addItem( + QuestionnaireResponse.QuestionnaireResponseItemComponent( + StringType(filterCriterionConfig.dataFilterLinkId), + ) + .apply { + when (filterCriterionConfig.dataType) { + DataType.QUANTITY -> + addAnswer( + QuestionnaireResponseItemAnswerComponent().apply { + value = Quantity(searchText.toDouble()) + }, + ) + DataType.DATETIME -> + addAnswer( + QuestionnaireResponseItemAnswerComponent().apply { + value = DateTimeType(searchText) + }, + ) + DataType.DATE -> + addAnswer( + QuestionnaireResponseItemAnswerComponent().apply { + value = DateType(searchText) + }, + ) + DataType.TIME -> + addAnswer( + QuestionnaireResponseItemAnswerComponent().apply { + value = TimeType(searchText) + }, + ) + DataType.DECIMAL -> + addAnswer( + QuestionnaireResponseItemAnswerComponent().apply { + value = DecimalType(searchText) + }, + ) + DataType.INTEGER -> + addAnswer( + QuestionnaireResponseItemAnswerComponent().apply { + value = IntegerType(searchText) + }, + ) + DataType.STRING -> + addAnswer( + QuestionnaireResponseItemAnswerComponent().apply { + value = StringType(searchText) + }, + ) + DataType.URI -> + addAnswer( + QuestionnaireResponseItemAnswerComponent().apply { + value = UriType(searchText) + }, + ) + DataType.URL -> + addAnswer( + QuestionnaireResponseItemAnswerComponent().apply { + value = UrlType(searchText) + }, + ) + DataType.REFERENCE -> + addAnswer( + QuestionnaireResponseItemAnswerComponent().apply { + value = Reference(searchText) + }, + ) + DataType.CODING -> + addAnswer( + QuestionnaireResponseItemAnswerComponent().apply { + value = Coding("", searchText, "") + }, + ) + DataType.CODEABLECONCEPT -> + addAnswer( + QuestionnaireResponseItemAnswerComponent().apply { + value = CodeableConcept(Coding("", searchText, "")) + }, + ) + DataType.CODE -> + addAnswer( + QuestionnaireResponseItemAnswerComponent().apply { + value = CodeType(searchText) + }, + ) + else -> { + // Type cannot be used in search query + } + } + }, + ) + } + } + } + + fun updateRegisterFilterState( + registerId: String, + questionnaireResponse: QuestionnaireResponse, + dataFilterFields: List? = null, + ) { // Reset filter state if no answer is provided for all the fields if (questionnaireResponse.item.all { !it.hasAnswer() }) { registerFilterState.value = @@ -220,8 +430,7 @@ constructor( val qrItemMap = questionnaireResponse.item.groupBy { it.linkId }.mapValues { it.value.first() } val registerDataFilterFieldsMap = - registerConfiguration.registerFilter - ?.dataFilterFields + (dataFilterFields ?: registerConfiguration.registerFilter?.dataFilterFields) ?.groupBy { it.filterId } ?.mapValues { it.value.first() } @@ -246,15 +455,19 @@ constructor( baseResource.copy( dataQueries = newBaseResourceDataQueries ?: baseResource.dataQueries, nestedSearchResources = - baseResourceRegisterFilterField?.nestedSearchResources?.map { nestedSearchConfig -> - nestedSearchConfig.copy( - dataQueries = - createQueriesForRegisterFilter( - dataQueries = nestedSearchConfig.dataQueries, - qrItemMap = qrItemMap, - ), + getValidatedNestedSearchResources( + baseResourceRegisterFilterField?.nestedSearchResources, + qrItemMap, ) - } ?: baseResource.nestedSearchResources, + ?.map { nestedSearchConfig -> + nestedSearchConfig.copy( + dataQueries = + createQueriesForRegisterFilter( + dataQueries = nestedSearchConfig.dataQueries, + qrItemMap = qrItemMap, + ), + ) + } ?: baseResource.nestedSearchResources, ), relatedResources = newRelatedResources, ) @@ -266,6 +479,19 @@ constructor( Timber.i("New ResourceConfig for register data filter: ${fhirResourceConfig.encodeJson()}") } + private fun getValidatedNestedSearchResources( + nestedSearchResources: List?, + qrItemMap: Map, + ) = + nestedSearchResources?.filter { nestedSearchConfig -> + nestedSearchConfig.dataQueries?.any { dataQuery -> + dataQuery.filterCriteria.any { filterCriterionConfig -> + filterCriterionConfig.dataFilterLinkId.isNullOrEmpty() || + qrItemMap[filterCriterionConfig.dataFilterLinkId]?.answer?.isNotEmpty() == true + } + } ?: false + } + private fun createFilterRelatedResources( registerDataFilterFieldsMap: Map?, relatedResources: List, @@ -313,7 +539,10 @@ constructor( val answerComponent = qrItemMap[filterCriterionConfig.dataFilterLinkId] answerComponent?.answer?.forEach { itemAnswerComponent -> val criterion = - convertAnswerToFilterCriterion(itemAnswerComponent, filterCriterionConfig) + convertAnswerToFilterCriterion( + itemAnswerComponent, + filterCriterionConfig, + ) if (criterion != null) newFilterCriteria.add(criterion) } } else { @@ -324,7 +553,7 @@ constructor( } private fun convertAnswerToFilterCriterion( - answerComponent: QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent, + answerComponent: QuestionnaireResponseItemAnswerComponent, oldFilterCriterion: FilterCriterionConfig, ): FilterCriterionConfig? = when { @@ -430,64 +659,82 @@ constructor( ) { if (registerId.isNotEmpty()) { val paramsMap: Map = params.toParamDataMap() - viewModelScope.launch(dispatcherProvider.io()) { - val currentRegisterConfiguration = retrieveRegisterConfiguration(registerId, paramsMap) - - _totalRecordsCount.longValue = - registerRepository.countRegisterData(registerId = registerId, paramsMap = paramsMap) - // Only count filtered data when queries are updated - if (registerFilterState.value.fhirResourceConfig != null) { - _filteredRecordsCount.longValue = + val currentRegisterConfiguration = retrieveRegisterConfiguration(registerId, paramsMap) + if (currentRegisterConfiguration.infiniteScroll) { + registerData.value = + retrieveCompleteRegisterData(currentRegisterConfiguration.id, clearCache) + } else { + paginateRegisterData( + registerId = registerId, + loadAll = false, + clearCache = clearCache, + ) + viewModelScope.launch(dispatcherProvider.io()) { + _totalRecordsCount.longValue = registerRepository.countRegisterData( registerId = registerId, paramsMap = paramsMap, - fhirResourceConfig = registerFilterState.value.fhirResourceConfig, ) - } - paginateRegisterData(registerId, loadAll = false, clearCache = clearCache) + // Only count filtered data when queries are updated + if (registerFilterState.value.fhirResourceConfig != null) { + _filteredRecordsCount.longValue = + registerRepository.countRegisterData( + registerId = registerId, + paramsMap = paramsMap, + fhirResourceConfig = registerFilterState.value.fhirResourceConfig, + ) + } - registerUiState.value = - RegisterUiState( - screenTitle = currentRegisterConfiguration.registerTitle ?: screenTitle, - isFirstTimeSync = - sharedPreferencesHelper - .read( - SharedPreferenceKey.LAST_SYNC_TIMESTAMP.name, - null, - ) - .isNullOrEmpty() && - _totalRecordsCount.longValue == 0L && - // Do not show progress dialog if initial sync is disabled - applicationConfiguration.usePractitionerAssignedLocationOnSync, - registerConfiguration = currentRegisterConfiguration, - registerId = registerId, - totalRecordsCount = _totalRecordsCount.longValue, - filteredRecordsCount = _filteredRecordsCount.longValue, - pagesCount = - ceil( - (if (registerFilterState.value.fhirResourceConfig != null) { - _filteredRecordsCount.longValue - } else _totalRecordsCount.longValue) - .toDouble() - .div(currentRegisterConfiguration.pageSize.toLong()), - ) - .toInt(), - progressPercentage = _percentageProgress, - isSyncUpload = _isUploadSync, - params = paramsMap, - ) + registerUiCountState.value = + RegisterUiCountState( + totalRecordsCount = _totalRecordsCount.longValue, + filteredRecordsCount = _filteredRecordsCount.longValue, + pagesCount = + ceil( + (if (registerFilterState.value.fhirResourceConfig != null) { + _filteredRecordsCount.longValue + } else { + _totalRecordsCount.longValue + }) + .toDouble() + .div(currentRegisterConfiguration.pageSize.toLong()), + ) + .toInt(), + ) + } } + + registerUiState.value = + RegisterUiState( + screenTitle = currentRegisterConfiguration.registerTitle ?: screenTitle, + isFirstTimeSync = isFirstTimeSync(), + registerConfiguration = currentRegisterConfiguration, + registerId = registerId, + progressPercentage = _percentageProgress, + isSyncUpload = _isUploadSync, + currentSyncJobStatus = _currentSyncJobStatusFlow, + params = params?.toList() ?: emptyList(), + ) } } + private fun isFirstTimeSync() = + sharedPreferencesHelper + .read( + SharedPreferenceKey.LAST_SYNC_TIMESTAMP.name, + null, + ) + .isNullOrEmpty() && + applicationConfiguration.usePractitionerAssignedLocationOnSync && + _totalRecordsCount.longValue == 0L + suspend fun emitSnackBarState(snackBarMessageConfig: SnackBarMessageConfig) { _snackBarStateFlow.emit(snackBarMessageConfig) } - suspend fun emitPercentageProgressState(progress: Int, isUploadSync: Boolean) { - _percentageProgress.emit(progress) - _isUploadSync.emit(isUploadSync) + fun getImageBitmap(reference: String) = runBlocking { + reference.referenceToBitmap(registerRepository.fhirEngine, decodedImageMap) } } diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/register/components/RegisterCardList.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/register/components/RegisterCardList.kt index d9e413a3634..a78c0658979 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/register/components/RegisterCardList.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/register/components/RegisterCardList.kt @@ -16,6 +16,8 @@ package org.smartregister.fhircore.quest.ui.register.components +import android.graphics.Bitmap +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -39,11 +41,14 @@ import org.smartregister.fhircore.engine.ui.components.ErrorMessage import org.smartregister.fhircore.engine.ui.components.register.RegisterFooter import org.smartregister.fhircore.engine.ui.theme.DividerColor import org.smartregister.fhircore.quest.ui.register.RegisterEvent +import org.smartregister.fhircore.quest.ui.register.RegisterUiCountState import org.smartregister.fhircore.quest.ui.register.RegisterUiState import org.smartregister.fhircore.quest.ui.shared.components.ViewRenderer import timber.log.Timber const val REGISTER_CARD_LIST_TEST_TAG = "RegisterCardListTestTag" +const val PADDING_BOTTOM_WITH_FAB = 80 +const val PADDING_BOTTOM_WITHOUT_FAB = 32 /** * This is the list used to render register data. The register data is wrapped in [ResourceData] @@ -58,8 +63,11 @@ fun RegisterCardList( lazyListState: LazyListState, onEvent: (RegisterEvent) -> Unit, registerUiState: RegisterUiState, + registerUiCountState: RegisterUiCountState, currentPage: MutableState, showPagination: Boolean = false, + onSearchByQrSingleResultAction: (ResourceData) -> Unit, + decodeImage: ((String) -> Bitmap?)?, ) { LazyColumn(modifier = Modifier.testTag(REGISTER_CARD_LIST_TEST_TAG), state = lazyListState) { items( @@ -68,15 +76,19 @@ fun RegisterCardList( contentType = pagingItems.itemContentType(), ) { index -> // Register card UI rendered dynamically should be wrapped in a column - Column(modifier = modifier.fillMaxWidth().padding(horizontal = 16.dp)) { + Column( + modifier = modifier.fillMaxWidth().padding(horizontal = 16.dp), + ) { ViewRenderer( viewProperties = registerCardConfig.views, resourceData = pagingItems[index]!!, navController = navController, + decodeImage = decodeImage, ) } Divider(color = DividerColor, thickness = 1.dp) } + pagingItems.apply { when { loadState.refresh is LoadState.Loading -> item { CircularProgressBar() } @@ -96,20 +108,37 @@ fun RegisterCardList( ErrorMessage(message = error.error.localizedMessage!!, onClickRetry = { retry() }) } } + loadState.append.endOfPaginationReached || loadState.refresh.endOfPaginationReached -> { + if (pagingItems.itemCount == 1) { + onSearchByQrSingleResultAction.invoke(pagingItems[0]!!) + } + } } } // Register pagination item { - if (pagingItems.itemCount > 0 && showPagination) { - RegisterFooter( - resultCount = pagingItems.itemCount, - currentPage = currentPage.value.plus(1), - pagesCount = registerUiState.pagesCount, - fabActions = registerUiState.registerConfiguration?.fabActions, - previousButtonClickListener = { onEvent(RegisterEvent.MoveToPreviousPage) }, - nextButtonClickListener = { onEvent(RegisterEvent.MoveToNextPage) }, - ) + val fabActions = registerUiState.registerConfiguration?.fabActions + Box( + modifier = + Modifier.padding( + bottom = + if (!fabActions.isNullOrEmpty() && fabActions.first().visible) { + PADDING_BOTTOM_WITH_FAB.dp + } else { + PADDING_BOTTOM_WITHOUT_FAB.dp + }, + ), + ) { + if (pagingItems.itemCount > 0 && showPagination) { + RegisterFooter( + resultCount = pagingItems.itemCount, + currentPage = currentPage.value.plus(1), + pagesCount = registerUiCountState.pagesCount, + previousButtonClickListener = { onEvent(RegisterEvent.MoveToPreviousPage) }, + nextButtonClickListener = { onEvent(RegisterEvent.MoveToNextPage) }, + ) + } } } } diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/report/measure/MeasureReportViewModel.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/report/measure/MeasureReportViewModel.kt index 29b192dcd09..302bfcd1a36 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/report/measure/MeasureReportViewModel.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/report/measure/MeasureReportViewModel.kt @@ -33,12 +33,12 @@ import com.google.android.material.datepicker.MaterialDatePicker import dagger.hilt.android.lifecycle.HiltViewModel import java.util.* import javax.inject.Inject +import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import org.hl7.fhir.r4.model.Group import org.hl7.fhir.r4.model.MeasureReport import org.hl7.fhir.r4.model.Observation @@ -170,7 +170,9 @@ constructor( ) } refreshData() - event.practitionerId?.let { evaluateMeasure(event.navController, practitionerId = it) } + event.practitionerId?.let { + viewModelScope.launch { evaluateMeasure(event.navController, practitionerId = it) } + } } is MeasureReportEvent.OnDateSelected -> { if (selectedDate != null) { @@ -262,8 +264,10 @@ constructor( return subjectData.value } + private val exceptionHandler = CoroutineExceptionHandler { _, throwable -> println(throwable) } + // TODO: Enhancement - use FhirPathEngine evaluator for data extraction - fun evaluateMeasure(navController: NavController, practitionerId: String? = null) { + suspend fun evaluateMeasure(navController: NavController, practitionerId: String? = null) { // Run evaluate measure only for existing report if (reportConfigurations.isNotEmpty()) { // Retrieve and parse dates to (2020-11-16) @@ -277,89 +281,87 @@ constructor( .parseDate(SDF_D_MMM_YYYY_WITH_COMA) ?.formatDate(SDF_YYYY_MM_DD)!! - viewModelScope.launch { - kotlin - .runCatching { - // Show Progress indicator while evaluating measure - toggleProgressIndicatorVisibility(true) - val result = - reportConfigurations.flatMap { config -> - val subjects = mutableListOf() - subjects.addAll(measureReportRepository.fetchSubjects(config)) - - // If a practitioner Id is available, add it to the list of subjects - if (practitionerId?.isNotBlank() == true && subjects.isEmpty()) { - subjects.add("${Practitioner().resourceType.name}/$practitionerId") - } + try { + // Show Progress indicator while evaluating measure + toggleProgressIndicatorVisibility(true) + val result = + reportConfigurations.flatMap { config -> + val subjects = mutableListOf() + subjects.addAll(measureReportRepository.fetchSubjects(config)) + + // If a practitioner Id is available and if the subjects list is empty, add it to the + // list of subjects + if (practitionerId?.isNotBlank() == true && subjects.isEmpty()) { + subjects.add("${Practitioner().resourceType.name}/$practitionerId") + } - val existingReports = - fhirEngine.retrievePreviouslyGeneratedMeasureReports( - startDateFormatted = startDateFormatted, - endDateFormatted = endDateFormatted, - measureUrl = config.url, - subjects = listOf(), - ) + val existingReports = + fhirEngine.retrievePreviouslyGeneratedMeasureReports( + startDateFormatted = startDateFormatted, + endDateFormatted = endDateFormatted, + measureUrl = config.url, + subjects = listOf(), + ) + + val existingValidReports = mutableListOf() - val existingValidReports = mutableListOf() - - existingReports - .groupBy { it.subject.reference } - .forEach { entry -> - if ( - entry.value.size > 1 && - entry.value.distinctBy { it.measure }.size > 1 && - entry.value.distinctBy { it.type }.size > 1 - ) { - return@forEach - } else { - existingValidReports.addAll(entry.value) - } - } - - // if report is of current month or does not exist generate a new one and replace - // existing + existingReports + .groupBy { it.subject.reference } + .forEach { entry -> if ( - endDateFormatted - .parseDate(SDF_YYYY_MM_DD)!! - .formatDate(SDF_YYYY_MMM) - .contentEquals(Date().formatDate(SDF_YYYY_MMM)) || - existingValidReports.isEmpty() || - existingValidReports.size != subjects.size + entry.value.size > 1 && + entry.value.distinctBy { it.measure }.size > 1 && + entry.value.distinctBy { it.type }.size > 1 ) { - withContext(dispatcherProvider.io()) { - fhirEngine.loadCqlLibraryBundle(fhirOperator, config.url) - } - - measureReportRepository.evaluatePopulationMeasure( - measureUrl = config.url, - startDateFormatted = startDateFormatted, - endDateFormatted = endDateFormatted, - subjects = subjects, - existing = existingValidReports, - practitionerId = practitionerId, - ) + return@forEach } else { - existingValidReports + existingValidReports.addAll(entry.value) } } - _measureReportPopulationResultList.addAll( - formatPopulationMeasureReports(result, reportConfigurations), - ) - } - .onSuccess { - measureReportPopulationResults.value = _measureReportPopulationResultList - Timber.w("measureReportPopulationResults${measureReportPopulationResults.value}") - toggleProgressIndicatorVisibility(false) - // Show results of measure report for individual/population - navController.navigate(MeasureReportNavigationScreen.MeasureReportResult.route) { - launchSingleTop = true + // if report is of current month or does not exist generate a new one and replace + // existing + if ( + endDateFormatted + .parseDate(SDF_YYYY_MM_DD)!! + .formatDate(SDF_YYYY_MMM) + .contentEquals(Date().formatDate(SDF_YYYY_MMM)) || + existingValidReports.isEmpty() || + existingValidReports.size != subjects.size + ) { + fhirEngine.loadCqlLibraryBundle(fhirOperator, config.url) + + measureReportRepository.evaluatePopulationMeasure( + measureUrl = config.url, + startDateFormatted = startDateFormatted, + endDateFormatted = endDateFormatted, + subjects = subjects, + existing = existingValidReports, + practitionerId = practitionerId, + ) + } else { + existingValidReports } } - .onFailure { - Timber.w(it) - toggleProgressIndicatorVisibility(false) - } + + val measureReportPopulationResultList = + formatPopulationMeasureReports(result, reportConfigurations) + _measureReportPopulationResultList.addAll( + measureReportPopulationResultList, + ) + + // On success + measureReportPopulationResults.value = _measureReportPopulationResultList + // Timber.w("measureReportPopulationResults${measureReportPopulationResults.value}") + + // Show results of measure report for individual/population + navController.navigate(MeasureReportNavigationScreen.MeasureReportResult.route) { + launchSingleTop = true + } + } catch (e: Exception) { + Timber.e(e) + } finally { + toggleProgressIndicatorVisibility(false) } } } diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/report/measure/worker/MeasureReportWorker.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/report/measure/worker/MeasureReportWorker.kt index c6f272384c7..9de0c5a0d54 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/report/measure/worker/MeasureReportWorker.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/report/measure/worker/MeasureReportWorker.kt @@ -25,7 +25,7 @@ import androidx.work.WorkManager import androidx.work.WorkerParameters import androidx.work.workDataOf import com.google.android.fhir.FhirEngine -import com.google.android.fhir.search.search +import com.google.android.fhir.knowledge.KnowledgeManager import com.google.android.fhir.workflow.FhirOperator import dagger.assisted.Assisted import dagger.assisted.AssistedInject @@ -36,15 +36,19 @@ import java.time.format.DateTimeFormatter import java.time.temporal.ChronoUnit import java.util.Calendar import java.util.Date +import java.util.NoSuchElementException import java.util.concurrent.TimeUnit import kotlinx.coroutines.withContext +import org.hl7.fhir.instance.model.api.IBaseResource import org.hl7.fhir.r4.model.Measure import org.hl7.fhir.r4.model.MeasureReport +import org.hl7.fhir.r4.model.ResourceType import org.smartregister.fhircore.engine.configuration.ConfigurationRegistry import org.smartregister.fhircore.engine.data.local.DefaultRepository import org.smartregister.fhircore.engine.util.DefaultDispatcherProvider import org.smartregister.fhircore.engine.util.extension.SDFHH_MM import org.smartregister.fhircore.engine.util.extension.SDF_YYYY_MM_DD +import org.smartregister.fhircore.engine.util.extension.batchedSearch import org.smartregister.fhircore.engine.util.extension.firstDayOfMonth import org.smartregister.fhircore.engine.util.extension.formatDate import org.smartregister.fhircore.engine.util.extension.lastDayOfMonth @@ -67,6 +71,7 @@ constructor( val dispatcherProvider: DefaultDispatcherProvider, val fhirOperator: FhirOperator, val fhirEngine: FhirEngine, + private val knowledgeManager: KnowledgeManager, val workManager: WorkManager, ) : CoroutineWorker(appContext, workerParams) { @@ -78,7 +83,7 @@ constructor( Timber.w("started MeasureReportWorker") fhirEngine - .search {} + .batchedSearch {} .map { it.resource } .forEach { monthList?.forEachIndexed { index, date -> @@ -127,8 +132,14 @@ constructor( val measureReport: MeasureReport? = withContext(dispatcherProvider.io()) { try { + val measureUrlResources: Iterable = + knowledgeManager.loadResources( + resourceType = ResourceType.Measure.name, + url = measureUrl, + ) + fhirOperator.evaluateMeasure( - measureUrl = measureUrl, + measure = measureUrlResources.first() as Measure, start = startDateFormatted, end = endDateFormatted, reportType = MeasureReportViewModel.POPULATION, @@ -140,6 +151,9 @@ constructor( } catch (exception: IllegalArgumentException) { Timber.e(exception) null + } catch (exception: NoSuchElementException) { + Timber.e(exception) + null } } if (measureReport != null) { diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/sdc/PasswordViewHolderFactory.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/sdc/password/PasswordViewHolderFactory.kt similarity index 98% rename from android/quest/src/main/java/org/smartregister/fhircore/quest/ui/sdc/PasswordViewHolderFactory.kt rename to android/quest/src/main/java/org/smartregister/fhircore/quest/ui/sdc/password/PasswordViewHolderFactory.kt index 875f21c10cd..e355405f9a4 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/sdc/PasswordViewHolderFactory.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/sdc/password/PasswordViewHolderFactory.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package org.smartregister.fhircore.quest.ui.sdc +package org.smartregister.fhircore.quest.ui.sdc.password import android.content.Context import android.text.Editable diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/sdc/qrCode/CameraPermissionsDialogFragment.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/sdc/qrCode/CameraPermissionsDialogFragment.kt new file mode 100644 index 00000000000..0202c11c72e --- /dev/null +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/sdc/qrCode/CameraPermissionsDialogFragment.kt @@ -0,0 +1,61 @@ +/* + * Copyright 2021-2024 Ona Systems, Inc + * + * 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 org.smartregister.fhircore.quest.ui.sdc.qrCode + +import android.Manifest +import androidx.activity.result.contract.ActivityResultContracts +import androidx.annotation.VisibleForTesting +import androidx.core.os.bundleOf +import androidx.fragment.app.DialogFragment +import org.smartregister.fhircore.engine.util.location.PermissionUtils +import org.smartregister.fhircore.quest.R + +class CameraPermissionsDialogFragment : DialogFragment(R.layout.fragment_camera_permission) { + + @VisibleForTesting + val cameraPermissionRequest = + registerForActivityResult(ActivityResultContracts.RequestPermission()) { permissionGranted -> + parentFragmentManager.setFragmentResult( + CAMERA_PERMISSION_REQUEST_RESULT_KEY, + bundleOf(CAMERA_PERMISSION_REQUEST_RESULT_KEY to permissionGranted), + ) + + if (permissionGranted) { + dismiss() + } else { + dismiss() + } + } + + override fun onResume() { + super.onResume() + + when { + PermissionUtils.checkPermissions(requireContext(), listOf(Manifest.permission.CAMERA)) -> { + dismiss() + } + else -> { + cameraPermissionRequest.launch(Manifest.permission.CAMERA) + } + } + } + + companion object { + const val CAMERA_PERMISSION_REQUEST_RESULT_KEY = + "quest.ui.sdc.qrCode.CameraPermissionsDialogFragment" + } +} diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/sdc/qrCode/EditTextQrCodeItemViewHolderFactory.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/sdc/qrCode/EditTextQrCodeItemViewHolderFactory.kt new file mode 100644 index 00000000000..468f58c079f --- /dev/null +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/sdc/qrCode/EditTextQrCodeItemViewHolderFactory.kt @@ -0,0 +1,130 @@ +/* + * Copyright 2021-2024 Ona Systems, Inc + * + * 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 org.smartregister.fhircore.quest.ui.sdc.qrCode + +import android.annotation.SuppressLint +import android.content.Context +import android.text.Editable +import android.text.InputType +import android.view.MotionEvent +import android.view.View +import android.view.inputmethod.InputMethodManager +import com.google.android.fhir.datacapture.extensions.getValidationErrorMessage +import com.google.android.fhir.datacapture.extensions.tryUnwrapContext +import com.google.android.fhir.datacapture.views.QuestionnaireViewItem +import com.google.android.fhir.datacapture.views.factories.QuestionnaireItemEditTextViewHolderDelegate +import com.google.android.fhir.datacapture.views.factories.QuestionnaireItemViewHolderDelegate +import com.google.android.fhir.datacapture.views.factories.QuestionnaireItemViewHolderFactory +import com.google.android.material.textfield.TextInputEditText +import com.google.android.material.textfield.TextInputLayout +import org.hl7.fhir.r4.model.QuestionnaireResponse +import org.hl7.fhir.r4.model.StringType +import org.smartregister.fhircore.quest.R +import org.smartregister.fhircore.quest.util.QrCodeScanUtils + +internal class EditTextQrCodeItemViewHolderFactory( + private val qrCodeAnswerChangeListener: QrCodeChangeListener, +) : QuestionnaireItemViewHolderFactory(R.layout.edit_text_single_line_qr_code_item_view) { + override fun getQuestionnaireItemViewHolderDelegate(): QuestionnaireItemViewHolderDelegate = + object : + QuestionnaireItemEditTextViewHolderDelegate( + InputType.TYPE_NULL, + ) { + @SuppressLint("ClickableViewAccessibility") + override fun init(itemView: View) { + super.init(itemView) + + val onQrCodeIconClickListener: (Context) -> Unit = { + it.tryUnwrapContext()?.let { appCompatActivity -> + QrCodeScanUtils.scanQrCode(appCompatActivity) { code -> + itemView.findViewById(R.id.text_input_edit_text).setText(code) + } + } + } + + itemView.findViewById(R.id.text_input_layout).apply { + setEndIconOnClickListener { onQrCodeIconClickListener.invoke(it.context) } + findViewById(R.id.text_input_edit_text).apply { + setOnFocusChangeListener { v, hasFocus -> + if (!hasFocus) { + (v.context.applicationContext.getSystemService(Context.INPUT_METHOD_SERVICE) + as InputMethodManager) + .hideSoftInputFromWindow(v.windowToken, 0) + } + } + setOnTouchListener { v, event, + -> + if (event.action == MotionEvent.ACTION_UP) { + onQrCodeIconClickListener(v.context) + } + return@setOnTouchListener false + } + } + } + } + + override fun setReadOnly(isReadOnly: Boolean) { + val questionnaireItemHasAnswer = questionnaireViewItem.answers.any { !it.value.isEmpty } + val readOnly = + questionnaireItemHasAnswer && (isReadOnly || questionnaireViewItem.isSetOnceReadOnly) + super.setReadOnly(readOnly) + } + + override suspend fun handleInput( + editable: Editable, + questionnaireViewItem: QuestionnaireViewItem, + ) { + val answer = + editable.toString().let { + if (it.isBlank()) { + null + } else { + QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent() + .setValue(StringType(it)) + } + } + + qrCodeAnswerChangeListener.onQrCodeChanged( + questionnaireViewItem.answers.singleOrNull(), + answer, + ) + } + + override fun updateInputTextUI( + questionnaireViewItem: QuestionnaireViewItem, + textInputEditText: TextInputEditText, + ) { + val text = questionnaireViewItem.answers.singleOrNull()?.valueStringType?.value ?: "" + if ((text != textInputEditText.text.toString())) { + textInputEditText.text?.clear() + textInputEditText.append(text) + } + } + + override fun updateValidationTextUI( + questionnaireViewItem: QuestionnaireViewItem, + textInputLayout: TextInputLayout, + ) { + textInputLayout.error = + getValidationErrorMessage( + textInputLayout.context, + questionnaireViewItem, + questionnaireViewItem.validationResult, + ) + } + } +} diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/sdc/qrCode/EditTextQrCodeViewHolderFactory.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/sdc/qrCode/EditTextQrCodeViewHolderFactory.kt new file mode 100644 index 00000000000..3bac623fb09 --- /dev/null +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/sdc/qrCode/EditTextQrCodeViewHolderFactory.kt @@ -0,0 +1,178 @@ +/* + * Copyright 2021-2024 Ona Systems, Inc + * + * 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 org.smartregister.fhircore.quest.ui.sdc.qrCode + +import android.annotation.SuppressLint +import android.view.View +import android.view.ViewGroup +import android.widget.Button +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import com.google.android.fhir.datacapture.views.QuestionnaireViewItem +import com.google.android.fhir.datacapture.views.factories.QuestionnaireItemViewHolder +import com.google.android.fhir.datacapture.views.factories.QuestionnaireItemViewHolderDelegate +import com.google.android.fhir.datacapture.views.factories.QuestionnaireItemViewHolderFactory +import org.hl7.fhir.r4.model.BooleanType +import org.hl7.fhir.r4.model.Questionnaire +import org.hl7.fhir.r4.model.QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent +import org.hl7.fhir.r4.model.StringType +import org.smartregister.fhircore.quest.R + +object EditTextQrCodeViewHolderFactory : + QuestionnaireItemViewHolderFactory(R.layout.edit_text_qr_code_view) { + override fun getQuestionnaireItemViewHolderDelegate(): QuestionnaireItemViewHolderDelegate = + object : QuestionnaireItemViewHolderDelegate { + override lateinit var questionnaireViewItem: QuestionnaireViewItem + + private val canHaveMultipleAnswers + get() = questionnaireViewItem.questionnaireItem.repeats + + private lateinit var qrCodesRecyclerView: RecyclerView + private lateinit var addQrCodeButton: Button + private lateinit var qrCodeViewItemsAdapter: QrCodeViewItemAdapter + private lateinit var questionnaireViewItemAnswers: + List + + override fun init(itemView: View) { + qrCodesRecyclerView = itemView.findViewById(R.id.recycler_view_qr_codes) + addQrCodeButton = itemView.findViewById(R.id.add_qr_code) + + qrCodeViewItemsAdapter = QrCodeViewItemAdapter { previousAnswer, newAnswer -> + val prevAnswerEmpty = previousAnswer == null || previousAnswer.value.isEmpty + val newAnswerEmpty = newAnswer == null || newAnswer.value.isEmpty + when { + prevAnswerEmpty && !newAnswerEmpty -> { + if (canHaveMultipleAnswers) { + questionnaireViewItem.addAnswer(newAnswer!!) + } else { + questionnaireViewItem.setAnswer(newAnswer!!) + } + } + !prevAnswerEmpty && newAnswerEmpty -> { + questionnaireViewItem.removeAnswer(previousAnswer!!) + } + !prevAnswerEmpty && !newAnswerEmpty -> { + previousAnswer!!.value = newAnswer!!.value + questionnaireViewItem.setAnswer(*questionnaireViewItemAnswers.toTypedArray()) + } + } + } + qrCodesRecyclerView.adapter = qrCodeViewItemsAdapter + val linearLayoutManager = LinearLayoutManager(itemView.context) + qrCodesRecyclerView.layoutManager = linearLayoutManager + qrCodesRecyclerView.itemAnimator = null + } + + override fun bind(questionnaireViewItem: QuestionnaireViewItem) { + questionnaireViewItemAnswers = questionnaireViewItem.answers + val subQuestionnaireViewItems = + questionnaireViewItemAnswers + .filterNot { it.isEmpty } + .map { getSubQuestionnaireViewItem(it) } + .filterIndexed { index, _ -> canHaveMultipleAnswers || index == 0 } + .toMutableList() + if (subQuestionnaireViewItems.isEmpty() && !canHaveMultipleAnswers) { + subQuestionnaireViewItems.add( + getSubQuestionnaireViewItem(QuestionnaireResponseItemAnswerComponent()), + ) + } + qrCodeViewItemsAdapter.submitList(subQuestionnaireViewItems) + + addQrCodeButton.visibility = if (canHaveMultipleAnswers) View.VISIBLE else View.GONE + if (canHaveMultipleAnswers) { + addQrCodeButton.setOnClickListener { + qrCodeViewItemsAdapter.submitList( + subQuestionnaireViewItems + + getSubQuestionnaireViewItem(QuestionnaireResponseItemAnswerComponent()), + ) + } + } + } + + override fun setReadOnly(isReadOnly: Boolean) { + if (isReadOnly) { + addQrCodeButton.visibility = View.GONE + } + } + + private fun getSubQuestionnaireViewItem( + answer: QuestionnaireResponseItemAnswerComponent, + ): QuestionnaireViewItem { + val newQrResponseItem = questionnaireViewItem.getQuestionnaireResponseItem().copy() + newQrResponseItem.answer = listOf(answer) + return questionnaireViewItem.copy(questionnaireResponseItem = newQrResponseItem) + } + } + + fun matcher(questionnaireItem: Questionnaire.QuestionnaireItemComponent): Boolean { + return questionnaireItem.getExtensionByUrl(QR_CODE_WIDGET_URL) != null + } +} + +internal class QrCodeViewItemAdapter(val qrCodeAnswerChangeListener: QrCodeChangeListener) : + ListAdapter( + QR_CODE_DIFF_ITEMCallBack, + ) { + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): QuestionnaireItemViewHolder { + return EditTextQrCodeItemViewHolderFactory(qrCodeAnswerChangeListener).create(parent) + } + + override fun onBindViewHolder(holder: QuestionnaireItemViewHolder, position: Int) { + holder.bind(getItem(position)) + } +} + +internal fun interface QrCodeChangeListener { + suspend fun onQrCodeChanged( + previous: QuestionnaireResponseItemAnswerComponent?, + newAnswer: QuestionnaireResponseItemAnswerComponent?, + ) +} + +internal val QR_CODE_DIFF_ITEMCallBack = + object : DiffUtil.ItemCallback() { + override fun areItemsTheSame( + oldItem: QuestionnaireViewItem, + newItem: QuestionnaireViewItem, + ): Boolean = areContentsTheSame(oldItem, newItem) + + @SuppressLint("DiffUtilEquals") + override fun areContentsTheSame( + oldItem: QuestionnaireViewItem, + newItem: QuestionnaireViewItem, + ): Boolean { + val newItemAnswers = newItem.answers.map { (it.value as? StringType)?.value } + val oldItemAnswers = oldItem.answers.map { (it.value as? StringType)?.value } + return oldItem.questionnaireItem === newItem.questionnaireItem && + oldItemAnswers.size == newItemAnswers.size && + newItemAnswers.all { oldItemAnswers.contains(it) } + } + } + +internal val QuestionnaireViewItem.isSetOnceReadOnly: Boolean + get() { + val qrCodeExtension = questionnaireItem.getExtensionByUrl(QR_CODE_WIDGET_URL) + val qrCodeEntryModeValue = + qrCodeExtension?.getExtensionByUrl(QR_CODE_SET_ONCE_READONLY_URL)?.value as? BooleanType + return qrCodeEntryModeValue?.value == true + } + +private const val QR_CODE_WIDGET_URL = + "https://github.com/opensrp/android-fhir/StructureDefinition/qr-code-widget" +private const val QR_CODE_SET_ONCE_READONLY_URL = "set-only-readonly" diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/sdc/qrCode/scan/QRCodeScannerDialogFragment.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/sdc/qrCode/scan/QRCodeScannerDialogFragment.kt new file mode 100644 index 00000000000..1f675c1a527 --- /dev/null +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/sdc/qrCode/scan/QRCodeScannerDialogFragment.kt @@ -0,0 +1,188 @@ +/* + * Copyright 2021-2024 Ona Systems, Inc + * + * 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 org.smartregister.fhircore.quest.ui.sdc.qrCode.scan + +import android.content.res.Resources +import android.graphics.RectF +import android.os.Bundle +import android.view.View +import android.view.ViewGroup +import android.widget.ImageButton +import android.widget.ImageView +import android.widget.TextView +import android.widget.Toast +import androidx.annotation.VisibleForTesting +import androidx.camera.core.ImageAnalysis.COORDINATE_SYSTEM_VIEW_REFERENCED +import androidx.camera.mlkit.vision.MlKitAnalyzer +import androidx.camera.view.LifecycleCameraController +import androidx.camera.view.PreviewView +import androidx.core.content.ContextCompat +import androidx.core.graphics.toRect +import androidx.core.os.bundleOf +import com.google.android.material.bottomsheet.BottomSheetBehavior +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import com.google.mlkit.vision.barcode.BarcodeScanner +import com.google.mlkit.vision.barcode.BarcodeScannerOptions +import com.google.mlkit.vision.barcode.BarcodeScanning +import com.google.mlkit.vision.barcode.common.Barcode +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors +import org.smartregister.fhircore.engine.util.location.PermissionUtils +import org.smartregister.fhircore.quest.R +import org.smartregister.fhircore.quest.ui.sdc.qrCode.CameraPermissionsDialogFragment + +internal class QRCodeScannerDialogFragment : + BottomSheetDialogFragment(R.layout.fragment_qr_code_scan) { + + private lateinit var cameraExecutor: ExecutorService + private lateinit var cameraController: LifecycleCameraController + private lateinit var mlKitImageAnalyzer: MlKitAnalyzer + private val barcodeScanner: BarcodeScanner by lazy { + val options = BarcodeScannerOptions.Builder().setBarcodeFormats(Barcode.FORMAT_QR_CODE).build() + BarcodeScanning.getClient(options) + } + + private lateinit var previewView: PreviewView + private lateinit var cancelScanButton: ImageButton + private lateinit var viewFinderImageView: ImageView + private lateinit var placeQrCodeScanTextView: TextView + + @VisibleForTesting + val viewFinderBounds: RectF + get() { + val viewFinderImageViewHeight = viewFinderImageView.height + val viewFinderImageViewWidth = viewFinderImageView.width + return RectF( + viewFinderImageView.x, + viewFinderImageView.y, + viewFinderImageView.x + viewFinderImageViewWidth + 10, + viewFinderImageView.y + viewFinderImageViewHeight + 10, + ) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + previewView = view.findViewById(R.id.previewView) + viewFinderImageView = view.findViewById(R.id.viewFinderImageView) + placeQrCodeScanTextView = view.findViewById(R.id.placeQrCodeScanTextView) + cancelScanButton = view.findViewById(R.id.cancelImageButton) + cancelScanButton.setOnClickListener { dismiss() } + + val parent = view.parent as View + val behavior = BottomSheetBehavior.from(parent) + val layoutParams = parent.layoutParams + layoutParams.height = ViewGroup.LayoutParams.MATCH_PARENT + parent.layoutParams = layoutParams + behavior.maxHeight = (0.8 * Resources.getSystem().displayMetrics.heightPixels).toInt() + behavior.peekHeight = (0.8 * Resources.getSystem().displayMetrics.heightPixels).toInt() + behavior.state = BottomSheetBehavior.STATE_EXPANDED + behavior.isDraggable = false + + cameraExecutor = Executors.newSingleThreadExecutor() + cameraController = LifecycleCameraController(requireContext()) + mlKitImageAnalyzer = + MlKitAnalyzer( + listOf(barcodeScanner), + COORDINATE_SYSTEM_VIEW_REFERENCED, + ContextCompat.getMainExecutor(requireActivity()), + ) { result: MlKitAnalyzer.Result? -> + val barcodeResults = result?.getValue(barcodeScanner) + if ( + (barcodeResults == null) || (barcodeResults.size == 0) || (barcodeResults.first() == null) + ) { + return@MlKitAnalyzer + } + + onQrCodeDetected(barcodeResults[0]) + } + + cameraController.setImageAnalysisAnalyzer( + ContextCompat.getMainExecutor(requireActivity()), + mlKitImageAnalyzer, + ) + + parentFragmentManager.setFragmentResultListener( + CameraPermissionsDialogFragment.CAMERA_PERMISSION_REQUEST_RESULT_KEY, + this, + ) { _, result -> + val permissionGranted = + result.getBoolean(CameraPermissionsDialogFragment.CAMERA_PERMISSION_REQUEST_RESULT_KEY) + if (!permissionGranted) { + Toast.makeText( + requireActivity(), + requireContext().getString(R.string.barcode_camera_permission_denied), + Toast.LENGTH_SHORT, + ) + .show() + dismiss() + } + } + } + + override fun onResume() { + super.onResume() + + if ( + !PermissionUtils.checkPermissions( + requireContext(), + listOf(android.Manifest.permission.CAMERA), + ) + ) { + requestCameraPermissions() + } else { + bindCamera() + } + } + + private fun requestCameraPermissions() { + CameraPermissionsDialogFragment().show(parentFragmentManager, TAG) + } + + private fun bindCamera() { + cameraController.bindToLifecycle(this) + previewView.controller = cameraController + } + + private fun isBarcodeWithinExpectedBounds(barcode: Barcode): Boolean { + return barcode.boundingBox?.let { viewFinderBounds.toRect().contains(it) } ?: false + } + + @VisibleForTesting + fun onQrCodeDetected(barcode: Barcode) { + if (isBarcodeWithinExpectedBounds(barcode)) { + parentFragmentManager.setFragmentResult( + RESULT_REQUEST_KEY, + bundleOf(RESULT_REQUEST_KEY to barcode.rawValue), + ) + dismiss() + } else { + placeQrCodeScanTextView.visibility = View.VISIBLE + } + } + + override fun onDestroyView() { + super.onDestroyView() + cameraExecutor.shutdown() + barcodeScanner.close() + } + + companion object { + private const val TAG = "QRCodeScannerDialogFragment" + const val RESULT_REQUEST_KEY = "quest.ui.sdc.qrCode.scan.QRCodeScannerDialogFragment" + } +} diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/shared/QuestionnaireHandler.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/shared/QuestionnaireHandler.kt index c293e5fb23f..13287beb857 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/shared/QuestionnaireHandler.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/shared/QuestionnaireHandler.kt @@ -26,6 +26,13 @@ import org.smartregister.fhircore.engine.configuration.QuestionnaireConfig import org.smartregister.fhircore.engine.domain.model.ActionParameter import org.smartregister.fhircore.quest.ui.questionnaire.QuestionnaireActivity +const val ON_RESULT_TYPE = "onResultType" + +enum class ActivityOnResultType { + LOCATION, + QUESTIONNAIRE, +} + interface QuestionnaireHandler { val startForResult: ActivityResultLauncher @@ -48,8 +55,11 @@ interface QuestionnaireHandler { ) .putExtras(extraIntentBundle), ) + onQuestionnaireLaunched(questionnaireConfig) } } + fun onQuestionnaireLaunched(questionnaireConfig: QuestionnaireConfig) + suspend fun onSubmitQuestionnaire(activityResult: ActivityResult) } diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/shared/components/ActionableButton.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/shared/components/ActionableButton.kt index 77a7b46631d..a7f318414dc 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/shared/components/ActionableButton.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/shared/components/ActionableButton.kt @@ -16,6 +16,7 @@ package org.smartregister.fhircore.quest.ui.shared.components +import android.graphics.Bitmap import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row @@ -70,6 +71,7 @@ fun ActionableButton( buttonProperties: ButtonProperties, resourceData: ResourceData, navController: NavController, + decodeImage: ((String) -> Bitmap?)?, ) { if (buttonProperties.visible.toBoolean()) { val status = buttonProperties.status @@ -162,6 +164,7 @@ fun ActionableButton( tint = iconTintColor, resourceData = resourceData, navController = navController, + decodeImage = decodeImage, ) } else { Icon( @@ -192,7 +195,9 @@ fun ActionableButton( } else { if (colorOpacity == 0.0f) { DefaultColor.copy(alpha = 0.9f) - } else statusColor.copy(alpha = colorOpacity) + } else { + statusColor.copy(alpha = colorOpacity) + } }, textAlign = TextAlign.Start, overflow = TextOverflow.Ellipsis, @@ -217,6 +222,7 @@ fun ActionableButtonPreview() { ), resourceData = ResourceData("id", ResourceType.Patient, emptyMap()), navController = rememberNavController(), + decodeImage = null, ) } @@ -233,6 +239,7 @@ fun ActionableButtonTinyButtonPreview() { ), resourceData = ResourceData("id", ResourceType.Patient, emptyMap()), navController = rememberNavController(), + decodeImage = null, ) } @@ -255,6 +262,7 @@ fun DisabledActionableButtonPreview() { ), resourceData = ResourceData("id", ResourceType.Patient, emptyMap()), navController = rememberNavController(), + decodeImage = null, ) } } @@ -274,6 +282,7 @@ fun SmallActionableButtonPreview() { ), resourceData = ResourceData("id", ResourceType.Patient, emptyMap()), navController = rememberNavController(), + decodeImage = null, ) ActionableButton( modifier = Modifier.weight(1.0f), @@ -286,6 +295,7 @@ fun SmallActionableButtonPreview() { ), resourceData = ResourceData("id", ResourceType.Patient, emptyMap()), navController = rememberNavController(), + decodeImage = null, ) } } diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/shared/components/CardView.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/shared/components/CardView.kt index 551b744725c..9ae90703260 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/shared/components/CardView.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/shared/components/CardView.kt @@ -16,6 +16,7 @@ package org.smartregister.fhircore.quest.ui.shared.components +import android.graphics.Bitmap import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -58,6 +59,7 @@ fun CardView( viewProperties: CardViewProperties, resourceData: ResourceData, navController: NavController, + decodeImage: ((String) -> Bitmap?)?, ) { // Check if card is visible if (viewProperties.visible.toBoolean()) { @@ -109,6 +111,7 @@ fun CardView( viewProperties = viewProperties.content, resourceData = resourceData, navController = navController, + decodeImage = decodeImage, ) } } @@ -147,6 +150,7 @@ private fun CardViewWithoutPaddingPreview() { ), resourceData = ResourceData("id", ResourceType.Patient, emptyMap()), navController = rememberNavController(), + decodeImage = null, ) } } @@ -183,6 +187,7 @@ private fun CardViewWithPaddingPreview() { ), resourceData = ResourceData("id", ResourceType.Patient, emptyMap()), navController = rememberNavController(), + decodeImage = null, ) } } @@ -205,6 +210,7 @@ private fun CardViewWithoutPaddingAndHeaderPreview() { ), resourceData = ResourceData("id", ResourceType.Patient, emptyMap()), navController = rememberNavController(), + decodeImage = null, ) } } @@ -272,6 +278,7 @@ private fun CardViewImageWithItems() { ), resourceData = ResourceData("id", ResourceType.Patient, emptyMap()), navController = rememberNavController(), + decodeImage = null, ) } } diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/shared/components/CompoundText.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/shared/components/CompoundText.kt index 37f546db932..dbe547f334f 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/shared/components/CompoundText.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/shared/components/CompoundText.kt @@ -97,6 +97,7 @@ fun CompoundText( navController = navController, overflow = compoundTextProperties.overflow, letterSpacing = compoundTextProperties.letterSpacing, + textInnerPadding = compoundTextProperties.textInnerPadding, ) } // Separate the primary and secondary text @@ -128,6 +129,7 @@ fun CompoundText( resourceData = resourceData, overflow = compoundTextProperties.overflow, letterSpacing = compoundTextProperties.letterSpacing, + textInnerPadding = compoundTextProperties.textInnerPadding, ) } } @@ -153,6 +155,7 @@ private fun CompoundTextPart( resourceData: ResourceData, overflow: TextOverFlow?, letterSpacing: Int = 0, + textInnerPadding: Int = 0, ) { Text( text = @@ -175,7 +178,7 @@ private fun CompoundTextPart( ) .clip(RoundedCornerShape(borderRadius.dp)) .background(backgroundColor.parseColor()) - .padding(0.dp), + .padding(textInnerPadding.dp), fontSize = fontSize.sp, fontWeight = textFontWeight.fontWeight, textAlign = diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/shared/components/ExtendedFab.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/shared/components/ExtendedFab.kt index baff4a9a178..269a56fc0e2 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/shared/components/ExtendedFab.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/shared/components/ExtendedFab.kt @@ -16,6 +16,7 @@ package org.smartregister.fhircore.quest.ui.shared.components +import android.graphics.Bitmap import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding @@ -56,6 +57,7 @@ fun ExtendedFab( resourceData: ResourceData? = null, navController: NavController, lazyListState: LazyListState?, + decodeImage: ((String) -> Bitmap?)?, ) { val firstFabAction = remember { fabActions.first() } val firstFabEnabled = @@ -90,6 +92,7 @@ fun ExtendedFab( tint = if (firstFabEnabled) Color.White else DefaultColor, navController = navController, resourceData = resourceData, + decodeImage = decodeImage, ) } if (text.isNotEmpty()) { @@ -126,6 +129,7 @@ fun PreviewDisabledExtendedFab() { ), navController = rememberNavController(), lazyListState = rememberLazyListState(), + decodeImage = null, ) } @@ -143,6 +147,7 @@ fun PreviewExtendedFab() { ), navController = rememberNavController(), lazyListState = rememberLazyListState(), + decodeImage = null, ) } @@ -160,5 +165,6 @@ fun PreviewExtendedFabJustIcon() { ), navController = rememberNavController(), lazyListState = rememberLazyListState(), + decodeImage = null, ) } diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/shared/components/Image.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/shared/components/Image.kt index 181cb2d3355..0d3a0cc71d2 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/shared/components/Image.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/shared/components/Image.kt @@ -17,6 +17,7 @@ package org.smartregister.fhircore.quest.ui.shared.components import android.content.Context +import android.graphics.Bitmap import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -55,7 +56,9 @@ import org.smartregister.fhircore.engine.configuration.view.ImageShape import org.smartregister.fhircore.engine.domain.model.ResourceData import org.smartregister.fhircore.engine.domain.model.ViewType import org.smartregister.fhircore.engine.ui.theme.DangerColor +import org.smartregister.fhircore.engine.ui.theme.SideMenuTopItemDarkColor import org.smartregister.fhircore.engine.util.annotation.PreviewWithBackgroundExcludeGenerated +import org.smartregister.fhircore.engine.util.extension.extractLogicalIdUuid import org.smartregister.fhircore.engine.util.extension.parseColor import org.smartregister.fhircore.engine.util.extension.retrieveResourceId import org.smartregister.fhircore.quest.ui.main.components.SIDE_MENU_ICON @@ -73,10 +76,11 @@ fun Image( imageProperties: ImageProperties = ImageProperties(viewType = ViewType.IMAGE, size = 24), navController: NavController, resourceData: ResourceData? = null, + decodeImage: ((String) -> Bitmap?)?, ) { val imageConfig = imageProperties.imageConfig val colorTint = tint ?: imageProperties.imageConfig?.color.parseColor() - val cotext = LocalContext.current + val context = LocalContext.current if (imageConfig != null) { if (imageProperties.text != null) { Row( @@ -87,29 +91,29 @@ fun Image( text = imageProperties.text!!, textAlign = TextAlign.Center, modifier = Modifier.padding(end = 8.dp), - color = imageProperties.textColor?.parseColor() ?: Color.Gray, + color = imageProperties.textColor?.parseColor() ?: SideMenuTopItemDarkColor, ) ClickableImageIcon( imageProperties = imageProperties, - imageConfig = imageConfig, tint = colorTint, paddingEnd = paddingEnd, navController = navController, resourceData = resourceData, modifier = modifier, - context = cotext, + context = context, + decodeImage = decodeImage, ) } } else { ClickableImageIcon( imageProperties = imageProperties, - imageConfig = imageConfig, tint = colorTint, paddingEnd = paddingEnd, navController = navController, resourceData = resourceData, modifier = modifier, - context = cotext, + context = context, + decodeImage = decodeImage, ) } } @@ -119,12 +123,12 @@ fun Image( fun ClickableImageIcon( modifier: Modifier = Modifier, imageProperties: ImageProperties, - imageConfig: ImageConfig, tint: Color, paddingEnd: Int?, navController: NavController, resourceData: ResourceData? = null, context: Context? = null, + decodeImage: ((String) -> Bitmap?)?, ) { Box( contentAlignment = Alignment.Center, @@ -137,8 +141,8 @@ fun ClickableImageIcon( ) .conditional( imageProperties.size != null, - { size(imageProperties.size!!.dp) }, - { size(24.dp) }, + { size(if (imageProperties.size!! >= 22) imageProperties.size!!.dp else 16.dp) }, + { size(20.dp) }, ) .conditional( !imageProperties.backgroundColor.isNullOrEmpty(), @@ -160,41 +164,49 @@ fun ClickableImageIcon( }, ), ) { - when (imageConfig.type) { - ICON_TYPE_LOCAL -> { - LocalContext.current.retrieveResourceId(imageConfig.reference)?.let { drawableId -> - Icon( - modifier = - Modifier.testTag(SIDE_MENU_ITEM_LOCAL_ICON_TEST_TAG) - .conditional(paddingEnd != null, { padding(end = paddingEnd?.dp!!) }) - .align(Alignment.Center) - .fillMaxSize(0.9f), - painter = painterResource(id = drawableId), - contentDescription = SIDE_MENU_ICON, - tint = tint, - ) + val imageConfig = + imageProperties.imageConfig?.interpolate( + resourceData?.computedValuesMap ?: emptyMap(), + ) + if (imageConfig != null) { + when (imageConfig.type) { + ICON_TYPE_LOCAL -> { + LocalContext.current.retrieveResourceId(imageConfig.reference)?.let { drawableId -> + Icon( + modifier = + Modifier.testTag(SIDE_MENU_ITEM_LOCAL_ICON_TEST_TAG) + .conditional(paddingEnd != null, { padding(end = paddingEnd?.dp!!) }) + .align(Alignment.Center) + .fillMaxSize(0.9f), + painter = painterResource(id = drawableId), + contentDescription = SIDE_MENU_ICON, + tint = tint, + ) + } } - } - ICON_TYPE_REMOTE -> - if (imageConfig.decodedBitmap != null) { - val imageType = imageProperties.imageConfig?.imageType + ICON_TYPE_REMOTE -> { + val imageType = imageConfig.imageType val colorFilter = if (imageType == ImageType.SVG || imageType == ImageType.PNG) tint else null - val contentScale = - convertContentScaleTypeToContentScale(imageProperties.imageConfig!!.contentScale) - Image( - modifier = - Modifier.testTag(SIDE_MENU_ITEM_REMOTE_ICON_TEST_TAG) - .conditional(paddingEnd != null, { padding(end = paddingEnd?.dp!!) }) - .align(Alignment.Center) - .fillMaxSize(0.9f), - bitmap = imageConfig.decodedBitmap!!.asImageBitmap(), - contentDescription = null, - alpha = imageProperties.imageConfig!!.alpha, - contentScale = contentScale, - colorFilter = colorFilter?.let { ColorFilter.tint(it) }, - ) + val contentScale = convertContentScaleTypeToContentScale(imageConfig.contentScale) + val decodedImage = + imageConfig.reference?.extractLogicalIdUuid()?.let { decodeImage?.invoke(it) } + if (decodedImage != null) { + Image( + modifier = + Modifier.testTag(SIDE_MENU_ITEM_REMOTE_ICON_TEST_TAG) + .conditional(paddingEnd != null, { padding(end = paddingEnd?.dp!!) }) + .align(Alignment.Center) + .fillMaxSize(0.9f), + bitmap = decodedImage.asImageBitmap(), + contentDescription = null, + alpha = imageConfig.alpha, + contentScale = contentScale, + colorFilter = colorFilter?.let { ColorFilter.tint(it) }, + ) + } } + } } } } @@ -227,6 +239,7 @@ fun ImagePreview() { tint = DangerColor.copy(0.1f), resourceData = ResourceData("id", ResourceType.Patient, emptyMap()), navController = rememberNavController(), + decodeImage = null, ) } @@ -247,5 +260,6 @@ fun ClickableImageWithTextPreview() { ), resourceData = ResourceData("id", ResourceType.Patient, emptyMap()), navController = rememberNavController(), + decodeImage = null, ) } diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/shared/components/List.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/shared/components/List.kt index 4ab1e7718cd..e0275a1ab1c 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/shared/components/List.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/shared/components/List.kt @@ -18,6 +18,7 @@ package org.smartregister.fhircore.quest.ui.shared.components +import android.graphics.Bitmap import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.slideInVertically import androidx.compose.foundation.background @@ -30,7 +31,6 @@ import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.material.Divider import androidx.compose.material.Text import androidx.compose.runtime.Composable @@ -48,7 +48,7 @@ import org.smartregister.fhircore.engine.configuration.register.RegisterCardConf import org.smartregister.fhircore.engine.configuration.view.CompoundTextProperties import org.smartregister.fhircore.engine.configuration.view.ListOrientation import org.smartregister.fhircore.engine.configuration.view.ListProperties -import org.smartregister.fhircore.engine.configuration.view.ListResource +import org.smartregister.fhircore.engine.configuration.view.ListResourceConfig import org.smartregister.fhircore.engine.domain.model.ResourceData import org.smartregister.fhircore.engine.domain.model.ViewType import org.smartregister.fhircore.engine.ui.theme.DefaultColor @@ -67,16 +67,20 @@ fun List( viewProperties: ListProperties, resourceData: ResourceData, navController: NavController, + decodeImage: ((String) -> Bitmap?)?, ) { val density = LocalDensity.current val currentListResourceData = resourceData.listResourceDataMap?.get(viewProperties.id) if (currentListResourceData.isNullOrEmpty()) { if (!viewProperties.emptyList?.message.isNullOrEmpty()) { - Box(contentAlignment = Alignment.Center, modifier = modifier.wrapContentSize()) { + Box(contentAlignment = Alignment.Center, modifier = modifier.fillMaxWidth()) { Text( text = viewProperties.emptyList?.message!!, - modifier = modifier.padding(8.dp).align(Alignment.Center), - color = DefaultColor, + modifier = + modifier + .conditional(viewProperties.enableTopBottomSpacing, { padding(8.dp) }) + .align(Alignment.Center), + color = viewProperties.emptyList?.textColor?.parseColor() ?: DefaultColor, fontStyle = FontStyle.Italic, ) } @@ -102,32 +106,61 @@ fun List( .testTag(VERTICAL_ORIENTATION), ) { currentListResourceData.forEachIndexed { index, listResourceData -> - Spacer(modifier = modifier.height(6.dp)) - Column( - modifier = - Modifier.padding( - horizontal = viewProperties.padding.dp, - vertical = viewProperties.padding.div(4).dp, - ), - ) { - AnimatedVisibility( - visible = true, - enter = - slideInVertically { - // Slide in from 40 dp from the top. - with(density) { -40.dp.roundToPx() } - }, + // Interpolate ViewProperties up-front to hide the child view spacers and divider when + // the child view is not visible + val interpolatedChildViewProperties = + viewProperties.registerCard.views.map { viewProperty -> + viewProperty.interpolate(listResourceData.computedValuesMap) + } + // At least 1 child view must be visible in order to show the spacers and divider + val areChildViewsVisible = + interpolatedChildViewProperties.any { viewProperty -> + viewProperty.visible.toBooleanStrict() + } + if (areChildViewsVisible) { + // Add spacing before each item, except for first item when enableTopBottomSpacing + // is false + if (index != 0 || viewProperties.enableTopBottomSpacing) { + Spacer(modifier = modifier.height(viewProperties.spacerHeight.dp)) + } + Column( + modifier = + Modifier.padding( + horizontal = viewProperties.padding.dp, + vertical = viewProperties.padding.div(4).dp, + ), ) { - ViewRenderer( - viewProperties = viewProperties.registerCard.views, - resourceData = listResourceData, - navController = navController, - ) + AnimatedVisibility( + visible = true, + enter = + slideInVertically { + // Slide in from 40 dp from the top. + with(density) { -40.dp.roundToPx() } + }, + ) { + ViewRenderer( + viewProperties = interpolatedChildViewProperties, + resourceData = listResourceData, + navController = navController, + decodeImage = decodeImage, + areViewPropertiesInterpolated = + true, // Prevents double interpolation (in this function and inside the + // ViewRenderer) which is a waste + ) + } + } + // Add spacer after each item except last one when enableTopBottomSpacing is false + if ( + index != currentListResourceData.lastIndex || + viewProperties.enableTopBottomSpacing + ) { + Spacer(modifier = modifier.height(viewProperties.spacerHeight.dp)) + } + // viewProperties in this case belongs to the List, setting the showDivider will + // apply to all child items under the List + if (index < currentListResourceData.lastIndex && viewProperties.showDivider) { + Divider(color = DividerColor, thickness = 0.5.dp) } - } - Spacer(modifier = modifier.height(6.dp)) - if (index < currentListResourceData.lastIndex && viewProperties.showDivider) { - Divider(color = DividerColor, thickness = 0.5.dp) } } } @@ -138,6 +171,7 @@ fun List( viewProperties = viewProperties.registerCard.views, resourceData = listResourceData, navController = navController, + decodeImage = decodeImage, ) } } @@ -163,7 +197,7 @@ private fun ListWithHorizontalOrientationPreview() { borderRadius = 10, emptyList = NoResultsConfig(message = ""), resources = - listOf(ListResource(id = "carePlanList", resourceType = ResourceType.CarePlan)), + listOf(ListResourceConfig(id = "carePlanList", resourceType = ResourceType.CarePlan)), fillMaxHeight = true, registerCard = RegisterCardConfig( @@ -207,6 +241,7 @@ private fun ListWithHorizontalOrientationPreview() { baseResourceType = ResourceType.Patient, computedValuesMap = emptyMap(), ), + decodeImage = null, ) } } @@ -227,7 +262,7 @@ private fun ListWithVerticalOrientationPreview() { borderRadius = 10, emptyList = NoResultsConfig(message = "No care Plans"), resources = - listOf(ListResource(id = "carePlanList", resourceType = ResourceType.CarePlan)), + listOf(ListResourceConfig(id = "carePlanList", resourceType = ResourceType.CarePlan)), fillMaxWidth = true, registerCard = RegisterCardConfig( @@ -258,6 +293,7 @@ private fun ListWithVerticalOrientationPreview() { baseResourceType = ResourceType.Patient, computedValuesMap = emptyMap(), ), + decodeImage = null, ) } } diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/shared/components/SearchBar.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/shared/components/SearchBar.kt index 2aad3327f4b..54b085ec52a 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/shared/components/SearchBar.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/shared/components/SearchBar.kt @@ -30,7 +30,7 @@ import androidx.compose.material.Text import androidx.compose.material.TextField import androidx.compose.material.TextFieldDefaults import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Close import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState @@ -49,8 +49,6 @@ import androidx.compose.ui.unit.sp import org.smartregister.fhircore.engine.R import org.smartregister.fhircore.engine.util.annotation.PreviewWithBackgroundExcludeGenerated -class SearchView - const val SEARCH_BAR_TRAILING_ICON_TEST_TAG = "searchBarTrailingIconTestTag" const val SEARCH_BAR_TRAILING_ICON_BUTTON_TEST_TAG = "searchBarTrailingIconButtonTestTag" const val SEARCH_BAR_TRAILING_TEXT_FIELD_TEST_TAG = "searchBarTrailingTextFieldTestTag" @@ -74,7 +72,7 @@ fun SearchBar( leadingIcon = { IconButton(onClick = onBackPress) { Icon( - Icons.Filled.ArrowBack, + Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null, modifier = modifier.padding(16.dp), ) diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/shared/components/ServiceCard.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/shared/components/ServiceCard.kt index fb93cc6e421..1bf739a7151 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/shared/components/ServiceCard.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/shared/components/ServiceCard.kt @@ -16,6 +16,7 @@ package org.smartregister.fhircore.quest.ui.shared.components +import android.graphics.Bitmap import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable @@ -82,7 +83,9 @@ fun ServiceCard( serviceCardProperties: ServiceCardProperties, resourceData: ResourceData, navController: NavController, + decodeImage: ((String) -> Bitmap?)?, ) { + val serviceMemberIconsTint = serviceCardProperties.serviceMemberIconsTint.parseColor() if (serviceCardProperties.showVerticalDivider) { Row( horizontalArrangement = Arrangement.SpaceBetween, @@ -108,6 +111,7 @@ fun ServiceCard( weight = 0.7f, details = serviceCardProperties.details, serviceMemberIcons = serviceCardProperties.serviceMemberIcons, + serviceMemberIconsTint = serviceMemberIconsTint, navController = navController, resourceData = resourceData, ) @@ -122,6 +126,7 @@ fun ServiceCard( serviceCardProperties = serviceCardProperties, navController = navController, resourceData = resourceData, + decodeImage = decodeImage, ) } } else { @@ -149,6 +154,7 @@ fun ServiceCard( weight = 0.55f, details = serviceCardProperties.details, serviceMemberIcons = serviceCardProperties.serviceMemberIcons, + serviceMemberIconsTint = serviceMemberIconsTint, navController = navController, resourceData = resourceData, ) @@ -158,6 +164,7 @@ fun ServiceCard( serviceCardProperties = serviceCardProperties, navController = navController, resourceData = resourceData, + decodeImage = decodeImage, ) } } @@ -168,6 +175,7 @@ private fun RowScope.RenderDetails( weight: Float, details: List, serviceMemberIcons: String?, + serviceMemberIconsTint: Color, navController: NavController, resourceData: ResourceData, ) { @@ -175,7 +183,7 @@ private fun RowScope.RenderDetails( val memberIcons = iconsSplit.map { it.capitalize().trim() }.take(NUMBER_OF_ICONS_DISPLAYED) Row( verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.weight(weight).padding(end = 6.dp).fillMaxWidth(), + modifier = Modifier.weight(weight).padding(end = 10.dp).fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, ) { Column( @@ -199,14 +207,12 @@ private fun RowScope.RenderDetails( horizontalArrangement = Arrangement.End, ) { memberIcons.forEach { - if ( - it.isNotEmpty() && ServiceMemberIcon.values().map { icon -> icon.name }.contains(it) - ) { + if (it.isNotEmpty() && ServiceMemberIcon.entries.map { icon -> icon.name }.contains(it)) { Icon( painter = painterResource(id = ServiceMemberIcon.valueOf(it).icon), contentDescription = null, modifier = Modifier.size(18.dp).padding(0.dp), - tint = Color.Unspecified, + tint = serviceMemberIconsTint, ) } } @@ -239,6 +245,7 @@ private fun RowScope.RenderActionButtons( serviceCardProperties: ServiceCardProperties, navController: NavController, resourceData: ResourceData, + decodeImage: ((String) -> Bitmap?)?, ) { Box(modifier = Modifier.weight(weight).padding(start = 6.dp)) { if (serviceCardProperties.serviceButton != null || serviceCardProperties.services != null) { @@ -256,6 +263,7 @@ private fun RowScope.RenderActionButtons( buttonProperties = serviceCardProperties.serviceButton!!, resourceData = resourceData, navController = navController, + decodeImage = decodeImage, ) } } @@ -278,6 +286,7 @@ private fun RowScope.RenderActionButtons( buttonProperties = buttonProperties, resourceData = resourceData, navController = navController, + decodeImage = decodeImage, ) } } @@ -405,6 +414,7 @@ private fun ServiceCardServiceOverduePreview() { viewProperties = viewProperties, resourceData = ResourceData("id", ResourceType.Patient, emptyMap()), navController = rememberNavController(), + decodeImage = null, ) } } @@ -460,6 +470,7 @@ private fun ServiceCardServiceOverdueWithBackgroundColorPreview() { viewProperties = viewProperties, resourceData = ResourceData("id", ResourceType.Patient, emptyMap()), navController = rememberNavController(), + decodeImage = null, ) } } @@ -515,6 +526,7 @@ private fun ServiceCardServiceOverdueWithNoBackgroundColorAndStatusPreview() { viewProperties = viewProperties, resourceData = ResourceData("id", ResourceType.Patient, emptyMap()), navController = rememberNavController(), + decodeImage = null, ) } } @@ -570,6 +582,7 @@ private fun ServiceCardServiceDuePreview() { viewProperties = viewProperties, resourceData = ResourceData("id", ResourceType.Patient, emptyMap()), navController = rememberNavController(), + decodeImage = null, ) } } @@ -624,6 +637,7 @@ private fun ServiceCardServiceUpcomingPreview() { viewProperties = viewProperties, resourceData = ResourceData("id", ResourceType.Patient, emptyMap()), navController = rememberNavController(), + decodeImage = null, ) } } @@ -659,6 +673,7 @@ private fun ServiceCardServiceFamilyMemberPreview() { viewProperties = viewProperties, resourceData = ResourceData("id", ResourceType.Patient, emptyMap()), navController = rememberNavController(), + decodeImage = null, ) } } @@ -704,6 +719,7 @@ private fun ServiceCardServiceWithTinyServiceButtonPreview() { viewProperties = viewProperties, resourceData = ResourceData("id", ResourceType.Patient, emptyMap()), navController = rememberNavController(), + decodeImage = null, ) } } @@ -757,6 +773,7 @@ private fun ServiceCardServiceCompletedPreview() { viewProperties = viewProperties, resourceData = ResourceData("id", ResourceType.Patient, emptyMap()), navController = rememberNavController(), + decodeImage = null, ) } } @@ -804,6 +821,7 @@ private fun ServiceCardANCServiceDuePreview() { viewProperties = viewProperties, resourceData = ResourceData("id", ResourceType.Patient, emptyMap()), navController = rememberNavController(), + decodeImage = null, ) } } @@ -861,6 +879,7 @@ private fun ServiceCardANCServiceOverduePreview() { viewProperties = viewProperties, resourceData = ResourceData("id", ResourceType.Patient, emptyMap()), navController = rememberNavController(), + decodeImage = null, ) } } diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/shared/components/StackView.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/shared/components/StackView.kt index 8410c08d6dd..ce960e62e74 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/shared/components/StackView.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/shared/components/StackView.kt @@ -16,7 +16,7 @@ package org.smartregister.fhircore.quest.ui.shared.components -import androidx.compose.foundation.background +import android.graphics.Bitmap import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable @@ -31,7 +31,6 @@ import org.smartregister.fhircore.engine.configuration.view.StackViewProperties import org.smartregister.fhircore.engine.configuration.view.ViewAlignment import org.smartregister.fhircore.engine.domain.model.ResourceData import org.smartregister.fhircore.engine.util.annotation.PreviewWithBackgroundExcludeGenerated -import org.smartregister.fhircore.engine.util.extension.parseColor const val STACK_VIEW_TEST_TAG = "stackViewTestTag" @@ -41,15 +40,10 @@ fun StackView( stackViewProperties: StackViewProperties, resourceData: ResourceData, navController: NavController, + decodeImage: ((String) -> Bitmap?)?, ) { - val backgroundColor = stackViewProperties.backgroundColor.parseColor() - val size = stackViewProperties.size - Box( - modifier = - Modifier.background(backgroundColor.copy(alpha = stackViewProperties.opacity)) - .size(size!!.dp) - .testTag(STACK_VIEW_TEST_TAG), + modifier.size(stackViewProperties.size.dp).testTag(STACK_VIEW_TEST_TAG), contentAlignment = castViewAlignment(stackViewProperties.alignment), ) { stackViewProperties.children.forEach { child -> @@ -58,6 +52,7 @@ fun StackView( properties = child.interpolate(resourceData.computedValuesMap), resourceData = resourceData, navController = navController, + decodeImage = decodeImage, ) } } @@ -96,5 +91,6 @@ private fun PreviewStack() { baseResourceType = ResourceType.Patient, computedValuesMap = emptyMap(), ), + decodeImage = null, ) } diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/shared/components/SyncStatusView.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/shared/components/SyncStatusView.kt new file mode 100644 index 00000000000..423323e660a --- /dev/null +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/shared/components/SyncStatusView.kt @@ -0,0 +1,468 @@ +/* + * Copyright 2021-2024 Ona Systems, Inc + * + * 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 org.smartregister.fhircore.quest.ui.shared.components + +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +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.material.Icon +import androidx.compose.material.LinearProgressIndicator +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.Error +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.KeyboardArrowUp +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect +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.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.google.android.fhir.sync.CurrentSyncJobStatus +import com.google.android.fhir.sync.SyncJobStatus +import com.google.android.fhir.sync.SyncOperation +import java.time.OffsetDateTime +import kotlin.time.Duration.Companion.seconds +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import org.smartregister.fhircore.engine.ui.theme.AppTheme +import org.smartregister.fhircore.engine.ui.theme.DangerColor +import org.smartregister.fhircore.engine.ui.theme.DefaultColor +import org.smartregister.fhircore.engine.ui.theme.SubtitleTextColor +import org.smartregister.fhircore.engine.ui.theme.SuccessColor +import org.smartregister.fhircore.engine.ui.theme.SyncBarBackgroundColor +import org.smartregister.fhircore.engine.util.annotation.PreviewWithBackgroundExcludeGenerated +import org.smartregister.fhircore.quest.ui.main.AppMainEvent +import org.smartregister.fhircore.quest.ui.shared.models.AppDrawerUIState +import org.smartregister.fhircore.quest.util.extensions.conditional + +const val TRANSPARENCY = 0.2f +const val SYNC_PROGRESS_INDICATOR_TEST_TAG = "syncProgressIndicatorTestTag" + +@Composable +fun SyncBottomBar( + isFirstTimeSync: Boolean, + appDrawerUIState: AppDrawerUIState, + onAppMainEvent: (AppMainEvent) -> Unit, + openDrawer: (Boolean) -> Unit, +) { + val coroutineScope = rememberCoroutineScope() + val currentSyncJobStatus = appDrawerUIState.currentSyncJobStatus + val hideSyncCompleteStatus = remember { mutableStateOf(false) } + if (currentSyncJobStatus is CurrentSyncJobStatus.Succeeded) { + LaunchedEffect(Unit) { + coroutineScope.launch { + delay(7.seconds) + hideSyncCompleteStatus.value = true + } + } + } + val syncBackgroundColor = + when (currentSyncJobStatus) { + is CurrentSyncJobStatus.Failed -> DangerColor.copy(alpha = 0.2f) + is CurrentSyncJobStatus.Succeeded -> SuccessColor.copy(alpha = 0.2f) + is CurrentSyncJobStatus.Running -> SyncBarBackgroundColor + else -> Color.Transparent + } + var syncNotificationBarExpanded by remember { mutableStateOf(true) } + val bottomRadius = + if (!hideSyncCompleteStatus.value || currentSyncJobStatus is CurrentSyncJobStatus.Running) { + 32.dp + } else { + 0.dp + } + val height = + when { + syncNotificationBarExpanded -> + if (currentSyncJobStatus is CurrentSyncJobStatus.Running) 114.dp else 80.dp + else -> 60.dp + } + if ( + !isFirstTimeSync && + currentSyncJobStatus != null && + (currentSyncJobStatus is CurrentSyncJobStatus.Running || + currentSyncJobStatus is CurrentSyncJobStatus.Failed || + (!hideSyncCompleteStatus.value && currentSyncJobStatus is CurrentSyncJobStatus.Succeeded)) + ) { + Box( + modifier = + Modifier.fillMaxWidth().animateContentSize().height(height).background(syncBackgroundColor), + ) { + Box( + modifier = + Modifier.fillMaxWidth() + .align(Alignment.TopStart) + .height(24.dp) + .clip(RoundedCornerShape(bottomStart = bottomRadius, bottomEnd = bottomRadius)) + .background(Color.White), + ) { + Box( + modifier = + Modifier.align(Alignment.BottomStart) + .padding(start = 16.dp) + .height(20.dp) + .width(60.dp) + .clip(RoundedCornerShape(topStart = 8.dp, topEnd = 8.dp)) + .background(syncBackgroundColor) + .clickable { syncNotificationBarExpanded = !syncNotificationBarExpanded }, + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = + if (syncNotificationBarExpanded) { + Icons.Default.KeyboardArrowDown + } else { + Icons.Default.KeyboardArrowUp + }, + contentDescription = null, + tint = + when (currentSyncJobStatus) { + is CurrentSyncJobStatus.Failed -> DangerColor + is CurrentSyncJobStatus.Succeeded -> SuccessColor + else -> Color.White + }, + modifier = Modifier.size(16.dp), + ) + } + } + Box( + modifier = Modifier.align(Alignment.BottomStart).fillMaxWidth(), + contentAlignment = Alignment.Center, + ) { + val context = LocalContext.current + when (currentSyncJobStatus) { + is CurrentSyncJobStatus.Running -> { + SyncStatusView( + isSyncUpload = appDrawerUIState.isSyncUpload, + currentSyncJobStatus = currentSyncJobStatus, + minimized = !syncNotificationBarExpanded, + progressPercentage = appDrawerUIState.percentageProgress, + onCancel = { onAppMainEvent(AppMainEvent.CancelSyncData(context)) }, + ) + SideEffect { hideSyncCompleteStatus.value = false } + } + is CurrentSyncJobStatus.Failed -> { + SyncStatusView( + isSyncUpload = appDrawerUIState.isSyncUpload, + currentSyncJobStatus = currentSyncJobStatus, + minimized = !syncNotificationBarExpanded, + onRetry = { + openDrawer(false) + onAppMainEvent(AppMainEvent.SyncData(context)) + }, + ) + } + is CurrentSyncJobStatus.Succeeded -> { + if (!hideSyncCompleteStatus.value) { + SyncStatusView( + isSyncUpload = appDrawerUIState.isSyncUpload, + currentSyncJobStatus = currentSyncJobStatus, + minimized = !syncNotificationBarExpanded, + ) + } + } + else -> { + // No render required + } + } + } + } + } +} + +@Composable +fun SyncStatusView( + isSyncUpload: Boolean?, + currentSyncJobStatus: CurrentSyncJobStatus?, + progressPercentage: Int? = null, + minimized: Boolean = false, + onRetry: () -> Unit = {}, + onCancel: () -> Unit = {}, +) { + val height = + if (minimized) { + 36.dp + } else if (currentSyncJobStatus is CurrentSyncJobStatus.Running) 88.dp else 56.dp + Row( + modifier = + Modifier.height(height) + .animateContentSize() + .background(Color.Transparent) // Inherits the color from the parent + .fillMaxWidth() + .padding(horizontal = 16.dp) + .conditional(minimized, { padding(vertical = 4.dp) }, { padding(vertical = 16.dp) }), + verticalAlignment = Alignment.CenterVertically, + ) { + if ( + (currentSyncJobStatus is CurrentSyncJobStatus.Failed || + currentSyncJobStatus is CurrentSyncJobStatus.Succeeded) + ) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.weight(1f)) { + if (!minimized) { + Icon( + imageVector = + if (currentSyncJobStatus is CurrentSyncJobStatus.Succeeded) { + Icons.Default.CheckCircle + } else { + Icons.Default.Error + }, + contentDescription = null, + tint = + when (currentSyncJobStatus) { + is CurrentSyncJobStatus.Failed -> DangerColor + is CurrentSyncJobStatus.Succeeded -> SuccessColor + else -> DefaultColor + }, + ) + } + SyncStatusTitle( + text = + if (currentSyncJobStatus is CurrentSyncJobStatus.Succeeded) { + stringResource(org.smartregister.fhircore.engine.R.string.sync_complete) + } else { + stringResource(org.smartregister.fhircore.engine.R.string.sync_error) + }, + minimized = minimized, + startPadding = if (minimized) 0 else 16, + ) + } + } + + if (currentSyncJobStatus is CurrentSyncJobStatus.Running) { + Column(modifier = Modifier.weight(1f)) { + if (!minimized) { + SyncStatusTitle( + text = + stringResource( + if (isSyncUpload == true) { + org.smartregister.fhircore.engine.R.string.sync_up_inprogress + } else { + org.smartregister.fhircore.engine.R.string.sync_down_inprogress + }, + progressPercentage ?: 0, + ), + minimized = false, + color = Color.White, + startPadding = 0, + ) + } + LinearProgressIndicator( + progress = (progressPercentage?.toFloat()?.div(100)) ?: 0f, + color = MaterialTheme.colors.primary, + backgroundColor = Color.White, + modifier = + Modifier.testTag(SYNC_PROGRESS_INDICATOR_TEST_TAG) + .padding(vertical = 4.dp) + .fillMaxWidth(), + ) + if (!minimized) { + Text( + text = stringResource(id = org.smartregister.fhircore.engine.R.string.please_wait), + color = SubtitleTextColor, + fontSize = 14.sp, + textAlign = TextAlign.Start, + modifier = Modifier.align(Alignment.Start), + ) + } + } + } + + if ( + (currentSyncJobStatus is CurrentSyncJobStatus.Failed || + currentSyncJobStatus is CurrentSyncJobStatus.Running) && !minimized + ) { + Text( + text = + stringResource( + if (currentSyncJobStatus is CurrentSyncJobStatus.Failed) { + org.smartregister.fhircore.engine.R.string.retry + } else { + org.smartregister.fhircore.engine.R.string.cancel + }, + ), + modifier = + Modifier.padding(start = 16.dp).clickable { + if (currentSyncJobStatus is CurrentSyncJobStatus.Failed) { + onRetry() + } else { + onCancel() + } + }, + color = MaterialTheme.colors.primary, + fontWeight = FontWeight.SemiBold, + ) + } + } +} + +@Composable +private fun SyncStatusTitle( + text: String, + color: Color = Color.Unspecified, + minimized: Boolean, + startPadding: Int, +) { + Text( + text = text, + modifier = Modifier.padding(start = startPadding.dp), + fontWeight = FontWeight.SemiBold, + fontSize = if (minimized) 14.sp else 16.sp, + color = color, + ) +} + +@Composable +@PreviewWithBackgroundExcludeGenerated +fun SyncStatusSucceededPreview() { + AppTheme { + Column(modifier = Modifier.background(SuccessColor.copy(alpha = TRANSPARENCY))) { + SyncStatusView( + isSyncUpload = false, + currentSyncJobStatus = CurrentSyncJobStatus.Succeeded(OffsetDateTime.now()), + ) + } + } +} + +@Composable +@PreviewWithBackgroundExcludeGenerated +fun SyncStatusFailedPreview() { + AppTheme { + Column(modifier = Modifier.background(DangerColor.copy(alpha = TRANSPARENCY))) { + SyncStatusView( + isSyncUpload = false, + currentSyncJobStatus = CurrentSyncJobStatus.Failed(OffsetDateTime.now()), + ) + } + } +} + +@Composable +@PreviewWithBackgroundExcludeGenerated +fun SyncStatusInProgressUploadPreview() { + AppTheme { + Column(modifier = Modifier.background(SyncBarBackgroundColor)) { + SyncStatusView( + isSyncUpload = true, + currentSyncJobStatus = + CurrentSyncJobStatus.Running( + inProgressSyncJob = + SyncJobStatus.InProgress( + SyncOperation.DOWNLOAD, + 187, + 34, + ), + ), + ) + } + } +} + +@Composable +@PreviewWithBackgroundExcludeGenerated +fun SyncStatusInProgressDownloadPreview() { + AppTheme { + Column(modifier = Modifier.background(SyncBarBackgroundColor)) { + SyncStatusView( + isSyncUpload = false, + currentSyncJobStatus = + CurrentSyncJobStatus.Running( + inProgressSyncJob = + SyncJobStatus.InProgress( + SyncOperation.DOWNLOAD, + 187, + 34, + ), + ), + ) + } + } +} + +@Composable +@PreviewWithBackgroundExcludeGenerated +fun SyncStatusSucceededMinimizedPreview() { + AppTheme { + Column(modifier = Modifier.background(SuccessColor.copy(alpha = TRANSPARENCY))) { + SyncStatusView( + isSyncUpload = false, + currentSyncJobStatus = CurrentSyncJobStatus.Succeeded(OffsetDateTime.now()), + minimized = true, + ) + } + } +} + +@Composable +@PreviewWithBackgroundExcludeGenerated +fun SyncStatusFailedMinimizedPreview() { + AppTheme { + Column(modifier = Modifier.background(DangerColor.copy(alpha = TRANSPARENCY))) { + SyncStatusView( + isSyncUpload = false, + currentSyncJobStatus = CurrentSyncJobStatus.Failed(OffsetDateTime.now()), + minimized = true, + ) + } + } +} + +@Composable +@PreviewWithBackgroundExcludeGenerated +fun SyncStatusRunningMinimizedPreview() { + AppTheme { + Column(modifier = Modifier.background(SyncBarBackgroundColor)) { + SyncStatusView( + isSyncUpload = false, + currentSyncJobStatus = + CurrentSyncJobStatus.Running( + inProgressSyncJob = + SyncJobStatus.InProgress( + SyncOperation.DOWNLOAD, + 187, + 34, + ), + ), + minimized = true, + ) + } + } +} diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/shared/components/ViewGenerator.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/shared/components/ViewGenerator.kt index 903825a3226..75756050504 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/shared/components/ViewGenerator.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/shared/components/ViewGenerator.kt @@ -17,6 +17,7 @@ package org.smartregister.fhircore.quest.ui.shared.components import android.annotation.SuppressLint +import android.graphics.Bitmap import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -70,6 +71,7 @@ fun GenerateView( properties: ViewProperties, resourceData: ResourceData, navController: NavController, + decodeImage: ((String) -> Bitmap?)?, ) { if (properties.visible.toBoolean()) { when (properties.viewType) { @@ -86,6 +88,7 @@ fun GenerateView( buttonProperties = properties as ButtonProperties, resourceData = resourceData, navController = navController, + decodeImage = decodeImage, ) ViewType.COLUMN -> { val children = (properties as ColumnProperties).children @@ -108,6 +111,7 @@ fun GenerateView( properties = properties, resourceData = resourceData, navController = navController, + decodeImage = decodeImage, ) } } @@ -130,11 +134,7 @@ fun GenerateView( .conditional( properties.clickable.toBoolean(), { - clickable { - (properties as RowProperties) - .actions - .handleClickEvent(navController, resourceData) - } + clickable { properties.actions.handleClickEvent(navController, resourceData) } }, ), verticalArrangement = @@ -150,6 +150,7 @@ fun GenerateView( properties = child.interpolate(resourceData.computedValuesMap), resourceData = resourceData, navController = navController, + decodeImage = decodeImage, ) if (properties.showDivider.toBoolean() && index < children.lastIndex) { Divider( @@ -183,6 +184,7 @@ fun GenerateView( properties = properties.interpolate(resourceData.computedValuesMap), resourceData = resourceData, navController = navController, + decodeImage = decodeImage, ) } } @@ -205,11 +207,7 @@ fun GenerateView( .conditional( properties.clickable.toBoolean(), { - clickable { - (properties as RowProperties) - .actions - .handleClickEvent(navController, resourceData) - } + clickable { properties.actions.handleClickEvent(navController, resourceData) } }, ), horizontalArrangement = @@ -225,6 +223,7 @@ fun GenerateView( properties = child.interpolate(resourceData.computedValuesMap), resourceData = resourceData, navController = navController, + decodeImage = decodeImage, ) } } @@ -236,6 +235,7 @@ fun GenerateView( serviceCardProperties = properties as ServiceCardProperties, resourceData = resourceData, navController = navController, + decodeImage = decodeImage, ) ViewType.CARD -> CardView( @@ -243,6 +243,7 @@ fun GenerateView( viewProperties = properties as CardViewProperties, resourceData = resourceData, navController = navController, + decodeImage = decodeImage, ) ViewType.PERSONAL_DATA -> PersonalDataView( @@ -261,6 +262,7 @@ fun GenerateView( viewProperties = properties as ListProperties, resourceData = resourceData, navController = navController, + decodeImage = decodeImage, ) ViewType.IMAGE -> Image( @@ -268,6 +270,7 @@ fun GenerateView( imageProperties = properties as ImageProperties, resourceData = resourceData, navController = navController, + decodeImage = decodeImage, ) ViewType.STACK -> StackView( @@ -275,6 +278,7 @@ fun GenerateView( stackViewProperties = properties as StackViewProperties, resourceData = resourceData, navController = navController, + decodeImage = decodeImage, ) } } @@ -325,5 +329,13 @@ fun generateModifier(viewProperties: ViewProperties): Modifier = private fun Modifier.applyCommonProperties(viewProperties: ViewProperties): Modifier = this.conditional(viewProperties.fillMaxWidth, { fillMaxWidth() }) .conditional(viewProperties.fillMaxHeight, { fillMaxHeight() }) - .background(viewProperties.backgroundColor.parseColor()) + .background( + viewProperties.backgroundColor.parseColor().let { baseColor -> + if (viewProperties.opacity != null) { + baseColor.copy(alpha = viewProperties.opacity!!.toFloat()) + } else { + baseColor + } + }, + ) .clip(RoundedCornerShape(viewProperties.borderRadius.dp)) diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/shared/components/ViewRenderer.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/shared/components/ViewRenderer.kt index 8f7916c5ff9..8c6fec4ca05 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/shared/components/ViewRenderer.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/shared/components/ViewRenderer.kt @@ -16,6 +16,7 @@ package org.smartregister.fhircore.quest.ui.shared.components +import android.graphics.Bitmap import androidx.compose.runtime.Composable import androidx.navigation.NavController import androidx.navigation.compose.rememberNavController @@ -47,13 +48,22 @@ fun ViewRenderer( viewProperties: List, resourceData: ResourceData, navController: NavController, + decodeImage: ((String) -> Bitmap?)?, + areViewPropertiesInterpolated: Boolean = false, ) { viewProperties.forEach { properties -> + val interpolatedProperties = + if (areViewPropertiesInterpolated) { + properties + } else { + properties.interpolate(resourceData.computedValuesMap) + } GenerateView( modifier = generateModifier(properties), - properties = properties.interpolate(resourceData.computedValuesMap), + properties = interpolatedProperties, resourceData = resourceData, navController = navController, + decodeImage = decodeImage, ) } } @@ -88,6 +98,7 @@ private fun PreviewWeightedViewsInRow() { ), resourceData = ResourceData("id", ResourceType.Patient, emptyMap()), navController = rememberNavController(), + decodeImage = null, ) } @@ -143,6 +154,7 @@ private fun PreviewWrappedViewsInRow() { ), resourceData = ResourceData("id", ResourceType.Patient, emptyMap()), navController = rememberNavController(), + decodeImage = null, ) } @@ -180,6 +192,7 @@ private fun PreviewSameSizedViewInRow() { ), resourceData = ResourceData("id", ResourceType.Patient, emptyMap()), navController = rememberNavController(), + decodeImage = null, ) } @@ -298,5 +311,6 @@ private fun PreviewCardViewWithRows() { ), resourceData = ResourceData("id", ResourceType.Patient, emptyMap()), navController = rememberNavController(), + decodeImage = null, ) } diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/shared/models/AppDrawerUIState.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/shared/models/AppDrawerUIState.kt new file mode 100644 index 00000000000..b998337ffd7 --- /dev/null +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/shared/models/AppDrawerUIState.kt @@ -0,0 +1,25 @@ +/* + * Copyright 2021-2024 Ona Systems, Inc + * + * 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 org.smartregister.fhircore.quest.ui.shared.models + +import com.google.android.fhir.sync.CurrentSyncJobStatus + +data class AppDrawerUIState( + val isSyncUpload: Boolean? = false, + val currentSyncJobStatus: CurrentSyncJobStatus? = null, + val percentageProgress: Int? = 0, +) diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/shared/models/Search.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/shared/models/Search.kt new file mode 100644 index 00000000000..ead9f74ad70 --- /dev/null +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/shared/models/Search.kt @@ -0,0 +1,44 @@ +/* + * Copyright 2021-2024 Ona Systems, Inc + * + * 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 org.smartregister.fhircore.quest.ui.shared.models + +import org.smartregister.fhircore.engine.configuration.workflow.ActionTrigger + +sealed class SearchMode { + data object KeyboardInput : SearchMode() + + data object QrCodeScan : SearchMode() +} + +/** + * Wrapper class to hold search input [String] and the [SearchMode] mode used to initiate the UI + * search + * + * Depending on the [SearchMode], additional [ActionTrigger.ON_SEARCH_SINGLE_RESULT] actions can be + * triggered a query returns a single result + * + * @param query Actual search input string + * @param mode [SearchMode] that initiated the search + */ +data class SearchQuery(val query: String, val mode: SearchMode = SearchMode.KeyboardInput) { + + fun isBlank() = query.isBlank() + + companion object { + val emptyText = SearchQuery(query = "") + } +} diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/shared/viewmodels/SearchViewModel.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/shared/viewmodels/SearchViewModel.kt new file mode 100644 index 00000000000..0797bc8bae1 --- /dev/null +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/shared/viewmodels/SearchViewModel.kt @@ -0,0 +1,26 @@ +/* + * Copyright 2021-2024 Ona Systems, Inc + * + * 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 org.smartregister.fhircore.quest.ui.shared.viewmodels + +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import androidx.lifecycle.ViewModel +import org.smartregister.fhircore.quest.ui.shared.models.SearchQuery + +class SearchViewModel : ViewModel() { + val searchQuery: MutableState = mutableStateOf(SearchQuery.emptyText) +} diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/usersetting/UserInsightScreenFragment.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/usersetting/UserInsightScreenFragment.kt index c6100becda6..8eac2841426 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/usersetting/UserInsightScreenFragment.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/usersetting/UserInsightScreenFragment.kt @@ -53,10 +53,11 @@ class UserInsightScreenFragment : Fragment() { location = userSettingViewModel.practitionerLocation(), appVersionCode = userSettingViewModel.appVersionCode.toString(), appVersion = userSettingViewModel.appVersionName, - buildDate = userSettingViewModel.buildDate, + buildDate = userSettingViewModel.getBuildDate(), unsyncedResourcesFlow = userSettingViewModel.unsyncedResourcesMutableSharedFlow, navController = findNavController(), onRefreshRequest = { userSettingViewModel.fetchUnsyncedResources() }, + dateFormat = userSettingViewModel.getDateFormat(), ) } } diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/usersetting/UserSettingFragment.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/usersetting/UserSettingFragment.kt index 4a2ff3c5657..ad7169aa85e 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/usersetting/UserSettingFragment.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/usersetting/UserSettingFragment.kt @@ -23,7 +23,6 @@ import android.view.ViewGroup import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.padding import androidx.compose.material.Scaffold -import androidx.compose.material.SnackbarDuration import androidx.compose.material.rememberScaffoldState import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.livedata.observeAsState @@ -33,17 +32,14 @@ import androidx.compose.ui.platform.testTag import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels import androidx.fragment.app.viewModels -import androidx.lifecycle.lifecycleScope import androidx.navigation.findNavController import com.google.android.fhir.sync.CurrentSyncJobStatus import com.google.android.fhir.sync.SyncJobStatus +import com.google.android.fhir.sync.SyncOperation import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject -import kotlinx.coroutines.launch import org.smartregister.fhircore.engine.BuildConfig -import org.smartregister.fhircore.engine.R import org.smartregister.fhircore.engine.configuration.app.SettingsOptions -import org.smartregister.fhircore.engine.domain.model.SnackBarMessageConfig import org.smartregister.fhircore.engine.sync.OnSyncListener import org.smartregister.fhircore.engine.sync.SyncListenerManager import org.smartregister.fhircore.engine.ui.theme.AppTheme @@ -105,8 +101,9 @@ class UserSettingFragment : Fragment(), OnSyncListener { userSettingViewModel.progressBarState.observeAsState(Pair(false, 0)).value, isDebugVariant = BuildConfig.DEBUG, mainNavController = findNavController(), - lastSyncTime = userSettingViewModel.retrieveLastSyncTimestamp(), + lastSyncTime = appMainViewModel.getSyncTime(), showProgressIndicatorFlow = userSettingViewModel.showProgressIndicatorFlow, + dataMigrationVersion = userSettingViewModel.retrieveDataMigrationVersion(), enableManualSync = userSettingViewModel.enableMenuOption(SettingsOptions.MANUAL_SYNC), allowSwitchingLanguages = userSettingViewModel.allowSwitchingLanguages(), @@ -133,43 +130,19 @@ class UserSettingFragment : Fragment(), OnSyncListener { } override fun onSync(syncJobStatus: CurrentSyncJobStatus) { - when (syncJobStatus) { - is CurrentSyncJobStatus.Running -> - if (syncJobStatus.inProgressSyncJob is SyncJobStatus.Started) { - lifecycleScope.launch { - userSettingViewModel.emitSnackBarState( - SnackBarMessageConfig(message = getString(R.string.syncing)), - ) - } - } - is CurrentSyncJobStatus.Succeeded -> { - lifecycleScope.launch { - userSettingViewModel.emitSnackBarState( - SnackBarMessageConfig( - message = getString(R.string.sync_completed), - actionLabel = getString(R.string.ok).uppercase(), - duration = SnackbarDuration.Long, - ), - ) - } - } - is CurrentSyncJobStatus.Failed -> { - lifecycleScope.launch { - userSettingViewModel.emitSnackBarState( - SnackBarMessageConfig( - message = - getString( - R.string.sync_completed_with_errors, - ), - duration = SnackbarDuration.Long, - actionLabel = getString(R.string.ok).uppercase(), - ), - ) - } - } - else -> { - // Do nothing + if (syncJobStatus is CurrentSyncJobStatus.Running) { + if (syncJobStatus.inProgressSyncJob is SyncJobStatus.InProgress) { + val inProgressSyncJob = syncJobStatus.inProgressSyncJob as SyncJobStatus.InProgress + val isSyncUpload = inProgressSyncJob.syncOperation == SyncOperation.UPLOAD + val progressPercentage = appMainViewModel.calculatePercentageProgress(inProgressSyncJob) + appMainViewModel.updateAppDrawerUIState( + isSyncUpload = isSyncUpload, + currentSyncJobStatus = syncJobStatus, + percentageProgress = progressPercentage, + ) } + } else { + appMainViewModel.updateAppDrawerUIState(currentSyncJobStatus = syncJobStatus) } } diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/usersetting/UserSettingInsightScreen.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/usersetting/UserSettingInsightScreen.kt index 8b854185aa5..f6ed79d691b 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/usersetting/UserSettingInsightScreen.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/usersetting/UserSettingInsightScreen.kt @@ -72,6 +72,8 @@ import kotlinx.coroutines.flow.MutableSharedFlow import org.smartregister.fhircore.engine.R import org.smartregister.fhircore.engine.ui.theme.DividerColor import org.smartregister.fhircore.engine.ui.theme.LoginDarkColor +import org.smartregister.fhircore.engine.util.extension.DEFAULT_FORMAT_SDF_DD_MM_YYYY +import org.smartregister.fhircore.engine.util.extension.formatDate const val USER_INSIGHT_TOP_APP_BAR = "userInsightToAppBar" const val INSIGHT_UNSYNCED_DATA = "insightUnsyncedData" @@ -93,6 +95,7 @@ fun UserSettingInsightScreen( unsyncedResourcesFlow: MutableSharedFlow>>, navController: NavController, onRefreshRequest: () -> Unit, + dateFormat: String = DEFAULT_FORMAT_SDF_DD_MM_YYYY, ) { val unsyncedResources = unsyncedResourcesFlow.collectAsState(initial = listOf()).value @@ -235,7 +238,8 @@ fun UserSettingInsightScreen( (if (Build.DEVICE.isNullOrEmpty()) "-" else Build.DEVICE), stringResource(R.string.os_version) to (if (Build.VERSION.BASE_OS.isNullOrEmpty()) "-" else Build.VERSION.BASE_OS), - stringResource(R.string.device_date) to (formatTimestamp(Build.TIME).ifEmpty { "-" }), + stringResource(R.string.device_date) to + (formatDate(Build.TIME, desireFormat = dateFormat).ifEmpty { "-" }), ) InsightInfoView( title = stringResource(id = R.string.device_info), @@ -365,6 +369,7 @@ fun UserSettingInsightScreenPreview() { unsyncedResourcesFlow = MutableSharedFlow(), navController = rememberNavController(), onRefreshRequest = {}, + dateFormat = DEFAULT_FORMAT_SDF_DD_MM_YYYY, ) } } diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/usersetting/UserSettingScreen.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/usersetting/UserSettingScreen.kt index f145fff38b1..b141d041251 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/usersetting/UserSettingScreen.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/usersetting/UserSettingScreen.kt @@ -46,11 +46,11 @@ import androidx.compose.material.Scaffold import androidx.compose.material.Text import androidx.compose.material.TopAppBar import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.rounded.Logout import androidx.compose.material.icons.rounded.ChevronRight import androidx.compose.material.icons.rounded.DeleteForever import androidx.compose.material.icons.rounded.Insights -import androidx.compose.material.icons.rounded.Logout import androidx.compose.material.icons.rounded.Map import androidx.compose.material.icons.rounded.Phone import androidx.compose.material.icons.rounded.Share @@ -120,6 +120,7 @@ fun UserSettingScreen( onEvent: (UserSettingsEvent) -> Unit, mainNavController: NavController, appVersionPair: Pair? = null, + dataMigrationVersion: String, lastSyncTime: String?, showProgressIndicatorFlow: MutableStateFlow, enableManualSync: Boolean, @@ -141,7 +142,7 @@ fun UserSettingScreen( title = { Text(text = stringResource(R.string.settings)) }, navigationIcon = { IconButton(onClick = { mainNavController.popBackStack() }) { - Icon(Icons.Filled.ArrowBack, null) + Icon(Icons.AutoMirrored.Filled.ArrowBack, null) } }, contentColor = Color.White, @@ -220,7 +221,7 @@ fun UserSettingScreen( if (enableManualSync) { UserSettingRow( icon = Icons.Rounded.Sync, - text = stringResource(id = R.string.sync), + text = stringResource(id = R.string.manual_sync), clickListener = { onEvent(UserSettingsEvent.SyncData(context)) }, modifier = modifier.testTag(USER_SETTING_ROW_SYNC), ) @@ -356,7 +357,7 @@ fun UserSettingScreen( } UserSettingRow( - icon = Icons.Rounded.Logout, + icon = Icons.AutoMirrored.Rounded.Logout, text = stringResource(id = R.string.logout), clickListener = { onEvent(UserSettingsEvent.Logout(context)) }, modifier = modifier.testTag(USER_SETTING_ROW_LOGOUT), @@ -394,6 +395,15 @@ fun UserSettingScreen( modifier = modifier.padding(top = 8.dp).align(Alignment.CenterHorizontally), ) + if (dataMigrationVersion.toInt() > 0) { + Text( + color = contentColor, + fontSize = 16.sp, + text = stringResource(id = R.string.data_migration_version, dataMigrationVersion), + modifier = modifier.padding(top = 2.dp).align(Alignment.CenterHorizontally), + ) + } + Text( color = contentColor, fontSize = 16.sp, @@ -505,6 +515,7 @@ fun UserSettingPreview() { onEvent = {}, mainNavController = rememberNavController(), appVersionPair = Pair(1, "1.0.1"), + dataMigrationVersion = "0", lastSyncTime = "05:30 PM, Mar 3", showProgressIndicatorFlow = MutableStateFlow(false), enableManualSync = true, diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/usersetting/UserSettingViewModel.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/usersetting/UserSettingViewModel.kt index 286813b7f4e..8a5595fb2f1 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/usersetting/UserSettingViewModel.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/usersetting/UserSettingViewModel.kt @@ -29,7 +29,9 @@ import javax.inject.Inject import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext import org.smartregister.fhircore.engine.R import org.smartregister.fhircore.engine.configuration.ConfigType @@ -37,20 +39,23 @@ import org.smartregister.fhircore.engine.configuration.ConfigurationRegistry import org.smartregister.fhircore.engine.configuration.app.ApplicationConfiguration import org.smartregister.fhircore.engine.configuration.app.SettingsOptions import org.smartregister.fhircore.engine.data.remote.model.response.UserInfo +import org.smartregister.fhircore.engine.datastore.PreferenceDataStore import org.smartregister.fhircore.engine.domain.model.SnackBarMessageConfig import org.smartregister.fhircore.engine.sync.SyncBroadcaster import org.smartregister.fhircore.engine.util.DispatcherProvider import org.smartregister.fhircore.engine.util.SecureSharedPreference import org.smartregister.fhircore.engine.util.SharedPreferenceKey import org.smartregister.fhircore.engine.util.SharedPreferencesHelper +import org.smartregister.fhircore.engine.util.extension.SDF_YYYY_MMM_DD_HH_MM_SS +import org.smartregister.fhircore.engine.util.extension.countUnSyncedResources import org.smartregister.fhircore.engine.util.extension.fetchLanguages import org.smartregister.fhircore.engine.util.extension.getActivity import org.smartregister.fhircore.engine.util.extension.isDeviceOnline import org.smartregister.fhircore.engine.util.extension.launchActivityWithNoBackStackHistory +import org.smartregister.fhircore.engine.util.extension.reformatDate import org.smartregister.fhircore.engine.util.extension.refresh import org.smartregister.fhircore.engine.util.extension.setAppLocale import org.smartregister.fhircore.engine.util.extension.showToast -import org.smartregister.fhircore.engine.util.extension.spaceByUppercase import org.smartregister.fhircore.quest.BuildConfig import org.smartregister.fhircore.quest.navigation.MainNavigationScreen import org.smartregister.fhircore.quest.ui.appsetting.AppSettingActivity @@ -70,6 +75,7 @@ constructor( val configurationRegistry: ConfigurationRegistry, val workManager: WorkManager, val dispatcherProvider: DispatcherProvider, + private val preferenceDataStore: PreferenceDataStore, ) : ViewModel() { val languages by lazy { configurationRegistry.fetchLanguages() } @@ -85,7 +91,6 @@ constructor( val appVersionCode = BuildConfig.VERSION_CODE val appVersionName = BuildConfig.VERSION_NAME - val buildDate = BuildConfig.BUILD_DATE fun retrieveUsername(): String? = secureSharedPreference.retrieveSessionUsername() @@ -102,6 +107,10 @@ constructor( fun retrieveCareTeam() = sharedPreferencesHelper.read(SharedPreferenceKey.CARE_TEAM.name, null) + fun retrieveDataMigrationVersion(): String = runBlocking { + (preferenceDataStore.read(PreferenceDataStore.MIGRATION_VERSION).firstOrNull() ?: 0).toString() + } + fun retrieveLastSyncTimestamp(): String? = sharedPreferencesHelper.read(SharedPreferenceKey.LAST_SYNC_TIMESTAMP.name, null) @@ -147,7 +156,7 @@ constructor( is UserSettingsEvent.SwitchLanguage -> { sharedPreferencesHelper.write(SharedPreferenceKey.LANG.name, event.language.tag) event.context.run { - configurationRegistry.clearConfigsCache() + configurationRegistry.configCacheMap.clear() setAppLocale(event.language.tag) getActivity()?.refresh() } @@ -196,18 +205,20 @@ constructor( fun enabledDeviceToDeviceSync(): Boolean = applicationConfiguration.deviceToDeviceSync != null + fun getDateFormat() = applicationConfiguration.dateFormat + + fun getBuildDate() = + reformatDate( + inputDateString = BuildConfig.BUILD_DATE, + currentFormat = SDF_YYYY_MMM_DD_HH_MM_SS, + desiredFormat = applicationConfiguration.dateFormat, + ) + fun fetchUnsyncedResources() { viewModelScope.launch { withContext(dispatcherProvider.io()) { showProgressIndicatorFlow.emit(true) - val unsyncedResources = - fhirEngine - .getUnsyncedLocalChanges() - .distinctBy { it.resourceId } - .groupingBy { it.resourceType.spaceByUppercase() } - .eachCount() - .map { it.key to it.value } - + val unsyncedResources = fhirEngine.countUnSyncedResources() showProgressIndicatorFlow.emit(false) unsyncedResourcesMutableSharedFlow.emit(unsyncedResources) } diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/util/QrCodeScanUtils.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/util/QrCodeScanUtils.kt new file mode 100644 index 00000000000..62de1fb3962 --- /dev/null +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/util/QrCodeScanUtils.kt @@ -0,0 +1,39 @@ +/* + * Copyright 2021-2024 Ona Systems, Inc + * + * 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 org.smartregister.fhircore.quest.util + +import androidx.fragment.app.FragmentActivity +import org.smartregister.fhircore.quest.ui.sdc.qrCode.scan.QRCodeScannerDialogFragment + +object QrCodeScanUtils { + + const val QR_CODE_SCAN_UTILS_TAG = "QrCodeScanUtils" + + fun scanQrCode(lifecycleOwner: FragmentActivity, onQrCodeScanResult: (String?) -> Unit) { + lifecycleOwner.supportFragmentManager.apply { + setFragmentResultListener( + QRCodeScannerDialogFragment.RESULT_REQUEST_KEY, + lifecycleOwner, + ) { _, result -> + val barcode = result.getString(QRCodeScannerDialogFragment.RESULT_REQUEST_KEY) + onQrCodeScanResult.invoke(barcode) + } + + QRCodeScannerDialogFragment().show(this@apply, QR_CODE_SCAN_UTILS_TAG) + } + } +} diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/util/extensions/ConfigExtensions.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/util/extensions/ConfigExtensions.kt index 5fd84c5c0cc..19e1e44dbf8 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/util/extensions/ConfigExtensions.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/util/extensions/ConfigExtensions.kt @@ -20,14 +20,17 @@ import android.content.ClipData import android.content.ClipboardManager import android.content.Context import android.content.Intent +import android.graphics.Bitmap import android.net.Uri import android.widget.Toast +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.runtime.snapshots.SnapshotStateMap import androidx.core.content.ContextCompat import androidx.core.os.bundleOf import androidx.navigation.NavController import androidx.navigation.NavOptions -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.launch +import com.google.android.fhir.FhirEngine +import kotlin.collections.set import org.hl7.fhir.r4.model.Binary import org.smartregister.fhircore.engine.configuration.navigation.ICON_TYPE_REMOTE import org.smartregister.fhircore.engine.configuration.navigation.NavigationMenuConfig @@ -36,14 +39,14 @@ import org.smartregister.fhircore.engine.configuration.view.ColumnProperties import org.smartregister.fhircore.engine.configuration.view.ImageProperties import org.smartregister.fhircore.engine.configuration.view.ListProperties import org.smartregister.fhircore.engine.configuration.view.RowProperties +import org.smartregister.fhircore.engine.configuration.view.ServiceCardProperties +import org.smartregister.fhircore.engine.configuration.view.StackViewProperties import org.smartregister.fhircore.engine.configuration.view.ViewProperties import org.smartregister.fhircore.engine.configuration.workflow.ActionTrigger import org.smartregister.fhircore.engine.configuration.workflow.ApplicationWorkflow -import org.smartregister.fhircore.engine.data.local.register.RegisterRepository import org.smartregister.fhircore.engine.domain.model.ActionConfig import org.smartregister.fhircore.engine.domain.model.ActionParameter import org.smartregister.fhircore.engine.domain.model.ActionParameterType -import org.smartregister.fhircore.engine.domain.model.OverflowMenuItemConfig import org.smartregister.fhircore.engine.domain.model.ResourceData import org.smartregister.fhircore.engine.domain.model.ViewType import org.smartregister.fhircore.engine.util.extension.decodeJson @@ -52,10 +55,12 @@ import org.smartregister.fhircore.engine.util.extension.encodeJson import org.smartregister.fhircore.engine.util.extension.extractLogicalIdUuid import org.smartregister.fhircore.engine.util.extension.interpolate import org.smartregister.fhircore.engine.util.extension.isIn +import org.smartregister.fhircore.engine.util.extension.loadResource import org.smartregister.fhircore.engine.util.extension.showToast import org.smartregister.fhircore.quest.R import org.smartregister.fhircore.quest.navigation.MainNavigationScreen import org.smartregister.fhircore.quest.navigation.NavigationArg +import org.smartregister.fhircore.quest.ui.pdf.PdfLauncherFragment import org.smartregister.fhircore.quest.ui.shared.QuestionnaireHandler import org.smartregister.p2p.utils.startP2PScreen @@ -68,135 +73,170 @@ fun List.handleClickEvent( context: Context? = null, ) { val onClickAction = - this.find { it.trigger.isIn(ActionTrigger.ON_CLICK, ActionTrigger.ON_QUESTIONNAIRE_SUBMISSION) } + this.find { + it.trigger.isIn( + ActionTrigger.ON_SEARCH_SINGLE_RESULT, + ActionTrigger.ON_CLICK, + ActionTrigger.ON_QUESTIONNAIRE_SUBMISSION, + ) + } - onClickAction?.let { theConfig -> - val computedValuesMap = resourceData?.computedValuesMap ?: emptyMap() - val actionConfig = theConfig.interpolate(computedValuesMap) - val interpolatedParams = interpolateActionParamsValue(actionConfig, resourceData) - val practitionerId = - interpolatedParams - .find { it.paramType == ActionParameterType.RESOURCE_ID && it.key == PRACTITIONER_ID } - ?.value - val resourceId = - interpolatedParams.find { it.paramType == ActionParameterType.RESOURCE_ID }?.value - ?: resourceData?.baseResourceId - when (actionConfig.workflow?.let { ApplicationWorkflow.valueOf(it) }) { - ApplicationWorkflow.LAUNCH_QUESTIONNAIRE -> { - actionConfig.questionnaire?.let { questionnaireConfig -> - val questionnaireConfigInterpolated = questionnaireConfig.interpolate(computedValuesMap) + onClickAction?.handleClickEvent(navController, resourceData, navMenu, context) +} - // Questionnaire is NOT launched via navigation component. It is started for result. - if (navController.context is QuestionnaireHandler) { - (navController.context as QuestionnaireHandler).launchQuestionnaire( - context = navController.context, - questionnaireConfig = questionnaireConfigInterpolated, - actionParams = interpolatedParams, - ) - } - } - } - ApplicationWorkflow.LAUNCH_PROFILE -> { - actionConfig.id?.let { id -> - val args = - bundleOf( - NavigationArg.PROFILE_ID to id, - NavigationArg.RESOURCE_ID to resourceId, - NavigationArg.RESOURCE_CONFIG to actionConfig.resourceConfig, - NavigationArg.PARAMS to interpolatedParams.toTypedArray(), - ) - val navOptions = - when (actionConfig.popNavigationBackStack) { - false, - null, -> null - true -> - navController.currentDestination?.id?.let { currentDestId -> - navOptions(resId = currentDestId, inclusive = true) - } - } - navController.navigate( - resId = MainNavigationScreen.Profile.route, - args = args, - navOptions = navOptions, +fun ActionConfig.handleClickEvent( + navController: NavController, + resourceData: ResourceData? = null, + navMenu: NavigationMenuConfig? = null, + context: Context? = null, +) { + val computedValuesMap = resourceData?.computedValuesMap ?: emptyMap() + val actionConfig = interpolate(computedValuesMap) + val interpolatedParams = interpolateActionParamsValue(actionConfig, resourceData) + val practitionerId = + interpolatedParams + .find { it.paramType == ActionParameterType.RESOURCE_ID && it.key == PRACTITIONER_ID } + ?.value + val resourceId = + interpolatedParams.find { it.paramType == ActionParameterType.RESOURCE_ID }?.value + ?: resourceData?.baseResourceId + when (actionConfig.workflow?.let { ApplicationWorkflow.valueOf(it) }) { + ApplicationWorkflow.LAUNCH_QUESTIONNAIRE -> { + actionConfig.questionnaire?.let { questionnaireConfig -> + val questionnaireConfigInterpolated = questionnaireConfig.interpolate(computedValuesMap) + + // Questionnaire is NOT launched via navigation component. It is started for result. + if (navController.context is QuestionnaireHandler) { + (navController.context as QuestionnaireHandler).launchQuestionnaire( + context = navController.context, + questionnaireConfig = questionnaireConfigInterpolated, + actionParams = interpolatedParams, ) } } - ApplicationWorkflow.LAUNCH_REGISTER -> { + } + ApplicationWorkflow.LAUNCH_PROFILE -> { + actionConfig.id?.let { id -> val args = bundleOf( - Pair(NavigationArg.REGISTER_ID, actionConfig.id ?: navMenu?.id), - Pair(NavigationArg.SCREEN_TITLE, actionConfig.display ?: navMenu?.display ?: ""), - Pair(NavigationArg.TOOL_BAR_HOME_NAVIGATION, actionConfig.toolBarHomeNavigation), - Pair(NavigationArg.PARAMS, interpolatedParams.toTypedArray()), + NavigationArg.PROFILE_ID to id, + NavigationArg.RESOURCE_ID to resourceId, + NavigationArg.RESOURCE_CONFIG to actionConfig.resourceConfig, + NavigationArg.PARAMS to interpolatedParams.toTypedArray(), ) + val navOptions = + when (actionConfig.popNavigationBackStack) { + false, + null, -> null + true -> + navController.currentDestination?.id?.let { currentDestId -> + navOptions(resId = currentDestId, inclusive = true) + } + } + navController.navigate( + resId = MainNavigationScreen.Profile.route, + args = args, + navOptions = navOptions, + ) + } + } + ApplicationWorkflow.LAUNCH_REGISTER -> { + val args = + bundleOf( + Pair(NavigationArg.REGISTER_ID, actionConfig.id ?: navMenu?.id), + Pair(NavigationArg.SCREEN_TITLE, actionConfig.display ?: navMenu?.display ?: ""), + Pair(NavigationArg.TOOL_BAR_HOME_NAVIGATION, actionConfig.toolBarHomeNavigation), + Pair(NavigationArg.PARAMS, interpolatedParams.toTypedArray()), + ) - // If value != null, we are navigating FROM a register; disallow same register navigation - val currentRegisterId = - navController.currentBackStackEntry?.arguments?.getString(NavigationArg.REGISTER_ID) - val sameRegisterNavigation = - args.getString(NavigationArg.REGISTER_ID) == - navController.previousBackStackEntry?.arguments?.getString(NavigationArg.REGISTER_ID) + // If value != null, we are navigating FROM a register; disallow same register navigation + val currentRegisterId = + navController.currentBackStackEntry?.arguments?.getString(NavigationArg.REGISTER_ID) + val sameRegisterNavigation = + args.getString(NavigationArg.REGISTER_ID) == + navController.previousBackStackEntry?.arguments?.getString(NavigationArg.REGISTER_ID) - if (!currentRegisterId.isNullOrEmpty() && sameRegisterNavigation) { - return - } else { - navController.navigate( - resId = MainNavigationScreen.Home.route, - args = args, - navOptions = - navController.currentDestination?.id?.let { - navOptions(resId = it, inclusive = actionConfig.popNavigationBackStack == true) - }, - ) - } + if (!currentRegisterId.isNullOrEmpty() && sameRegisterNavigation) { + return + } else { + navController.navigate( + resId = MainNavigationScreen.Home.route, + args = args, + navOptions = + navController.currentDestination?.id?.let { + navOptions(resId = it, inclusive = actionConfig.popNavigationBackStack != false) + }, + ) } - ApplicationWorkflow.LAUNCH_REPORT -> { - val args = - bundleOf( - Pair(NavigationArg.REPORT_ID, actionConfig.id), - Pair(NavigationArg.RESOURCE_ID, practitionerId?.extractLogicalIdUuid() ?: ""), - ) + } + ApplicationWorkflow.LAUNCH_REPORT -> { + val args = + bundleOf( + Pair(NavigationArg.REPORT_ID, actionConfig.id), + Pair(NavigationArg.RESOURCE_ID, practitionerId?.extractLogicalIdUuid() ?: ""), + ) - navController.navigate(MainNavigationScreen.Reports.route, args) - } - ApplicationWorkflow.LAUNCH_SETTINGS -> - navController.navigate(MainNavigationScreen.Settings.route) - ApplicationWorkflow.LAUNCH_INSIGHT_SCREEN -> - navController.navigate(MainNavigationScreen.Insight.route) - ApplicationWorkflow.DEVICE_TO_DEVICE_SYNC -> startP2PScreen(navController.context) - ApplicationWorkflow.LAUNCH_MAP -> + navController.navigate(MainNavigationScreen.Reports.route, args) + } + ApplicationWorkflow.LAUNCH_SETTINGS -> + navController.navigate(MainNavigationScreen.Settings.route) + ApplicationWorkflow.LAUNCH_INSIGHT_SCREEN -> + navController.navigate(MainNavigationScreen.Insight.route) + ApplicationWorkflow.DEVICE_TO_DEVICE_SYNC -> startP2PScreen(navController.context) + ApplicationWorkflow.LAUNCH_MAP -> { + val args = bundleOf(NavigationArg.GEO_WIDGET_ID to actionConfig.id) + // If value != null, we are navigating FROM a map; disallow same map navigation + val currentGeoWidgetId = + navController.currentBackStackEntry?.arguments?.getString(NavigationArg.GEO_WIDGET_ID) + val sameGeoWidgetNavigation = + args.getString(NavigationArg.GEO_WIDGET_ID) == + navController.previousBackStackEntry?.arguments?.getString(NavigationArg.GEO_WIDGET_ID) + if (!currentGeoWidgetId.isNullOrEmpty() && sameGeoWidgetNavigation) { + return + } else { navController.navigate( - MainNavigationScreen.GeoWidgetLauncher.route, - bundleOf(NavigationArg.GEO_WIDGET_ID to actionConfig.id), + resId = MainNavigationScreen.GeoWidgetLauncher.route, + args = args, + navOptions = + navController.currentDestination?.id?.let { + navOptions(resId = it, inclusive = actionConfig.popNavigationBackStack != false) + }, ) - ApplicationWorkflow.LAUNCH_DIALLER -> { - val actionParameter = interpolatedParams.first() - val patientPhoneNumber = actionParameter.value - val intent = Intent(Intent.ACTION_DIAL) - intent.data = Uri.parse("tel:$patientPhoneNumber") - ContextCompat.startActivity(navController.context, intent, null) } - ApplicationWorkflow.COPY_TEXT -> { - val copyTextActionParameter = interpolatedParams.first() - val clipboardManager = - context?.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager - val clipData = ClipData.newPlainText(null, copyTextActionParameter.value) - clipboardManager.setPrimaryClip(clipData) - context.showToast( - context.getString(R.string.copy_text_success_message, copyTextActionParameter.value), - Toast.LENGTH_LONG, + } + ApplicationWorkflow.LAUNCH_DIALLER -> { + val actionParameter = interpolatedParams.first() + val phoneNumber = actionParameter.value + val intent = Intent(Intent.ACTION_DIAL) + intent.data = Uri.parse("tel:$phoneNumber") + ContextCompat.startActivity(navController.context, intent, null) + } + ApplicationWorkflow.COPY_TEXT -> { + val copyTextActionParameter = interpolatedParams.first() + val clipboardManager = + context?.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + val clipData = ClipData.newPlainText(null, copyTextActionParameter.value) + clipboardManager.setPrimaryClip(clipData) + context.showToast( + context.getString(R.string.copy_text_success_message, copyTextActionParameter.value), + Toast.LENGTH_LONG, + ) + } + ApplicationWorkflow.LAUNCH_LOCATION_SELECTOR -> { + val args = + bundleOf( + NavigationArg.SCREEN_TITLE to (actionConfig.display ?: navMenu?.display ?: ""), + NavigationArg.MULTI_SELECT_VIEW_CONFIG to actionConfig.multiSelectViewConfig, ) - } - ApplicationWorkflow.LAUNCH_LOCATION_SELECTOR -> { - val args = - bundleOf( - NavigationArg.SCREEN_TITLE to (actionConfig.display ?: navMenu?.display ?: ""), - NavigationArg.MULTI_SELECT_VIEW_CONFIG to actionConfig.multiSelectViewConfig, - ) - navController.navigate(MainNavigationScreen.LocationSelector.route, args) - } - else -> return + navController.navigate(MainNavigationScreen.LocationSelector.route, args) + } + ApplicationWorkflow.LAUNCH_PDF_GENERATION -> { + val pdfConfig = actionConfig.pdfConfig ?: return + val interpolatedPdfConfig = pdfConfig.interpolate(computedValuesMap) + val appCompatActivity = (navController.context as AppCompatActivity) + PdfLauncherFragment.launch(appCompatActivity, interpolatedPdfConfig.encodeJson()) } + else -> return } } @@ -221,77 +261,53 @@ fun Array?.toParamDataMap(): Map = ?.filter { it.paramType == ActionParameterType.PARAMDATA } ?.associate { it.key to it.value } ?: emptyMap() -fun List.decodeBinaryResourcesToBitmap( - coroutineScope: CoroutineScope, - registerRepository: RegisterRepository, -) { - this.forEach { - val resourceId = it.icon!!.reference!!.extractLogicalIdUuid() - coroutineScope.launch() { - registerRepository.loadResource(resourceId)?.let { binary -> - it.icon!!.decodedBitmap = binary.data.decodeToBitmap() - } +suspend fun String.referenceToBitmap( + fhirEngine: FhirEngine, + decodedImageMap: SnapshotStateMap, + forceRefresh: Boolean = false, +): Bitmap? { + val resourceId = this.extractLogicalIdUuid() + if (!decodedImageMap.containsKey(resourceId) || forceRefresh) { + fhirEngine.loadResource(resourceId)?.let { binary -> + binary.data.decodeToBitmap()?.let { bitmap -> decodedImageMap[resourceId] = bitmap } } } + return decodedImageMap[resourceId] } -fun Sequence.decodeBinaryResourcesToBitmap( - coroutineScope: CoroutineScope, - registerRepository: RegisterRepository, +suspend fun List.decodeImageResourcesToBitmap( + fhirEngine: FhirEngine, + decodedImageMap: MutableMap, ) { - this.forEach { - val resourceId = it.menuIconConfig!!.reference!!.extractLogicalIdUuid() - coroutineScope.launch() { - registerRepository.loadResource(resourceId)?.let { binary -> - it.menuIconConfig!!.decodedBitmap = binary.data.decodeToBitmap() - } - } - } -} - -suspend fun loadRemoteImagesBitmaps( - views: List, - registerRepository: RegisterRepository, - computedValuesMap: Map, -) { - suspend fun ViewProperties.loadIcons() { - when (this.viewType) { + val queue = ArrayDeque(this) + while (queue.isNotEmpty()) { + val viewProperty = queue.removeFirst() + when (viewProperty.viewType) { ViewType.IMAGE -> { - val imageProps = this as ImageProperties - if ( - !imageProps.imageConfig?.reference.isNullOrEmpty() && - imageProps.imageConfig?.type == ICON_TYPE_REMOTE - ) { - val resourceId = - imageProps.imageConfig!! - .reference!! - .interpolate(computedValuesMap) - .extractLogicalIdUuid() - registerRepository.loadResource(resourceId)?.let { binary -> - imageProps.imageConfig?.decodedBitmap = binary.data.decodeToBitmap() + val imageProperties = (viewProperty as ImageProperties) + if (imageProperties.imageConfig != null) { + val imageConfig = imageProperties.imageConfig + if ( + ICON_TYPE_REMOTE.equals(imageConfig?.type, ignoreCase = true) && + !imageConfig?.reference.isNullOrBlank() + ) { + val resourceId = imageConfig!!.reference!! + fhirEngine.loadResource(resourceId)?.let { binary: Binary -> + binary.data.decodeToBitmap()?.let { bitmap -> decodedImageMap[resourceId] = bitmap } + } } } } - ViewType.ROW -> { - val container = this as RowProperties - container.children.forEach { it.loadIcons() } - } - ViewType.COLUMN -> { - val container = this as ColumnProperties - container.children.forEach { it.loadIcons() } - } - ViewType.CARD -> { - val card = this as CardViewProperties - card.content.forEach { it.loadIcons() } - } - ViewType.LIST -> { - val list = this as ListProperties - list.registerCard.views.forEach { it.loadIcons() } - } + ViewType.COLUMN -> (viewProperty as ColumnProperties).children.forEach(queue::addLast) + ViewType.ROW -> (viewProperty as RowProperties).children.forEach(queue::addLast) + ViewType.SERVICE_CARD -> + (viewProperty as ServiceCardProperties).details.forEach(queue::addLast) + ViewType.CARD -> (viewProperty as CardViewProperties).content.forEach(queue::addLast) + ViewType.LIST -> (viewProperty as ListProperties).registerCard.views.forEach(queue::addLast) + ViewType.STACK -> (viewProperty as StackViewProperties).children.forEach(queue::addLast) else -> { - // Handle any other view types if needed + /** Ignore other views that cannot display images* */ } } } - views.forEach { it.loadIcons() } } diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/util/extensions/WorkManagerExtensions.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/util/extensions/WorkManagerExtensions.kt index 2a24f750f43..3c225c49354 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/util/extensions/WorkManagerExtensions.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/util/extensions/WorkManagerExtensions.kt @@ -37,6 +37,7 @@ inline fun WorkManager.schedulePeriodically( existingPeriodicWorkPolicy: ExistingPeriodicWorkPolicy = ExistingPeriodicWorkPolicy.KEEP, requiresNetwork: Boolean = true, inputData: Data = workDataOf(), + initialDelay: Long? = null, ) { val constraint = Constraints.Builder() @@ -55,7 +56,7 @@ inline fun WorkManager.schedulePeriodically( workId, existingPeriodicWorkPolicy, workRequestBuilder - .setInitialDelay(repeatInterval, timeUnit) + .setInitialDelay(initialDelay ?: repeatInterval, timeUnit) .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS) .setConstraints(constraint) .setInputData(inputData) diff --git a/android/quest/src/main/res/drawable-hdpi/ic_scan_qr_viewfinder.png b/android/quest/src/main/res/drawable-hdpi/ic_scan_qr_viewfinder.png new file mode 100644 index 00000000000..063c1f5f487 Binary files /dev/null and b/android/quest/src/main/res/drawable-hdpi/ic_scan_qr_viewfinder.png differ diff --git a/android/quest/src/main/res/drawable-mdpi/ic_scan_qr_viewfinder.png b/android/quest/src/main/res/drawable-mdpi/ic_scan_qr_viewfinder.png new file mode 100644 index 00000000000..d76359263b0 Binary files /dev/null and b/android/quest/src/main/res/drawable-mdpi/ic_scan_qr_viewfinder.png differ diff --git a/android/quest/src/main/res/drawable-xhdpi/ic_scan_qr_viewfinder.png b/android/quest/src/main/res/drawable-xhdpi/ic_scan_qr_viewfinder.png new file mode 100644 index 00000000000..2537f0b9528 Binary files /dev/null and b/android/quest/src/main/res/drawable-xhdpi/ic_scan_qr_viewfinder.png differ diff --git a/android/quest/src/main/res/drawable-xxhdpi/ic_scan_qr_viewfinder.png b/android/quest/src/main/res/drawable-xxhdpi/ic_scan_qr_viewfinder.png new file mode 100644 index 00000000000..da085ae8cb7 Binary files /dev/null and b/android/quest/src/main/res/drawable-xxhdpi/ic_scan_qr_viewfinder.png differ diff --git a/android/quest/src/main/res/drawable-xxxhdpi/ic_scan_qr_viewfinder.png b/android/quest/src/main/res/drawable-xxxhdpi/ic_scan_qr_viewfinder.png new file mode 100644 index 00000000000..b3fc69c15b5 Binary files /dev/null and b/android/quest/src/main/res/drawable-xxxhdpi/ic_scan_qr_viewfinder.png differ diff --git a/android/quest/src/main/res/drawable/ic_qr_code.xml b/android/quest/src/main/res/drawable/ic_qr_code.xml new file mode 100644 index 00000000000..519f19de697 --- /dev/null +++ b/android/quest/src/main/res/drawable/ic_qr_code.xml @@ -0,0 +1,13 @@ + + + + diff --git a/android/quest/src/main/res/drawable/ic_workshop.xml b/android/quest/src/main/res/drawable/ic_workshop.xml new file mode 100644 index 00000000000..b35a4a54af0 --- /dev/null +++ b/android/quest/src/main/res/drawable/ic_workshop.xml @@ -0,0 +1,13 @@ + + + + diff --git a/android/quest/src/main/res/layout/activity_main.xml b/android/quest/src/main/res/layout/activity_main.xml index 7be48fd5c74..bda72a47881 100644 --- a/android/quest/src/main/res/layout/activity_main.xml +++ b/android/quest/src/main/res/layout/activity_main.xml @@ -1,13 +1,43 @@ - - - + android:layout_height="match_parent"> + + + + + + - + + diff --git a/android/quest/src/main/res/layout/add_qr_codes_widget_view.xml b/android/quest/src/main/res/layout/add_qr_codes_widget_view.xml new file mode 100644 index 00000000000..8fc4da13882 --- /dev/null +++ b/android/quest/src/main/res/layout/add_qr_codes_widget_view.xml @@ -0,0 +1,76 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/quest/src/main/res/layout/edit_text_qr_code_view.xml b/android/quest/src/main/res/layout/edit_text_qr_code_view.xml new file mode 100644 index 00000000000..d4163456145 --- /dev/null +++ b/android/quest/src/main/res/layout/edit_text_qr_code_view.xml @@ -0,0 +1,29 @@ + + + + + + + + + + diff --git a/android/quest/src/main/res/layout/edit_text_single_line_qr_code_item_view.xml b/android/quest/src/main/res/layout/edit_text_single_line_qr_code_item_view.xml new file mode 100644 index 00000000000..17268a7655f --- /dev/null +++ b/android/quest/src/main/res/layout/edit_text_single_line_qr_code_item_view.xml @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + diff --git a/android/quest/src/main/res/layout/fragment_camera_permission.xml b/android/quest/src/main/res/layout/fragment_camera_permission.xml new file mode 100644 index 00000000000..66b62c9e24e --- /dev/null +++ b/android/quest/src/main/res/layout/fragment_camera_permission.xml @@ -0,0 +1,17 @@ + + + + + + diff --git a/android/quest/src/main/res/layout/fragment_geo_widget_launcher.xml b/android/quest/src/main/res/layout/fragment_geo_widget_launcher.xml index ef02a4594cc..83ab4652ad3 100644 --- a/android/quest/src/main/res/layout/fragment_geo_widget_launcher.xml +++ b/android/quest/src/main/res/layout/fragment_geo_widget_launcher.xml @@ -4,7 +4,7 @@ xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" - tools:context=".ui.launcher.GeoWidgetLauncherFragment" + tools:context=".ui.geowidget.GeoWidgetLauncherFragment" > + + + + + + + + + + + + + + + + + + + diff --git a/android/quest/src/main/res/layout/questionnaire_item_bar_code_reader_view.xml b/android/quest/src/main/res/layout/questionnaire_item_bar_code_reader_view.xml new file mode 100644 index 00000000000..e4310ea265f --- /dev/null +++ b/android/quest/src/main/res/layout/questionnaire_item_bar_code_reader_view.xml @@ -0,0 +1,99 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/quest/src/main/res/navigation/application_nav_graph.xml b/android/quest/src/main/res/navigation/application_nav_graph.xml index cbe1d0e3d8a..21bf25e2a1f 100644 --- a/android/quest/src/main/res/navigation/application_nav_graph.xml +++ b/android/quest/src/main/res/navigation/application_nav_graph.xml @@ -6,7 +6,7 @@ + android:name="org.smartregister.fhircore.quest.ui.geowidget.GeoWidgetLauncherFragment"> + Retour à la liste des clients Résultat du test @@ -85,7 +85,8 @@ Enregistrer comme ANC Résultats de la grossesse Registres - Pas d\'emplacement défini + Aucun emplacement défini. + Aucun emplacement à afficher sur la carte. Définir l\'emplacement pour synchroniser les données et charger les points de service Définir l\'emplacement @@ -99,6 +100,9 @@ Questionnaire introuvable, synchroniser tous les questionnaires pour régler ce problème Pas de visites Réponse du questionnaire invalide + La validation des ressources extraites a échoué. Veuillez vérifier les journaux. + Une erreur est survenue lors de la génération du CarePlan. Veuillez vérifier les journaux. + Version de l\'application Type de sujet manquant dans le questionnaire. Fournir Questionnaire.subjectType pour résoudre le problème. QuestionnaireConfig est requis mais manquant Erreur dans le remplissage de certains champs du questionnaire. Réponse au questionnaire non valide. @@ -112,5 +116,163 @@ Les services de localisation sont désactivés. Souhaitez-vous les activer ? Lien %1$s copié avec succès Soumettre + La ressource de base pour la configuration du GeoWidget DOIT être l\'emplacement. + Aucune configuration fournie pour la barre de recherche. + Aucun emplacement trouvé correspondant au texte \"%1$s\" + code_QR + Demande d\'autorisation de caméra refusée. Le code-barres pourrait ne pas fonctionner comme prévu. + Placez votre caméra sur le code QR pour commencer à scanner. + Ajouter un code QR + Scanner le code QR + Placez votre caméra sur l\'intégralité du code QR pour commencer à scanner. + Échec de récupération de la position GPS. + Quitter l\'application + Êtes-vous sûr de vouloir quitter l\'application ? + Configuration de vue multi-sélection manquante. Veuillez fournir les configurations pour que la vue puisse être rendue. + \"%1$d hors de%2$d L\'emplacement (ou les emplacements) n\'ont pas de coordonnées\" + Tous Les emplacements ont été rendus avec succès\" + %1$d Les emplacements correspondants ont été rendus avec succès. + Annuler l\'ajout de l\'emplacement + + + + + + + Suivant + Précédent + Retour + Modifier + Revoir les réponses + Revoir + Annuler + + + Erreurs trouvées + Répondez aux questions suivantes : + %s + Soumettre de toute façon + Répondre aux questions + + + Voulez-vous quitter le questionnaire \? + + + + + \"Non répondu\" + Aide + + + + + + + - + + + Autre + Saisissez une option personnalisée + Ajouter une autre réponse + Cochez toutes les cases qui s\'appliquent + Sauvegardez + Annuler + + + Date + Heure + + Sélectionner l\'heure + + + Prendre une photo + Aperçu des photos + Aperçu de l\'icône du fichier + Télécharger la photo + Télécharger l\'audio + Télécharger la vidéo + Télécharger le document + Télécharger le fichier + Effacer + Erreur : La taille de l\'image est supérieure à %1$s MB + Erreur : La taille du fichier est supérieure à %1$s MB + Échec du téléchargement + Erreur : Mauvais format de support + Téléchargé + Image téléchargée + Fichier téléchargé + Vidéo téléchargée + Audio téléchargée + Image supprimée + Fichier supprimé + Vidéo supprimée + Audio supprimé + + + Ajouter un article + + + + + Réponse manquante pour le champ obligatoire. + La valeur minimale autorisée est de%1$s : + La valeur maximale autorisée est de %1$s: + Le nombre minimum de caractères autorisés dans la réponse est le suivant : %1$s + Le nombre maximum de décimales autorisées dans la réponse est de : %1$s + La réponse ne correspond pas à l\'expression régulière : %1$s + N\'utilisez que (.) entre deux nombres. Les autres caractères spéciaux ne sont pas pris en charge. + Le format de la date doit être %1$s (ex. %2$s) + Le nombre doit être compris entre %1$s et %2$s + Numéro invalide + Optionnel + Requis + Requis\n + Ajouter %1$s + + diff --git a/android/quest/src/main/res/values-in/strings.xml b/android/quest/src/main/res/values-in/strings.xml index 1e5339a53cf..e77b34ba800 100644 --- a/android/quest/src/main/res/values-in/strings.xml +++ b/android/quest/src/main/res/values-in/strings.xml @@ -13,8 +13,6 @@ Tidak ada hasil tes ditemukan HASIL TES Tes terakhir - %1$s - 2.4 km - # Tugas Keluarga Kunjungan rutin @@ -88,11 +86,8 @@ Luaran Kehamilan Register - %1$s (%2$s) Dijadwalkan pada %1$s - GeoWidget Fragment Destination - Gagal mengekstraksi resources untuk %1$s StructureMap untuk Questionnaire tidak ada, QuestionnaireResponse disimpan Resources berhasil diekstraksi untuk %1$s @@ -101,7 +96,6 @@ Questionnaire tidak ditemukan. Sinkronkan semua kuesioner untuk memperbaiki Tidak ada kunjungan Respons kuesioner tidak valid - https://smartregister.org/app-version Versi aplikasi Jenis subjek pada kuesioner tidak ada. Berikan Questionnaire.subjectType untuk diselesaikan. QuestionnaireConfig diperlukan tetapi tidak ada. diff --git a/android/quest/src/main/res/values-sw/strings.xml b/android/quest/src/main/res/values-sw/strings.xml index d81539309a3..4d8a8be8f78 100644 --- a/android/quest/src/main/res/values-sw/strings.xml +++ b/android/quest/src/main/res/values-sw/strings.xml @@ -73,4 +73,5 @@ Ramani ya Muundo Haipo kwa Hojaji , HojajiJibu limehifadhiwa Imetoa rasilimali za %1$s Imeshindwa kutoa nyenzo za dodoso %1$s + Futa yote diff --git a/android/quest/src/main/res/values/strings.xml b/android/quest/src/main/res/values/strings.xml index 1f41db64a97..113c8e6d31c 100644 --- a/android/quest/src/main/res/values/strings.xml +++ b/android/quest/src/main/res/values/strings.xml @@ -87,7 +87,8 @@ Record as ANC Pregnancy Outcome Registers - No Location Set + No location Set + No locations to render on map Set location to sync data and load service points Set location @@ -104,8 +105,10 @@ Questionnaire not found. Sync all questionnaires to fix No visits Questionnaire response invalid + Validation on extracted resources failed. Please check the logs + An error occurred generating CarePlan. Please check the logs https://smartregister.org/app-version - Application Version + Application Version Missing subject type on questionnaire. Provide Questionnaire.subjectType to resolve. QuestionnaireConfig is required but missing. Error populating some questionnaire fields. Invalid QuestionnaireResponse. @@ -119,5 +122,23 @@ Location services are disabled. Do you want to enable them? Link %1$s copied successfully Submit - + The base resource for GeoWidgetConfiguration MUST be Location + No configs provided for search bar + No location found matching text \"%1$s\" + Other + qr_code + Camera permission request denied. Barcode may not work as expected + Place your camera over the QR Code to start scanning + Add QR code + Scan QR Code + Place your camera over the entire QR Code to start scanning + Failed to get GPS location + \u0020\u002a + Exit App + Are you sure you want to exit from the app? + Missing required multi select view configs. Please provide the configurations for the view to render. + "%1$d out of %2$d Location(s) have no coordinates" + All the locations rendered successfully" + %1$d matching location(s) rendered successfully" + Cancel adding location diff --git a/android/quest/src/main/res/xml/data_extraction_rules.xml b/android/quest/src/main/res/xml/data_extraction_rules.xml new file mode 100644 index 00000000000..805fc3affab --- /dev/null +++ b/android/quest/src/main/res/xml/data_extraction_rules.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/android/quest/src/minsaEir/res/drawable/ic_app_logo.xml b/android/quest/src/minsaEir/res/drawable/ic_app_logo.xml new file mode 100644 index 00000000000..2d6821f2da3 --- /dev/null +++ b/android/quest/src/minsaEir/res/drawable/ic_app_logo.xml @@ -0,0 +1,36 @@ + + + + + + + + + + + + diff --git a/android/quest/src/vamosJuntos/res/drawable/ic_launcher.xml b/android/quest/src/minsaEir/res/drawable/ic_launcher.xml similarity index 100% rename from android/quest/src/vamosJuntos/res/drawable/ic_launcher.xml rename to android/quest/src/minsaEir/res/drawable/ic_launcher.xml diff --git a/android/quest/src/test/assets/configs/app/application_config.json b/android/quest/src/test/assets/configs/app/application_config.json index 85a96d76b35..bc04e9fc7da 100644 --- a/android/quest/src/test/assets/configs/app/application_config.json +++ b/android/quest/src/test/assets/configs/app/application_config.json @@ -29,6 +29,54 @@ "QuestionnaireResponse" ] }, + "eventWorkflows": [ + { + "eventType": "RESOURCE_CLOSURE", + "triggerConditions": [ + { + "eventResourceId": "encounterToBeClosed", + "matchAll": false, + "conditionalFhirPathExpressions": [ + "true" + ] + } + ], + "eventResources": [ + { + "id": "encounterToBeClosed", + "resource": "Encounter", + "configRules": [], + "dataQueries": [ + { + "paramName": "reason-code", + "filterCriteria": [ + { + "dataType": "CODEABLECONCEPT", + "value": { + "system": "http://smartregister.org/", + "code": "SVISIT" + } + } + ] + } + ] + } + ], + "updateValues": [ + { + "jsonPathExpression": "Encounter.status", + "value": "finished", + "resourceType": "Encounter" + } + ], + "resourceFilterExpression": { + "conditionalFhirPathExpressions": [ + "Encounter.period.end < now()" + ], + "matchAll": true + } + } + ], "logGpsLocation": [ "QUESTIONNAIRE" ] diff --git a/android/quest/src/test/java/org/smartregister/fhircore/quest/CqlContentTest.kt b/android/quest/src/test/java/org/smartregister/fhircore/quest/CqlContentTest.kt index d9724ab580f..5b14d4dd223 100644 --- a/android/quest/src/test/java/org/smartregister/fhircore/quest/CqlContentTest.kt +++ b/android/quest/src/test/java/org/smartregister/fhircore/quest/CqlContentTest.kt @@ -23,6 +23,9 @@ import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidTest import java.io.File import javax.inject.Inject +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest import org.hl7.fhir.r4.model.Bundle import org.hl7.fhir.r4.model.Library import org.hl7.fhir.r4.model.Parameters @@ -37,9 +40,9 @@ import org.smartregister.fhircore.engine.util.extension.encodeResourceToString import org.smartregister.fhircore.engine.util.extension.valueToString import org.smartregister.fhircore.quest.robolectric.RobolectricTest import org.smartregister.fhircore.quest.sdk.CqlBuilder -import org.smartregister.fhircore.quest.sdk.runBlockingOnWorkerThread @HiltAndroidTest +@OptIn(ExperimentalCoroutinesApi::class) class CqlContentTest : RobolectricTest() { @get:Rule var hiltRule = HiltAndroidRule(this) @@ -58,112 +61,115 @@ class CqlContentTest : RobolectricTest() { } @Test - fun runCqlLibraryTestForPqMedication() = runBlockingOnWorkerThread { - val resourceDir = "cql/pq-medication" - val cql = "$resourceDir/cql.txt".readFile() - - val cqlLibrary = buildCqlLibrary(cql) - - val dataBundle = - loadTestResultsSampleData().apply { - // output of test results cql is also added to input of this cql - "cql/test-results/sample" - .readDir() - .map { it.parseSampleResource() as Resource } - .forEach { addEntry().apply { resource = it } } - } + fun runCqlLibraryTestForPqMedication() = + runTest(context = UnconfinedTestDispatcher()) { + val resourceDir = "cql/pq-medication" + val cql = "$resourceDir/cql.txt".readFile() + + val cqlLibrary = buildCqlLibrary(cql) + + val dataBundle = + loadTestResultsSampleData().apply { + // output of test results cql is also added to input of this cql + "cql/test-results/sample" + .readDir() + .map { it.parseSampleResource() as Resource } + .forEach { addEntry().apply { resource = it } } + } - createTestData(dataBundle, cqlLibrary) + createTestData(dataBundle, cqlLibrary) - val result = - fhirOperator.evaluateLibrary( - cqlLibrary.url, - dataBundle.entry.find { it.resource.resourceType == ResourceType.Patient }!!.resource.id, - null, - ) as Parameters + val result = + fhirOperator.evaluateLibrary( + cqlLibrary.url, + dataBundle.entry.find { it.resource.resourceType == ResourceType.Patient }!!.resource.id, + null, + ) as Parameters - printResult(result) + printResult(result) - assertOutput( - "$resourceDir/output_medication_request.json", - result, - ResourceType.MedicationRequest, - ) - } + assertOutput( + "$resourceDir/output_medication_request.json", + result, + ResourceType.MedicationRequest, + ) + } @Test - fun runCqlLibraryTestForTestResults() = runBlockingOnWorkerThread { - val resourceDir = "cql/test-results" - val cql = "$resourceDir/cql.txt".readFile() + fun runCqlLibraryTestForTestResults() = + runTest(context = UnconfinedTestDispatcher()) { + val resourceDir = "cql/test-results" + val cql = "$resourceDir/cql.txt".readFile() - val cqlLibrary = buildCqlLibrary(cql) + val cqlLibrary = buildCqlLibrary(cql) - val dataBundle = loadTestResultsSampleData() + val dataBundle = loadTestResultsSampleData() - createTestData(dataBundle, cqlLibrary) + createTestData(dataBundle, cqlLibrary) - val result = - fhirOperator.evaluateLibrary( - cqlLibrary.url, - dataBundle.entry.find { it.resource.resourceType == ResourceType.Patient }!!.resource.id, - null, - null, - null, - ) as Parameters + val result = + fhirOperator.evaluateLibrary( + cqlLibrary.url, + dataBundle.entry.find { it.resource.resourceType == ResourceType.Patient }!!.resource.id, + null, + null, + null, + ) as Parameters - printResult(result) + printResult(result) - assertOutput("$resourceDir/sample/output_condition.json", result, ResourceType.Condition) - assertOutput( - "$resourceDir/sample/output_service_request.json", - result, - ResourceType.ServiceRequest, - ) - assertOutput( - "$resourceDir/sample/output_diagnostic_report.json", - result, - ResourceType.DiagnosticReport, - ) - } + assertOutput("$resourceDir/sample/output_condition.json", result, ResourceType.Condition) + assertOutput( + "$resourceDir/sample/output_service_request.json", + result, + ResourceType.ServiceRequest, + ) + assertOutput( + "$resourceDir/sample/output_diagnostic_report.json", + result, + ResourceType.DiagnosticReport, + ) + } @Test - fun runCqlLibraryTestForControlTest() = runBlockingOnWorkerThread { - val resourceDir = "cql/control-test" - val cql = "$resourceDir/cql.txt".readFile() - - val cqlLibrary = buildCqlLibrary(cql) - - val dataBundle = - loadTestResultsSampleData().apply { - addEntry().apply { - // questionnaire-response of test results is input of this cql - resource = - "test-results-questionnaire/questionnaire-response.json".parseSampleResourceFromFile() - as Resource + fun runCqlLibraryTestForControlTest() = + runTest(context = UnconfinedTestDispatcher()) { + val resourceDir = "cql/control-test" + val cql = "$resourceDir/cql.txt".readFile() + + val cqlLibrary = buildCqlLibrary(cql) + + val dataBundle = + loadTestResultsSampleData().apply { + addEntry().apply { + // questionnaire-response of test results is input of this cql + resource = + "test-results-questionnaire/questionnaire-response.json".parseSampleResourceFromFile() + as Resource + } } - } - createTestData(dataBundle, cqlLibrary) + createTestData(dataBundle, cqlLibrary) - val result = - fhirOperator.evaluateLibrary( - cqlLibrary.url, - dataBundle.entry.find { it.resource.resourceType == ResourceType.Patient }!!.resource.id, - null, - ) as Parameters + val result = + fhirOperator.evaluateLibrary( + cqlLibrary.url, + dataBundle.entry.find { it.resource.resourceType == ResourceType.Patient }!!.resource.id, + null, + ) as Parameters - printResult(result) + printResult(result) - Assert.assertTrue( - result.getParameterValues("OUTPUT").first().valueToString() == "Correct Result", - ) - Assert.assertEquals( - result.getParameterValues("OUTPUT").elementAt(1).valueToString(), - "\nDetails:\n" + - "Value (3.0) is in Normal G6PD Range 0-3\n" + - "Value (11.0) is in Normal Haemoglobin Range 8-12", - ) - } + Assert.assertTrue( + result.getParameterValues("OUTPUT").first().valueToString() == "Correct Result", + ) + Assert.assertEquals( + result.getParameterValues("OUTPUT").elementAt(1).valueToString(), + "\nDetails:\n" + + "Value (3.0) is in Normal G6PD Range 0-3\n" + + "Value (11.0) is in Normal Haemoglobin Range 8-12", + ) + } private fun buildCqlLibrary(cql: String): Library { val cqlCompiler = CqlBuilder.compile(cql) @@ -220,7 +226,9 @@ class CqlContentTest : RobolectricTest() { it.name to if (it.hasResource()) { it.resource.encodeResourceToString() - } else it.valueToString() + } else { + it.valueToString() + } } val expectedResource = resource.parseSampleResourceFromFile().convertToString(true) @@ -233,7 +241,7 @@ class CqlContentTest : RobolectricTest() { .replaceTimePart() println(cqlResultStr) - println(expectedResource as String) + println(expectedResource) Assert.assertEquals(expectedResource, cqlResultStr) } diff --git a/android/quest/src/test/java/org/smartregister/fhircore/quest/HiltExtension.kt b/android/quest/src/test/java/org/smartregister/fhircore/quest/HiltExtension.kt index 46ef047b066..0929383cf80 100644 --- a/android/quest/src/test/java/org/smartregister/fhircore/quest/HiltExtension.kt +++ b/android/quest/src/test/java/org/smartregister/fhircore/quest/HiltExtension.kt @@ -46,16 +46,7 @@ inline fun launchFragmentInHiltContainer( navHostController: TestNavHostController? = null, crossinline action: Fragment.() -> Unit = {}, ) { - val startActivityIntent = - Intent.makeMainActivity( - ComponentName(ApplicationProvider.getApplicationContext(), HiltActivityForTest::class.java), - ) - .putExtra( - "androidx.fragment.app.testing.FragmentScenario.EmptyFragmentActivity.THEME_EXTRAS_BUNDLE_KEY", - themeResId, - ) - - ActivityScenario.launch(startActivityIntent).use { scenario -> + hiltActivityForTestScenario(themeResId).use { scenario -> scenario.onActivity { activity -> val fragment: Fragment = activity.supportFragmentManager.fragmentFactory.instantiate( @@ -82,3 +73,18 @@ inline fun launchFragmentInHiltContainer( } } } + +fun hiltActivityForTestScenario( + @StyleRes themeResId: Int = R.style.AppTheme, +): ActivityScenario { + val startActivityIntent = + Intent.makeMainActivity( + ComponentName(ApplicationProvider.getApplicationContext(), HiltActivityForTest::class.java), + ) + .putExtra( + HiltActivityForTest.THEME_EXTRAS_BUNDLE_KEY, + themeResId, + ) + + return ActivityScenario.launch(startActivityIntent) +} diff --git a/android/quest/src/test/java/org/smartregister/fhircore/quest/StructureMapUtilitiesTest.kt b/android/quest/src/test/java/org/smartregister/fhircore/quest/StructureMapUtilitiesTest.kt index d6cfc4eb3b5..4b893f97fef 100644 --- a/android/quest/src/test/java/org/smartregister/fhircore/quest/StructureMapUtilitiesTest.kt +++ b/android/quest/src/test/java/org/smartregister/fhircore/quest/StructureMapUtilitiesTest.kt @@ -33,7 +33,9 @@ import org.hl7.fhir.r4.model.QuestionnaireResponse import org.hl7.fhir.r4.model.RelatedPerson import org.hl7.fhir.r4.model.ResourceType import org.hl7.fhir.utilities.npm.FilesystemPackageCacheManager +import org.junit.After import org.junit.Assert +import org.junit.Before import org.junit.Test import org.smartregister.fhircore.engine.util.helper.TransformSupportServices import org.smartregister.fhircore.quest.robolectric.RobolectricTest @@ -45,25 +47,40 @@ import org.smartregister.fhircore.quest.robolectric.RobolectricTest * This should be removed at a later point once we have a more clear way of doing this */ class StructureMapUtilitiesTest : RobolectricTest() { - - @Test - fun `perform family extraction`() { - val registrationQuestionnaireResponseString: String = - "content/general/family/questionnaire-response-standard.json".readFile() - val registrationStructureMap = "content/general/family/family-registration.map".readFile() - val packageCacheManager = FilesystemPackageCacheManager(true) - val contextR4 = + private lateinit var packageCacheManager: FilesystemPackageCacheManager + private lateinit var contextR4: SimpleWorkerContext + private lateinit var transformSupportServices: TransformSupportServices + private lateinit var structureMapUtilities: org.hl7.fhir.r4.utils.StructureMapUtilities + private lateinit var iParser: IParser + + @Before + fun setUp() { + packageCacheManager = FilesystemPackageCacheManager(true) + contextR4 = SimpleWorkerContext.fromPackage(packageCacheManager.loadPackage("hl7.fhir.r4.core", "4.0.1")) .apply { setExpansionProfile(Parameters()) isCanRunWithoutTerminology = true } - val transformSupportServices = TransformSupportServices(contextR4) - val structureMapUtilities = + transformSupportServices = TransformSupportServices(contextR4) + structureMapUtilities = org.hl7.fhir.r4.utils.StructureMapUtilities(contextR4, transformSupportServices) + iParser = FhirContext.forCached(FhirVersionEnum.R4).newJsonParser() + } + + @After + fun packageTearDown() { + // Clean up resources or reset states here + packageCacheManager.clear() + } + + @Test + fun `perform family extraction`() { + val registrationQuestionnaireResponseString: String = + "content/general/family/questionnaire-response-standard.json".readFile() + val registrationStructureMap = "content/general/family/family-registration.map".readFile() val structureMap = structureMapUtilities.parse(registrationStructureMap, "eCBIS Family Registration") - val iParser: IParser = FhirContext.forCached(FhirVersionEnum.R4).newJsonParser() val targetResource = Bundle() val baseElement = iParser.parseResource( @@ -84,19 +101,8 @@ class StructureMapUtilitiesTest : RobolectricTest() { "content/general/disease-registration-resources/questionnaire_response.json".readFile() val immunizationStructureMap = "content/general/disease-registration-resources/structure-map.txt".readFile() - val packageCacheManager = FilesystemPackageCacheManager(true) - val contextR4 = - SimpleWorkerContext.fromPackage(packageCacheManager.loadPackage("hl7.fhir.r4.core", "4.0.1")) - .apply { - setExpansionProfile(Parameters()) - isCanRunWithoutTerminology = true - } - val transformSupportServices = TransformSupportServices(contextR4) - val structureMapUtilities = - org.hl7.fhir.r4.utils.StructureMapUtilities(contextR4, transformSupportServices) val structureMap = structureMapUtilities.parse(immunizationStructureMap, "eCBIS Disease Registration") - val iParser: IParser = FhirContext.forCached(FhirVersionEnum.R4).newJsonParser() val targetResource = Bundle() val baseElement = iParser.parseResource( @@ -118,19 +124,8 @@ class StructureMapUtilitiesTest : RobolectricTest() { val immunizationJson = "content/eir/immunization/immunization-1.json".readFile() val immunizationStructureMap = "content/eir/immunization/structure-map.txt".readFile() val questionnaireJson = "content/eir/immunization/questionnaire.json".readFile() - val packageCacheManager = FilesystemPackageCacheManager(true) - val contextR4 = - SimpleWorkerContext.fromPackage(packageCacheManager.loadPackage("hl7.fhir.r4.core", "4.0.1")) - .apply { - setExpansionProfile(Parameters()) - isCanRunWithoutTerminology = true - } - val transformSupportServices = TransformSupportServices(contextR4) - val structureMapUtilities = - org.hl7.fhir.r4.utils.StructureMapUtilities(contextR4, transformSupportServices) val structureMap = structureMapUtilities.parse(immunizationStructureMap, "ImmunizationRegistration") - val iParser: IParser = FhirContext.forCached(FhirVersionEnum.R4).newJsonParser() val targetResource = Bundle() val patient = iParser.parseResource(Patient::class.java, patientJson) val immunization = iParser.parseResource(Immunization::class.java, immunizationJson) @@ -182,16 +177,6 @@ class StructureMapUtilitiesTest : RobolectricTest() { ) } - val packageCacheManager = FilesystemPackageCacheManager(true) - val contextR4 = - SimpleWorkerContext.fromPackage(packageCacheManager.loadPackage("hl7.fhir.r4.core", "4.0.1")) - .apply { - setExpansionProfile(Parameters()) - isCanRunWithoutTerminology = true - } - val transformSupportServices = TransformSupportServices(contextR4) - val structureMapUtilities = - org.hl7.fhir.r4.utils.StructureMapUtilities(contextR4, transformSupportServices) val structureMap = structureMapUtilities.parse(patientRegistrationStructureMap, "PatientRegistration") val targetResource = Bundle() @@ -224,16 +209,6 @@ class StructureMapUtilitiesTest : RobolectricTest() { ) } - val packageCacheManager = FilesystemPackageCacheManager(true) - val contextR4 = - SimpleWorkerContext.fromPackage(packageCacheManager.loadPackage("hl7.fhir.r4.core", "4.0.1")) - .apply { - setExpansionProfile(Parameters()) - isCanRunWithoutTerminology = true - } - val transformSupportServices = TransformSupportServices(contextR4) - val structureMapUtilities = - org.hl7.fhir.r4.utils.StructureMapUtilities(contextR4, transformSupportServices) val structureMap = structureMapUtilities.parse(adverseEventStructureMap, "AdverseEvent") val targetResource = Bundle() @@ -248,14 +223,8 @@ class StructureMapUtilitiesTest : RobolectricTest() { fun `convert StructureMap to JSON`() { val patientRegistrationStructureMap = "patient-registration-questionnaire/structure-map.txt".readFile() - val packageCacheManager = FilesystemPackageCacheManager(true) - val contextR4 = - SimpleWorkerContext.fromPackage(packageCacheManager.loadPackage("hl7.fhir.r4.core", "4.0.1")) - .apply { isCanRunWithoutTerminology = true } - val structureMapUtilities = org.hl7.fhir.r4.utils.StructureMapUtilities(contextR4) val structureMap = structureMapUtilities.parse(patientRegistrationStructureMap, "PatientRegistration") - val iParser: IParser = FhirContext.forCached(FhirVersionEnum.R4).newJsonParser() val mapString = iParser.encodeResourceToString(structureMap) Assert.assertNotNull(mapString) @@ -267,19 +236,8 @@ class StructureMapUtilitiesTest : RobolectricTest() { "patient-registration-questionnaire/questionnaire-response.json".readFile() val patientRegistrationStructureMap = "patient-registration-questionnaire/structure-map.txt".readFile() - val packageCacheManager = FilesystemPackageCacheManager(true) - val contextR4 = - SimpleWorkerContext.fromPackage(packageCacheManager.loadPackage("hl7.fhir.r4.core", "4.0.1")) - .apply { - setExpansionProfile(Parameters()) - isCanRunWithoutTerminology = true - } - val transformSupportServices = TransformSupportServices(contextR4) - val structureMapUtilities = - org.hl7.fhir.r4.utils.StructureMapUtilities(contextR4, transformSupportServices) val structureMap = structureMapUtilities.parse(patientRegistrationStructureMap, "PatientRegistration") - val iParser: IParser = FhirContext.forCached(FhirVersionEnum.R4).newJsonParser() val targetResource = Bundle() val baseElement = iParser.parseResource( @@ -298,18 +256,7 @@ class StructureMapUtilitiesTest : RobolectricTest() { val adverseEventQuestionnaireResponse = "content/eir/adverse-event/questionnaire-response.json".readFile() val adverseEventStructureMap = "content/eir/adverse-event/structure-map.txt".readFile() - val packageCacheManager = FilesystemPackageCacheManager(true) - val contextR4 = - SimpleWorkerContext.fromPackage(packageCacheManager.loadPackage("hl7.fhir.r4.core", "4.0.1")) - .apply { - setExpansionProfile(Parameters()) - isCanRunWithoutTerminology = true - } - val transformSupportServices = TransformSupportServices(contextR4) - val structureMapUtilities = - org.hl7.fhir.r4.utils.StructureMapUtilities(contextR4, transformSupportServices) val structureMap = structureMapUtilities.parse(adverseEventStructureMap, "AdverseEvent") - val iParser: IParser = FhirContext.forCached(FhirVersionEnum.R4).newJsonParser() val targetResource = Bundle() val baseElement = @@ -327,18 +274,7 @@ class StructureMapUtilitiesTest : RobolectricTest() { val vitalSignQuestionnaireResponse = "content/anc/vital-signs/metric/questionnaire-response-pulse-rate.json".readFile() val vitalSignStructureMap = "content/anc/vital-signs/metric/structure-map.txt".readFile() - val packageCacheManager = FilesystemPackageCacheManager(true) - val contextR4 = - SimpleWorkerContext.fromPackage(packageCacheManager.loadPackage("hl7.fhir.r4.core", "4.0.1")) - .apply { - setExpansionProfile(Parameters()) - isCanRunWithoutTerminology = true - } - val transformSupportServices = TransformSupportServices(contextR4) - val structureMapUtilities = - org.hl7.fhir.r4.utils.StructureMapUtilities(contextR4, transformSupportServices) val structureMap = structureMapUtilities.parse(vitalSignStructureMap, "VitalSigns") - val iParser: IParser = FhirContext.forCached(FhirVersionEnum.R4).newJsonParser() val targetResource = Bundle() val baseElement = iParser.parseResource(QuestionnaireResponse::class.java, vitalSignQuestionnaireResponse) @@ -356,18 +292,7 @@ class StructureMapUtilitiesTest : RobolectricTest() { "content/anc/vital-signs/standard/questionnaire-response-pulse-rate.json".readFile() val vitalSignStructureMap = "content/anc/vital-signs/standard/structure-map.txt".readFile() - val packageCacheManager = FilesystemPackageCacheManager(true) - val contextR4 = - SimpleWorkerContext.fromPackage(packageCacheManager.loadPackage("hl7.fhir.r4.core", "4.0.1")) - .apply { - setExpansionProfile(Parameters()) - isCanRunWithoutTerminology = true - } - val transformSupportServices = TransformSupportServices(contextR4) - val structureMapUtilities = - org.hl7.fhir.r4.utils.StructureMapUtilities(contextR4, transformSupportServices) val structureMap = structureMapUtilities.parse(vitalSignStructureMap, "VitalSigns") - val iParser: IParser = FhirContext.forCached(FhirVersionEnum.R4).newJsonParser() val targetResource = Bundle() val baseElement = iParser.parseResource(QuestionnaireResponse::class.java, vitalSignQuestionnaireResponse) @@ -377,6 +302,7 @@ class StructureMapUtilitiesTest : RobolectricTest() { Assert.assertEquals(2, targetResource.entry.size) Assert.assertEquals("Encounter", targetResource.entry[0].resource.resourceType.toString()) Assert.assertEquals("Observation", targetResource.entry[1].resource.resourceType.toString()) + packageCacheManager.clear() } @Test @@ -384,18 +310,7 @@ class StructureMapUtilitiesTest : RobolectricTest() { val locationQuestionnaireResponseString: String = "content/general/location/location-response-sample.json".readFile() val locationStructureMap = "content/general/location/location-structure-map.txt".readFile() - val packageCacheManager = FilesystemPackageCacheManager(true) - val contextR4 = - SimpleWorkerContext.fromPackage(packageCacheManager.loadPackage("hl7.fhir.r4.core", "4.0.1")) - .apply { - setExpansionProfile(Parameters()) - isCanRunWithoutTerminology = true - } - val transformSupportServices = TransformSupportServices(contextR4) - val structureMapUtilities = - org.hl7.fhir.r4.utils.StructureMapUtilities(contextR4, transformSupportServices) val structureMap = structureMapUtilities.parse(locationStructureMap, "LocationRegistration") - val iParser: IParser = FhirContext.forCached(FhirVersionEnum.R4).newJsonParser() val targetResource = Bundle() val baseElement = iParser.parseResource(QuestionnaireResponse::class.java, locationQuestionnaireResponseString) @@ -412,22 +327,11 @@ class StructureMapUtilitiesTest : RobolectricTest() { "content/general/supply-chain/questionnaire-response-standard.json".readFile() val physicalInventoryCountStructureMap = "content/general/supply-chain/physical_inventory_count_and_stock.map".readFile() - val packageCacheManager = FilesystemPackageCacheManager(true) - val contextR4 = - SimpleWorkerContext.fromPackage(packageCacheManager.loadPackage("hl7.fhir.r4.core", "4.0.1")) - .apply { - setExpansionProfile(Parameters()) - isCanRunWithoutTerminology = true - } - val transformSupportServices = TransformSupportServices(contextR4) - val structureMapUtilities = - org.hl7.fhir.r4.utils.StructureMapUtilities(contextR4, transformSupportServices) val structureMap = structureMapUtilities.parse( physicalInventoryCountStructureMap, "Physical Inventory Count and Stock Supply", ) - val iParser: IParser = FhirContext.forCached(FhirVersionEnum.R4).newJsonParser() val targetResource = Bundle() val baseElement = iParser.parseResource( @@ -450,19 +354,8 @@ class StructureMapUtilitiesTest : RobolectricTest() { val vitalSignQuestionnaireResponse = "content/anc/preg-outcome/questionnaire-response.json".readFile() val vitalSignStructureMap = "content/anc/preg-outcome/structure-map.txt".readFile() - val packageCacheManager = FilesystemPackageCacheManager(true) - val contextR4 = - SimpleWorkerContext.fromPackage(packageCacheManager.loadPackage("hl7.fhir.r4.core", "4.0.1")) - .apply { - setExpansionProfile(Parameters()) - isCanRunWithoutTerminology = true - } - val transformSupportServices = TransformSupportServices(contextR4) - val structureMapUtilities = - org.hl7.fhir.r4.utils.StructureMapUtilities(contextR4, transformSupportServices) val structureMap = structureMapUtilities.parse(vitalSignStructureMap, "PregnancyOutcomeRegistration") - val iParser: IParser = FhirContext.forCached(FhirVersionEnum.R4).newJsonParser() val targetResource = Bundle() val baseElement = iParser.parseResource(QuestionnaireResponse::class.java, vitalSignQuestionnaireResponse) @@ -483,18 +376,7 @@ class StructureMapUtilitiesTest : RobolectricTest() { "content/general/who-eir/patient_registration_questionnaire_response.json".readFile() val locationStructureMap = "content/general/who-eir/patient_registration_structure_map.txt".readFile() - val packageCacheManager = FilesystemPackageCacheManager(true) - val contextR4 = - SimpleWorkerContext.fromPackage(packageCacheManager.loadPackage("hl7.fhir.r4.core", "4.0.1")) - .apply { - setExpansionProfile(Parameters()) - isCanRunWithoutTerminology = true - } - val transformSupportServices = TransformSupportServices(contextR4) - val structureMapUtilities = - org.hl7.fhir.r4.utils.StructureMapUtilities(contextR4, transformSupportServices) val structureMap = structureMapUtilities.parse(locationStructureMap, "IMMZ-C-QRToPatient") - val iParser: IParser = FhirContext.forCached(FhirVersionEnum.R4).newJsonParser() val targetResource = Bundle() val baseElement = iParser.parseResource(QuestionnaireResponse::class.java, locationQuestionnaireResponseString) diff --git a/android/quest/src/test/java/org/smartregister/fhircore/quest/TestHelper.kt b/android/quest/src/test/java/org/smartregister/fhircore/quest/TestHelper.kt new file mode 100644 index 00000000000..8d9c0fc277e --- /dev/null +++ b/android/quest/src/test/java/org/smartregister/fhircore/quest/TestHelper.kt @@ -0,0 +1,31 @@ +/* + * Copyright 2021-2024 Ona Systems, Inc + * + * 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 org.smartregister.fhircore.quest + +import ca.uhn.fhir.context.FhirContext +import ca.uhn.fhir.parser.IParser +import org.hl7.fhir.instance.model.api.IBaseResource +import org.junit.Assert + +private val printer: IParser = FhirContext.forR4().newJsonParser() + +fun assertResourceEquals(expected: T, actual: T) { + Assert.assertEquals( + printer.encodeResourceToString(expected), + printer.encodeResourceToString(actual), + ) +} diff --git a/android/quest/src/test/java/org/smartregister/fhircore/quest/app/ConfigurationRegistryTest.kt b/android/quest/src/test/java/org/smartregister/fhircore/quest/app/ConfigurationRegistryTest.kt index 5905eeac4fe..01fd8666c54 100644 --- a/android/quest/src/test/java/org/smartregister/fhircore/quest/app/ConfigurationRegistryTest.kt +++ b/android/quest/src/test/java/org/smartregister/fhircore/quest/app/ConfigurationRegistryTest.kt @@ -119,7 +119,6 @@ class ConfigurationRegistryTest : RobolectricTest() { coEvery { configurationRegistry.fhirResourceDataSource.getResource(any()) } returns bundle coEvery { configurationRegistry.fhirResourceDataSource.post(any(), any()) } returns bundle every { sharedPreferencesHelper.read(SharedPreferenceKey.APP_ID.name, null) } returns "demo" - coEvery { configurationRegistry.saveSyncSharedPreferences(any()) } just runs configurationRegistry.fetchNonWorkflowConfigResources() coVerify { configurationRegistry.addOrUpdate(any()) } @@ -162,7 +161,6 @@ class ConfigurationRegistryTest : RobolectricTest() { coEvery { configurationRegistry.fetchRemoteCompositionByAppId(any()) } returns composition coEvery { configurationRegistry.fhirResourceDataSource.getResource(any()) } returns bundle every { sharedPreferencesHelper.read(SharedPreferenceKey.APP_ID.name, null) } returns "demo" - coEvery { configurationRegistry.saveSyncSharedPreferences(any()) } just runs coEvery { fhirResourceDataSource.getResource("List?_id=123456") } returns bundle configurationRegistry.fetchNonWorkflowConfigResources() @@ -202,7 +200,6 @@ class ConfigurationRegistryTest : RobolectricTest() { ) } returns bundle every { sharedPreferencesHelper.read(SharedPreferenceKey.APP_ID.name, null) } returns "demo" - coEvery { configurationRegistry.saveSyncSharedPreferences(any()) } just runs coEvery { fhirResourceDataSource.getResource("List?_id=123456&_page=1&_count=200") } returns bundle diff --git a/android/geowidget/src/test/java/org/smartregister/fhircore/geowidget/screens/GeoWidgetTestActivity.kt b/android/quest/src/test/java/org/smartregister/fhircore/quest/app/fakes/HiltTestActivity.kt similarity index 74% rename from android/geowidget/src/test/java/org/smartregister/fhircore/geowidget/screens/GeoWidgetTestActivity.kt rename to android/quest/src/test/java/org/smartregister/fhircore/quest/app/fakes/HiltTestActivity.kt index 3a0ced7ad2d..c1cdd149617 100644 --- a/android/geowidget/src/test/java/org/smartregister/fhircore/geowidget/screens/GeoWidgetTestActivity.kt +++ b/android/quest/src/test/java/org/smartregister/fhircore/quest/app/fakes/HiltTestActivity.kt @@ -14,12 +14,9 @@ * limitations under the License. */ -package org.smartregister.fhircore.geowidget.screens +package org.smartregister.fhircore.quest.app.fakes import androidx.appcompat.app.AppCompatActivity import dagger.hilt.android.AndroidEntryPoint -import org.smartregister.fhircore.engine.util.annotation.ExcludeFromJacocoGeneratedReport -@ExcludeFromJacocoGeneratedReport -@AndroidEntryPoint -class GeoWidgetTestActivity : AppCompatActivity() +@AndroidEntryPoint class HiltTestActivity : AppCompatActivity() diff --git a/android/quest/src/test/java/org/smartregister/fhircore/quest/data/DataMigrationTest.kt b/android/quest/src/test/java/org/smartregister/fhircore/quest/data/DataMigrationTest.kt index 1aafa14d4ff..7586d323b6e 100644 --- a/android/quest/src/test/java/org/smartregister/fhircore/quest/data/DataMigrationTest.kt +++ b/android/quest/src/test/java/org/smartregister/fhircore/quest/data/DataMigrationTest.kt @@ -77,6 +77,24 @@ class DataMigrationTest : RobolectricTest() { dataMigration.migrate( migrationConfigs = listOf( + MigrationConfig( + resourceConfig = + FhirResourceConfig( + baseResource = ResourceConfig(resource = ResourceType.Patient), + ), + version = 7, + rules = + listOf( + RuleConfig(name = "value", actions = listOf("data.put('value', 'female')")), + ), + updateValues = + listOf( + UpdateValueConfig( + jsonPathExpression = "\$.gender", + computedValueKey = "value", + ), + ), + ), MigrationConfig( resourceConfig = FhirResourceConfig( @@ -103,9 +121,9 @@ class DataMigrationTest : RobolectricTest() { Assert.assertTrue(updatedPatient?.gender != patient.gender) Assert.assertEquals(Enumerations.AdministrativeGender.FEMALE, updatedPatient?.gender) - // Version updated to 2 + // Version updated to 7 (the maximum migration version) Assert.assertEquals( - 2, + 7, preferenceDataStore.read(PreferenceDataStore.MIGRATION_VERSION).first(), ) } @@ -179,9 +197,9 @@ class DataMigrationTest : RobolectricTest() { Assert.assertNotNull(updatedTask?.basedOn) Assert.assertEquals("CarePlan/${carePlan.logicalId}", updatedTask?.basedOnFirstRep?.reference) - // Version updated to 2 + // Version updated to 1 Assert.assertEquals( - 2, + 1, preferenceDataStore.read(PreferenceDataStore.MIGRATION_VERSION).first(), ) } diff --git a/android/quest/src/test/java/org/smartregister/fhircore/quest/data/QuestXFhirQueryResolverTest.kt b/android/quest/src/test/java/org/smartregister/fhircore/quest/data/QuestXFhirQueryResolverTest.kt index cade896cb22..94390c95a81 100644 --- a/android/quest/src/test/java/org/smartregister/fhircore/quest/data/QuestXFhirQueryResolverTest.kt +++ b/android/quest/src/test/java/org/smartregister/fhircore/quest/data/QuestXFhirQueryResolverTest.kt @@ -47,7 +47,7 @@ class QuestXFhirQueryResolverTest : RobolectricTest() { @Test fun testQuestXFhirQueryResolver() = runTest(timeout = 120.seconds) { - val patient = Patient() + val patient = Patient().apply { setActive(true) } val task = Task() fhirEngine.create(patient, task) val xFhirResolver = QuestXFhirQueryResolver(fhirEngine) diff --git a/android/quest/src/test/java/org/smartregister/fhircore/quest/data/register/RegisterPagingSourceTest.kt b/android/quest/src/test/java/org/smartregister/fhircore/quest/data/register/RegisterPagingSourceTest.kt index a4ad755523b..459390d854c 100644 --- a/android/quest/src/test/java/org/smartregister/fhircore/quest/data/register/RegisterPagingSourceTest.kt +++ b/android/quest/src/test/java/org/smartregister/fhircore/quest/data/register/RegisterPagingSourceTest.kt @@ -145,4 +145,114 @@ class RegisterPagingSourceTest : RobolectricTest() { } } } + + @Test + fun testLoadWithEmptyRegisterDataShouldReturnEmptyResults() { + registerPagingSource = + RegisterPagingSource( + registerRepository = registerRepository, + resourceDataRulesExecutor = resourceDataRulesExecutor, + ruleConfigs = listOf(), + actionParameters = emptyMap(), + fhirResourceConfig = null, + ) + coEvery { registerRepository.loadRegisterData(0, registerId) } returns emptyList() + val loadParams = mockk>() + every { loadParams.key } returns null + runBlocking { + registerPagingSource.run { + setPatientPagingSourceState( + RegisterPagingSourceState(registerId = registerId, currentPage = 0, loadAll = false), + ) + val result = load(loadParams) + Assert.assertNotNull(result) + Assert.assertTrue((result as PagingSource.LoadResult.Page).data.isEmpty()) + } + } + } + + @Test + fun testLoadWithNonZeroInitialPageShouldReturnResults() { + registerPagingSource = + RegisterPagingSource( + registerRepository = registerRepository, + resourceDataRulesExecutor = resourceDataRulesExecutor, + ruleConfigs = listOf(), + actionParameters = emptyMap(), + fhirResourceConfig = null, + ) + coEvery { registerRepository.loadRegisterData(2, registerId) } returns + listOf(RepositoryResourceData(resource = Faker.buildPatient())) + val loadParams = mockk>() + every { loadParams.key } returns 2 + runBlocking { + registerPagingSource.run { + setPatientPagingSourceState( + RegisterPagingSourceState(registerId = registerId, currentPage = 2, loadAll = false), + ) + val result = load(loadParams) + Assert.assertNotNull(result) + Assert.assertEquals(1, (result as PagingSource.LoadResult.Page).data.size) + } + } + } + + @Test + fun testLoadWithMultiplePagesShouldReturnPaginatedResults() { + registerPagingSource = + RegisterPagingSource( + registerRepository = registerRepository, + resourceDataRulesExecutor = resourceDataRulesExecutor, + ruleConfigs = listOf(), + actionParameters = emptyMap(), + fhirResourceConfig = null, + ) + coEvery { registerRepository.loadRegisterData(0, registerId) } returns + listOf(RepositoryResourceData(resource = Faker.buildPatient())) + coEvery { registerRepository.loadRegisterData(1, registerId) } returns + listOf(RepositoryResourceData(resource = Faker.buildPatient())) + val loadParams = mockk>() + every { loadParams.key } returns null + runBlocking { + registerPagingSource.run { + setPatientPagingSourceState( + RegisterPagingSourceState(registerId = registerId, currentPage = 0, loadAll = true), + ) + var result = load(loadParams) + Assert.assertNotNull(result) + Assert.assertEquals(1, (result as PagingSource.LoadResult.Page).data.size) + every { loadParams.key } returns 1 + result = load(loadParams) + Assert.assertNotNull(result) + Assert.assertEquals(1, (result as PagingSource.LoadResult.Page).data.size) + } + } + } + + @Test + fun testLoadWithNonEmptyActionParametersShouldReturnResults() { + val actionParameters = mapOf("param1" to "value1") + registerPagingSource = + RegisterPagingSource( + registerRepository = registerRepository, + resourceDataRulesExecutor = resourceDataRulesExecutor, + ruleConfigs = listOf(), + actionParameters = actionParameters, + fhirResourceConfig = null, + ) + coEvery { registerRepository.loadRegisterData(0, registerId) } returns + listOf(RepositoryResourceData(resource = Faker.buildPatient())) + val loadParams = mockk>() + every { loadParams.key } returns null + runBlocking { + registerPagingSource.run { + setPatientPagingSourceState( + RegisterPagingSourceState(registerId = registerId, currentPage = 0, loadAll = false), + ) + val result = load(loadParams) + Assert.assertNotNull(result) + Assert.assertEquals(1, (result as PagingSource.LoadResult.Page).data.size) + } + } + } } diff --git a/android/quest/src/test/java/org/smartregister/fhircore/quest/data/report/measure/MeasureReportPagingSourceTest.kt b/android/quest/src/test/java/org/smartregister/fhircore/quest/data/report/measure/MeasureReportPagingSourceTest.kt index 07a77032f27..fef58e2a9db 100644 --- a/android/quest/src/test/java/org/smartregister/fhircore/quest/data/report/measure/MeasureReportPagingSourceTest.kt +++ b/android/quest/src/test/java/org/smartregister/fhircore/quest/data/report/measure/MeasureReportPagingSourceTest.kt @@ -20,6 +20,7 @@ import androidx.paging.PagingConfig import androidx.paging.PagingSource import androidx.paging.PagingState import androidx.test.core.app.ApplicationProvider +import ca.uhn.fhir.context.FhirContext import ca.uhn.fhir.parser.IParser import com.google.android.fhir.FhirEngine import com.google.android.fhir.SearchResult @@ -41,13 +42,14 @@ import org.smartregister.fhircore.engine.configuration.ConfigurationRegistry import org.smartregister.fhircore.engine.configuration.register.RegisterConfiguration import org.smartregister.fhircore.engine.configuration.report.measure.MeasureReportConfiguration import org.smartregister.fhircore.engine.configuration.report.measure.ReportConfiguration +import org.smartregister.fhircore.engine.data.local.ContentCache +import org.smartregister.fhircore.engine.data.local.DefaultRepository import org.smartregister.fhircore.engine.data.local.register.RegisterRepository import org.smartregister.fhircore.engine.domain.model.FhirResourceConfig import org.smartregister.fhircore.engine.domain.model.ResourceConfig import org.smartregister.fhircore.engine.rulesengine.ResourceDataRulesExecutor import org.smartregister.fhircore.engine.rulesengine.RulesFactory import org.smartregister.fhircore.engine.rulesengine.services.LocationService -import org.smartregister.fhircore.engine.util.DefaultDispatcherProvider import org.smartregister.fhircore.engine.util.DispatcherProvider import org.smartregister.fhircore.engine.util.fhirpath.FhirPathDataExtractor import org.smartregister.fhircore.quest.app.fakes.Faker @@ -65,6 +67,10 @@ class MeasureReportPagingSourceTest : RobolectricTest() { @Inject lateinit var locationService: LocationService + @Inject lateinit var contentCache: ContentCache + + @Inject lateinit var fhirContext: FhirContext + private val configurationRegistry: ConfigurationRegistry = Faker.buildTestConfigurationRegistry() private val fhirEngine: FhirEngine = mockk() private val registerId = "register id" @@ -73,11 +79,13 @@ class MeasureReportPagingSourceTest : RobolectricTest() { private lateinit var measureReportConfiguration: MeasureReportConfiguration private lateinit var measureReportPagingSource: MeasureReportPagingSource private lateinit var registerRepository: RegisterRepository + private lateinit var defaultRepository: DefaultRepository @Before @kotlinx.coroutines.ExperimentalCoroutinesApi fun setUp() { hiltAndroidRule.inject() + defaultRepository = mockk(relaxed = true) rulesFactory = spyk( RulesFactory( @@ -86,6 +94,8 @@ class MeasureReportPagingSourceTest : RobolectricTest() { fhirPathDataExtractor = fhirPathDataExtractor, dispatcherProvider = dispatcherProvider, locationService = locationService, + fhirContext = fhirContext, + defaultRepository = defaultRepository, ), ) resourceDataRulesExecutor = ResourceDataRulesExecutor(rulesFactory) @@ -100,7 +110,7 @@ class MeasureReportPagingSourceTest : RobolectricTest() { spyk( RegisterRepository( fhirEngine = fhirEngine, - dispatcherProvider = DefaultDispatcherProvider(), + dispatcherProvider = dispatcherProvider, sharedPreferencesHelper = mockk(), configurationRegistry = configurationRegistry, configService = mockk(), @@ -108,6 +118,7 @@ class MeasureReportPagingSourceTest : RobolectricTest() { fhirPathDataExtractor = fhirPathDataExtractor, parser = parser, context = ApplicationProvider.getApplicationContext(), + contentCache = contentCache, ), ) diff --git a/android/quest/src/test/java/org/smartregister/fhircore/quest/data/report/measure/MeasureReportRepositoryTest.kt b/android/quest/src/test/java/org/smartregister/fhircore/quest/data/report/measure/MeasureReportRepositoryTest.kt index 249c601e02b..fc2e1b22c09 100644 --- a/android/quest/src/test/java/org/smartregister/fhircore/quest/data/report/measure/MeasureReportRepositoryTest.kt +++ b/android/quest/src/test/java/org/smartregister/fhircore/quest/data/report/measure/MeasureReportRepositoryTest.kt @@ -34,6 +34,7 @@ import javax.inject.Inject import kotlin.test.assertEquals import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runBlocking +import org.hl7.fhir.exceptions.FHIRException import org.hl7.fhir.r4.model.Group import org.hl7.fhir.r4.model.Patient import org.hl7.fhir.r4.model.Reference @@ -43,11 +44,12 @@ import org.junit.Test import org.smartregister.fhircore.engine.configuration.ConfigurationRegistry import org.smartregister.fhircore.engine.configuration.report.measure.MeasureReportConfiguration import org.smartregister.fhircore.engine.configuration.report.measure.ReportConfiguration +import org.smartregister.fhircore.engine.data.local.ContentCache +import org.smartregister.fhircore.engine.data.local.DefaultRepository import org.smartregister.fhircore.engine.data.local.register.RegisterRepository import org.smartregister.fhircore.engine.rulesengine.ResourceDataRulesExecutor import org.smartregister.fhircore.engine.rulesengine.RulesFactory import org.smartregister.fhircore.engine.rulesengine.services.LocationService -import org.smartregister.fhircore.engine.util.DefaultDispatcherProvider import org.smartregister.fhircore.engine.util.DispatcherProvider import org.smartregister.fhircore.engine.util.extension.SDF_YYYY_MM_DD import org.smartregister.fhircore.engine.util.extension.firstDayOfMonth @@ -72,6 +74,10 @@ class MeasureReportRepositoryTest : RobolectricTest() { @Inject lateinit var locationService: LocationService + @Inject lateinit var fhirContext: FhirContext + + @Inject lateinit var contentCache: ContentCache + private val configurationRegistry: ConfigurationRegistry = Faker.buildTestConfigurationRegistry() private val fhirEngine: FhirEngine = mockk() private lateinit var measureReportConfiguration: MeasureReportConfiguration @@ -81,11 +87,13 @@ class MeasureReportRepositoryTest : RobolectricTest() { private lateinit var resourceDataRulesExecutor: ResourceDataRulesExecutor private lateinit var registerRepository: RegisterRepository private val parser = FhirContext.forR4Cached().newJsonParser() + private lateinit var defaultRepository: DefaultRepository @Before @kotlinx.coroutines.ExperimentalCoroutinesApi fun setUp() { hiltAndroidRule.inject() + defaultRepository = mockk(relaxed = true) rulesFactory = spyk( RulesFactory( @@ -94,6 +102,8 @@ class MeasureReportRepositoryTest : RobolectricTest() { fhirPathDataExtractor = fhirPathDataExtractor, dispatcherProvider = dispatcherProvider, locationService = locationService, + fhirContext = fhirContext, + defaultRepository = defaultRepository, ), ) resourceDataRulesExecutor = ResourceDataRulesExecutor(rulesFactory) @@ -106,7 +116,7 @@ class MeasureReportRepositoryTest : RobolectricTest() { spyk( RegisterRepository( fhirEngine = fhirEngine, - dispatcherProvider = DefaultDispatcherProvider(), + dispatcherProvider = dispatcherProvider, sharedPreferencesHelper = mockk(), configurationRegistry = configurationRegistry, configService = mockk(), @@ -114,13 +124,13 @@ class MeasureReportRepositoryTest : RobolectricTest() { fhirPathDataExtractor = mockk(), parser = parser, context = ApplicationProvider.getApplicationContext(), + contentCache = contentCache, ), ) measureReportRepository = MeasureReportRepository( fhirEngine = fhirEngine, - dispatcherProvider = DefaultDispatcherProvider(), sharedPreferencesHelper = mockk(), configurationRegistry = configurationRegistry, configService = mockk(), @@ -130,6 +140,8 @@ class MeasureReportRepositoryTest : RobolectricTest() { fhirPathDataExtractor = mockk(), parser = parser, context = ApplicationProvider.getApplicationContext(), + dispatcherProvider = dispatcherProvider, + contentCache = contentCache, ) } @@ -238,4 +250,18 @@ class MeasureReportRepositoryTest : RobolectricTest() { coVerify { fhirEngine.search(any()) } coVerify(inverse = true) { fhirEngine.update(any()) } } + + @Test + @kotlinx.coroutines.ExperimentalCoroutinesApi + fun testRetrieveSubjectHandlesFhirException() { + val reportConfiguration = ReportConfiguration(subjectXFhirQuery = "Patient") + coEvery { fhirEngine.search(any()) } throws FHIRException("") + + runBlocking(Dispatchers.Default) { + val data = measureReportRepository.fetchSubjects(reportConfiguration) + assertEquals(0, data.size) + } + + coVerify { fhirEngine.search(any()) } + } } diff --git a/android/quest/src/test/java/org/smartregister/fhircore/quest/event/EventBusTest.kt b/android/quest/src/test/java/org/smartregister/fhircore/quest/event/EventBusTest.kt index c7da70b6716..f1009313e76 100644 --- a/android/quest/src/test/java/org/smartregister/fhircore/quest/event/EventBusTest.kt +++ b/android/quest/src/test/java/org/smartregister/fhircore/quest/event/EventBusTest.kt @@ -16,15 +16,15 @@ package org.smartregister.fhircore.quest.event +import com.google.android.fhir.datacapture.extensions.logicalId import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidTest import javax.inject.Inject import kotlin.test.assertEquals -import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlin.test.assertTrue import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.launch -import kotlinx.coroutines.test.runBlockingTest +import kotlinx.coroutines.test.runTest import org.hl7.fhir.r4.model.QuestionnaireResponse import org.junit.Before import org.junit.Rule @@ -38,39 +38,42 @@ class EventBusTest : RobolectricTest() { @get:Rule(order = 0) val hiltRule = HiltAndroidRule(this) - @Inject lateinit var eventQueue: EventQueue - private lateinit var eventBus: EventBus - lateinit var emittedEvents: MutableList + @Inject lateinit var eventBus: EventBus @Before fun setUp() { hiltRule.inject() - emittedEvents = mutableListOf() - eventBus = EventBus(eventQueue) } @Test - @OptIn(ExperimentalCoroutinesApi::class) fun testTriggerEventEmitsLogoutEvent1() { - val onSubmitQuestionnaireEvent = - AppEvent.OnSubmitQuestionnaire( - QuestionnaireSubmission( - questionnaireConfig = QuestionnaireConfig(id = "submit-questionnaire"), - QuestionnaireResponse(), - ), - ) + runTest { + val onSubmitQuestionnaireEvent = + AppEvent.OnSubmitQuestionnaire( + QuestionnaireSubmission( + questionnaireConfig = QuestionnaireConfig(id = "questionnaire1"), + questionnaireResponse = QuestionnaireResponse().apply { id = "questionnaireResponse1" }, + ), + ) - runBlockingTest { - val collectJob = launch { + val job = eventBus.events - .getFor("TestTag") - .onEach { appEvent -> emittedEvents.add(appEvent) } + .getFor("thisConsumer") + .onEach { + assertTrue(it is AppEvent.OnSubmitQuestionnaire) + assertEquals( + onSubmitQuestionnaireEvent.questionnaireSubmission.questionnaireConfig.id, + it.questionnaireSubmission.questionnaireConfig.id, + ) + assertEquals( + onSubmitQuestionnaireEvent.questionnaireSubmission.questionnaireResponse.logicalId, + it.questionnaireSubmission.questionnaireResponse.logicalId, + ) + } .launchIn(this) - } + eventBus.triggerEvent(onSubmitQuestionnaireEvent) - collectJob.cancel() + job.cancel() } - - assertEquals(onSubmitQuestionnaireEvent, emittedEvents[0]) } } diff --git a/android/quest/src/test/java/org/smartregister/fhircore/quest/pdf/PdfLauncherViewModelTest.kt b/android/quest/src/test/java/org/smartregister/fhircore/quest/pdf/PdfLauncherViewModelTest.kt new file mode 100644 index 00000000000..7ff831355f3 --- /dev/null +++ b/android/quest/src/test/java/org/smartregister/fhircore/quest/pdf/PdfLauncherViewModelTest.kt @@ -0,0 +1,103 @@ +/* + * Copyright 2021-2024 Ona Systems, Inc + * + * 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 org.smartregister.fhircore.quest.pdf + +import com.google.android.fhir.FhirEngine +import com.google.android.fhir.SearchResult +import com.google.android.fhir.search.Search +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import io.mockk.coEvery +import io.mockk.mockk +import java.util.Date +import javax.inject.Inject +import kotlinx.coroutines.test.runTest +import org.hl7.fhir.r4.model.Patient +import org.hl7.fhir.r4.model.Questionnaire +import org.hl7.fhir.r4.model.QuestionnaireResponse +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.smartregister.fhircore.engine.data.local.ContentCache +import org.smartregister.fhircore.engine.data.local.DefaultRepository +import org.smartregister.fhircore.engine.util.extension.asReference +import org.smartregister.fhircore.engine.util.extension.yesterday +import org.smartregister.fhircore.quest.robolectric.RobolectricTest +import org.smartregister.fhircore.quest.ui.pdf.PdfLauncherViewModel + +@HiltAndroidTest +class PdfLauncherViewModelTest : RobolectricTest() { + + @get:Rule(order = 0) val hiltAndroidRule = HiltAndroidRule(this) + + @Inject lateinit var contentCache: ContentCache + + private lateinit var fhirEngine: FhirEngine + private lateinit var defaultRepository: DefaultRepository + private lateinit var viewModel: PdfLauncherViewModel + + @Before + fun setUp() { + hiltAndroidRule.inject() + fhirEngine = mockk() + defaultRepository = + DefaultRepository( + fhirEngine = fhirEngine, + dispatcherProvider = mockk(), + sharedPreferencesHelper = mockk(), + configurationRegistry = mockk(), + configService = mockk(), + configRulesExecutor = mockk(), + fhirPathDataExtractor = mockk(), + parser = mockk(), + context = mockk(), + contentCache = contentCache, + ) + viewModel = PdfLauncherViewModel(defaultRepository) + } + + @Test + fun testRetrieveQuestionnaireResponseReturnsLatestResponse() = runTest { + val patient = Patient().apply { id = "p1" } + val questionnaire = Questionnaire().apply { id = "q1" } + val olderQuestionnaireResponse = + QuestionnaireResponse().apply { + id = "qr2" + meta.lastUpdated = yesterday() + subject = patient.asReference() + setQuestionnaire(questionnaire.asReference().reference) + } + val latestQuestionnaireResponse = + QuestionnaireResponse().apply { + id = "qr1" + meta.lastUpdated = Date() + subject = patient.asReference() + setQuestionnaire(questionnaire.asReference().reference) + } + val questionnaireResponses = + listOf(olderQuestionnaireResponse, latestQuestionnaireResponse).map { + SearchResult(it, null, null) + } + + coEvery { fhirEngine.search(any()) } returns + questionnaireResponses + val result = viewModel.retrieveQuestionnaireResponse(questionnaire.id, "Patient/${patient.id}") + + assertEquals(latestQuestionnaireResponse.id, result!!.id) + } +} diff --git a/android/quest/src/test/java/org/smartregister/fhircore/quest/robolectric/RobolectricTest.kt b/android/quest/src/test/java/org/smartregister/fhircore/quest/robolectric/RobolectricTest.kt index 2e3a93709a0..0698d2978b6 100644 --- a/android/quest/src/test/java/org/smartregister/fhircore/quest/robolectric/RobolectricTest.kt +++ b/android/quest/src/test/java/org/smartregister/fhircore/quest/robolectric/RobolectricTest.kt @@ -100,13 +100,13 @@ abstract class RobolectricTest { fun File.parseSampleResource(): IBaseResource = sanitizeSampleResourceContent(this.readText()) - fun sanitizeSampleResourceContent(content: String): IBaseResource = + private fun sanitizeSampleResourceContent(content: String): IBaseResource = content .replace("#TODAY", Date().formatDate(SDF_YYYY_MM_DD)) .replace("#NOW", DateTimeType.now().valueAsString) .let { FhirContext.forCached(FhirVersionEnum.R4).newJsonParser().parseResource(it) } - fun IBaseResource.convertToString(trimTime: Boolean) = + fun IBaseResource.convertToString(trimTime: Boolean): String = FhirContext.forCached(FhirVersionEnum.R4).newJsonParser().encodeResourceToString(this).let { // replace time part 11:11:11+05:00 with xx:xx:xx+xx:xx if (trimTime) { @@ -119,8 +119,9 @@ abstract class RobolectricTest { fun String.replaceTimePart() = // replace time part 11:11:11+05:00 with xx:xx:xx+xx:xx // replace time part 11:11:11.111+05:00 with xx:xx:xx+xx:xx - this.replace(Regex("\\d{2}:\\d{2}:\\d{2}.\\d{2}:\\d{2}"), "xx:xx:xx+xx:xx") - .replace(Regex("\\d{2}:\\d{2}:\\d{2}.\\d{3}.\\d{2}:\\d{2}"), "xx:xx:xx+xx:xx") + // replace time part 18:33:04.520481+03:00 + this.replace(Regex("\\d{2}:\\d{2}:\\d{2}.\\d[0-9,+]+:\\d{2}"), "xx:xx:xx+xx:xx") + .replace(Regex("\\d{2}:\\d{2}:\\d{2}.\\d{3}.\\d[0-9,+]+:\\d{2}"), "xx:xx:xx+xx:xx") fun buildStructureMapUtils(): StructureMapUtilities { val pcm = FilesystemPackageCacheManager(true) @@ -136,7 +137,8 @@ abstract class RobolectricTest { return StructureMapUtilities(contextR4, transformSupportServices) } - fun StructureMapUtilities.worker(): IWorkerContext = ReflectionHelpers.getField(this, "worker") + private fun StructureMapUtilities.worker(): IWorkerContext = + ReflectionHelpers.getField(this, "worker") fun transform( scu: StructureMapUtilities, diff --git a/android/quest/src/test/java/org/smartregister/fhircore/quest/robolectric/WorkManagerRule.kt b/android/quest/src/test/java/org/smartregister/fhircore/quest/robolectric/WorkManagerRule.kt index 9239735acb6..515eb206881 100644 --- a/android/quest/src/test/java/org/smartregister/fhircore/quest/robolectric/WorkManagerRule.kt +++ b/android/quest/src/test/java/org/smartregister/fhircore/quest/robolectric/WorkManagerRule.kt @@ -17,8 +17,10 @@ package org.smartregister.fhircore.quest.robolectric import android.util.Log +import androidx.test.core.app.ApplicationProvider import androidx.test.platform.app.InstrumentationRegistry import androidx.work.Configuration +import androidx.work.WorkManager import androidx.work.testing.SynchronousExecutor import androidx.work.testing.WorkManagerTestInitHelper import org.junit.rules.TestRule @@ -37,7 +39,11 @@ class WorkManagerRule : TestRule { .setExecutor(SynchronousExecutor()) .build() WorkManagerTestInitHelper.initializeTestWorkManager(context, config) - base.evaluate() + try { + base.evaluate() + } finally { + WorkManager.getInstance(ApplicationProvider.getApplicationContext()).cancelAllWork() + } } } } diff --git a/android/quest/src/test/java/org/smartregister/fhircore/quest/sdk/CqlBuilder.kt b/android/quest/src/test/java/org/smartregister/fhircore/quest/sdk/CqlBuilder.kt index e193864813c..28b710e0d79 100644 --- a/android/quest/src/test/java/org/smartregister/fhircore/quest/sdk/CqlBuilder.kt +++ b/android/quest/src/test/java/org/smartregister/fhircore/quest/sdk/CqlBuilder.kt @@ -16,7 +16,6 @@ package org.smartregister.fhircore.quest.sdk -import ca.uhn.fhir.context.FhirContext import java.io.InputStream import org.cqframework.cql.cql2elm.CqlTranslator import org.cqframework.cql.cql2elm.LibraryManager @@ -34,7 +33,6 @@ import org.junit.Assert.fail // required object CqlBuilder : Loadable() { - private val jsonParser = FhirContext.forR4Cached().newJsonParser() /** * Compiles a CQL Text to ELM diff --git a/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/appsetting/AppSettingViewModelTest.kt b/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/appsetting/AppSettingViewModelTest.kt index 4ba7ab35c2c..069e9c75430 100644 --- a/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/appsetting/AppSettingViewModelTest.kt +++ b/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/appsetting/AppSettingViewModelTest.kt @@ -19,7 +19,6 @@ package org.smartregister.fhircore.quest.ui.appsetting import android.content.Context import androidx.lifecycle.MutableLiveData import androidx.test.core.app.ApplicationProvider -import ca.uhn.fhir.util.JsonUtil import com.google.android.fhir.FhirEngine import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidTest @@ -30,16 +29,14 @@ import io.mockk.every import io.mockk.just import io.mockk.mockk import io.mockk.runs -import io.mockk.slot import io.mockk.spyk import io.mockk.verify import java.net.UnknownHostException import java.nio.charset.StandardCharsets -import java.util.Base64 import javax.inject.Inject -import kotlin.test.assertEquals import kotlin.time.Duration.Companion.seconds import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.runTest import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.RequestBody @@ -62,12 +59,9 @@ import org.mockito.ArgumentMatchers.anyString import org.smartregister.fhircore.engine.R import org.smartregister.fhircore.engine.configuration.ConfigurationRegistry import org.smartregister.fhircore.engine.configuration.app.ConfigService -import org.smartregister.fhircore.engine.configuration.profile.ProfileConfiguration import org.smartregister.fhircore.engine.configuration.register.RegisterConfiguration import org.smartregister.fhircore.engine.data.local.DefaultRepository import org.smartregister.fhircore.engine.data.remote.fhir.resource.FhirResourceDataSource -import org.smartregister.fhircore.engine.domain.model.FhirResourceConfig -import org.smartregister.fhircore.engine.domain.model.ResourceConfig import org.smartregister.fhircore.engine.util.DispatcherProvider import org.smartregister.fhircore.engine.util.SharedPreferenceKey import org.smartregister.fhircore.engine.util.SharedPreferencesHelper @@ -137,186 +131,37 @@ class AppSettingViewModelTest : RobolectricTest() { @Test fun testFetchConfigurations() = - runTest(timeout = 90.seconds) { - fhirEngine.create(Composition().apply { id = "sampleComposition" }) + runTest(timeout = 90.seconds, context = UnconfinedTestDispatcher()) { val appId = "test_app_id" appSettingViewModel.onApplicationIdChanged(appId) - coEvery { fhirResourceDataSource.getResource(any()) } returns - Bundle().apply { - addEntry().resource = - Composition().apply { - addSection().apply { this.focus = Reference().apply { reference = "Binary/123" } } - } - } - coEvery { appSettingViewModel.defaultRepository.createRemote(any(), any()) } just runs - - appSettingViewModel.fetchConfigurations(context) - - coVerify { fhirResourceDataSource.getResource(any()) } - coVerify { appSettingViewModel.defaultRepository.createRemote(any(), any()) } - } - - @Test - fun `fetchConfigurations() should call configurationRegistry#processResultBundleBinaries with correct values`() = - runTest { - coEvery { - appSettingViewModel.configurationRegistry.fetchRemoteImplementationGuideByAppId( - any(), - any(), - ) - } returns null - coEvery { appSettingViewModel.configurationRegistry.fetchRemoteCompositionByAppId(any()) } returns Composition().apply { - addSection().apply { - this.focus = - Reference().apply { - reference = "Binary/123" - identifier = Identifier().apply { value = "register-test" } - } - } - } - - val expectedBundle = - Bundle().apply { - addEntry().resource = - Binary().apply { - id = "binary-id-1" - data = - Base64.getEncoder() - .encode( - JsonUtil.serialize( - RegisterConfiguration( - id = "1", - appId = "a", - fhirResource = - FhirResourceConfig( - baseResource = - ResourceConfig( - resource = ResourceType.Patient, - ), - relatedResources = - listOf( - ResourceConfig( - resource = ResourceType.Encounter, - ), - ResourceConfig( - resource = ResourceType.Task, - ), - ), - ), - ), - ) - .encodeToByteArray(), - ) - } + addSection().apply { this.focus = Reference().apply { reference = "Binary/123" } } } - coEvery { fhirResourceDataSource.post(any(), any()) } returns expectedBundle - coEvery { defaultRepository.createRemote(any(), any()) } just runs - coEvery { appSettingViewModel.configurationRegistry.saveSyncSharedPreferences(any()) } just - runs - coEvery { appSettingViewModel.loadConfigurations(any()) } just runs - coEvery { appSettingViewModel.isNonProxy() } returns false coEvery { - appSettingViewModel.configurationRegistry.processResultBundleBinaries(any(), any()) + appSettingViewModel.configurationRegistry.loadConfigurations(any(), any(), any()) } just runs - appSettingViewModel.run { - onApplicationIdChanged("app") - fetchConfigurations(context) - } + coEvery { appSettingViewModel.fhirResourceDataSource.post(requestBody = any()) } returns + Bundle() - val binarySlot = slot() + coEvery { appSettingViewModel.defaultRepository.createRemote(any(), any()) } just runs - coVerify { appSettingViewModel.configurationRegistry.fetchRemoteCompositionByAppId(any()) } - coVerify { fhirResourceDataSource.post(any(), any()) } - coVerify { defaultRepository.createRemote(any(), any()) } - coVerify { - appSettingViewModel.configurationRegistry.processResultBundleBinaries( - capture(binarySlot), - any(), + coEvery { + appSettingViewModel.configurationRegistry.fetchRemoteImplementationGuideByAppId( + appId, + QuestBuildConfig.VERSION_CODE, ) - } - - assertEquals(expectedBundle.entry[0].resource.id, binarySlot.captured.id) - assertEquals((expectedBundle.entry[0].resource as Binary).data, binarySlot.captured.data) - } - - @Test - fun `fetchConfigurations() should decode profile configuration`() = - runTest(timeout = 90.seconds) { - fhirEngine.create( - Composition().apply { id = "sampleComposition" }, - ) // Create sample Composition - - coEvery { appSettingViewModel.fetchComposition(any(), any()) } returns - Composition().apply { - addSection().apply { - this.focus = - Reference().apply { - reference = "Binary/123" - identifier = Identifier().apply { value = "register-test" } - } - } - } - coEvery { fhirResourceDataSource.post(any(), any()) } returns - Bundle().apply { - addEntry().resource = - Binary().apply { - data = - Base64.getEncoder() - .encode( - JsonUtil.serialize( - ProfileConfiguration( - id = "1", - appId = "a", - fhirResource = - FhirResourceConfig( - baseResource = ResourceConfig(resource = ResourceType.Patient), - relatedResources = - listOf( - ResourceConfig( - resource = ResourceType.Encounter, - ), - ResourceConfig( - resource = ResourceType.Task, - ), - ), - ), - profileParams = listOf("1"), - ), - ) - .encodeToByteArray(), - ) - } - } - coEvery { defaultRepository.createRemote(any(), any()) } just runs - coEvery { appSettingViewModel.configurationRegistry.saveSyncSharedPreferences(any()) } just - runs - coEvery { appSettingViewModel.isNonProxy() } returns false - - appSettingViewModel.run { - onApplicationIdChanged("app") - fetchConfigurations(context) - } - - val slot = slot>() + } returns null - coVerify { appSettingViewModel.fetchComposition(any(), any()) } - coVerify { fhirResourceDataSource.post(any(), any()) } - coVerify { defaultRepository.createRemote(any(), any()) } - coVerify { - appSettingViewModel.configurationRegistry.saveSyncSharedPreferences(capture(slot)) - } + appSettingViewModel.fetchConfigurations(context) - Assert.assertEquals( - listOf(ResourceType.Patient, ResourceType.Encounter, ResourceType.Task), - slot.captured, - ) + coVerify { appSettingViewModel.configurationRegistry.fetchRemoteCompositionByAppId(any()) } + coVerify { appSettingViewModel.defaultRepository.createRemote(any(), any()) } } @Test(expected = HttpException::class) @@ -489,10 +334,6 @@ class AppSettingViewModelTest : RobolectricTest() { appSettingViewModel.configurationRegistry.fetchRemoteCompositionByAppId(appId) } returns composition coEvery { appSettingViewModel.defaultRepository.createRemote(any(), any()) } just runs - coEvery { appSettingViewModel.configurationRegistry.saveSyncSharedPreferences(any()) } just runs - coEvery { - appSettingViewModel.configurationRegistry.processResultBundleBinaries(any(), any()) - } just runs coEvery { fhirResourceDataSource.post(any(), any()) } returns Bundle().apply { entry = @@ -543,77 +384,6 @@ class AppSettingViewModelTest : RobolectricTest() { ) } - @Test - fun `fetchConfigurations() should decode profile configuration Non Proxy`() = - runTest(timeout = 90.seconds) { - fhirEngine.create(Composition().apply { id = "sampleComposition" }) - - coEvery { appSettingViewModel.fetchComposition(any(), any()) } returns - Composition().apply { - addSection().apply { - this.focus = - Reference().apply { - reference = "Binary/123" - identifier = Identifier().apply { value = "register-test" } - } - } - } - coEvery { fhirResourceDataSource.getResource(any()) } returns - Bundle().apply { - addEntry().resource = - Binary().apply { - data = - Base64.getEncoder() - .encode( - JsonUtil.serialize( - ProfileConfiguration( - id = "1", - appId = "a", - fhirResource = - FhirResourceConfig( - baseResource = ResourceConfig(resource = ResourceType.Patient), - relatedResources = - listOf( - ResourceConfig( - resource = ResourceType.Encounter, - ), - ResourceConfig( - resource = ResourceType.Task, - ), - ), - ), - profileParams = listOf("1"), - ), - ) - .encodeToByteArray(), - ) - } - } - coEvery { defaultRepository.createRemote(any(), any()) } just runs - coEvery { appSettingViewModel.configurationRegistry.saveSyncSharedPreferences(any()) } just - runs - coEvery { appSettingViewModel.isNonProxy() } returns true - - appSettingViewModel.run { - onApplicationIdChanged("app") - fetchConfigurations(context) - } - - val slot = slot>() - - coVerify { appSettingViewModel.fetchComposition(any(), any()) } - coVerify { fhirResourceDataSource.getResource(any()) } - coVerify { defaultRepository.createRemote(any(), any()) } - coVerify { - appSettingViewModel.configurationRegistry.saveSyncSharedPreferences(capture(slot)) - } - - Assert.assertEquals( - listOf(ResourceType.Patient, ResourceType.Encounter, ResourceType.Task), - slot.captured, - ) - } - @Test fun `fetchConfigurations() with an ImplementationGuide should call fetchRemoteCompositionById()`() { runBlocking { @@ -646,8 +416,6 @@ class AppSettingViewModelTest : RobolectricTest() { coEvery { appSettingViewModel.configurationRegistry.fetchRemoteCompositionById(any(), any()) } returns composition - coEvery { appSettingViewModel.configurationRegistry.saveSyncSharedPreferences(any()) } just - runs coEvery { appSettingViewModel.defaultRepository.createRemote(any(), any()) } just runs appSettingViewModel.fetchConfigurations(context) coVerify { @@ -673,8 +441,6 @@ class AppSettingViewModelTest : RobolectricTest() { coEvery { appSettingViewModel.configurationRegistry.fetchRemoteCompositionByAppId(any()) } returns composition - coEvery { appSettingViewModel.configurationRegistry.saveSyncSharedPreferences(any()) } just - runs coEvery { appSettingViewModel.defaultRepository.createRemote(any(), any()) } just runs appSettingViewModel.fetchConfigurations(context) coVerify { appSettingViewModel.configurationRegistry.fetchRemoteCompositionByAppId(any()) } diff --git a/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/geowidget/GeoWidgetLauncherViewModelTest.kt b/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/geowidget/GeoWidgetLauncherViewModelTest.kt index 6953f9da830..dcfa53e5ea7 100644 --- a/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/geowidget/GeoWidgetLauncherViewModelTest.kt +++ b/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/geowidget/GeoWidgetLauncherViewModelTest.kt @@ -16,148 +16,137 @@ package org.smartregister.fhircore.quest.ui.geowidget -import androidx.arch.core.executor.testing.InstantTaskExecutorRule -import io.mockk.mockk +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import dagger.hilt.android.testing.HiltTestApplication +import javax.inject.Inject +import kotlin.test.assertTrue import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.runTest +import org.hl7.fhir.r4.model.DecimalType +import org.hl7.fhir.r4.model.Location +import org.hl7.fhir.r4.model.ResourceType import org.junit.Assert.assertEquals import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull import org.junit.Before +import org.junit.Ignore import org.junit.Rule import org.junit.Test -import org.junit.rules.TestRule +import org.smartregister.fhircore.engine.configuration.QuestionnaireConfig +import org.smartregister.fhircore.engine.configuration.geowidget.GeoWidgetConfiguration +import org.smartregister.fhircore.engine.configuration.register.NoResultsConfig import org.smartregister.fhircore.engine.data.local.DefaultRepository +import org.smartregister.fhircore.engine.domain.model.FhirResourceConfig +import org.smartregister.fhircore.engine.domain.model.ResourceConfig +import org.smartregister.fhircore.engine.domain.model.SnackBarMessageConfig import org.smartregister.fhircore.engine.rulesengine.ResourceDataRulesExecutor -import org.smartregister.fhircore.engine.util.DispatcherProvider +import org.smartregister.fhircore.engine.util.DefaultDispatcherProvider import org.smartregister.fhircore.engine.util.SharedPreferencesHelper -import org.smartregister.fhircore.geowidget.model.Coordinates -import org.smartregister.fhircore.geowidget.model.Feature -import org.smartregister.fhircore.geowidget.model.Geometry -import org.smartregister.fhircore.geowidget.model.ServicePointType -import org.smartregister.fhircore.geowidget.screens.GeoWidgetViewModel -import org.smartregister.fhircore.quest.ui.launcher.GeoWidgetLauncherViewModel +import org.smartregister.fhircore.quest.app.fakes.Faker +import org.smartregister.fhircore.quest.robolectric.RobolectricTest @ExperimentalCoroutinesApi -class GeoWidgetLauncherViewModelTest { +@HiltAndroidTest +class GeoWidgetLauncherViewModelTest : RobolectricTest() { - @get:Rule var rule: TestRule = InstantTaskExecutorRule() + @get:Rule(order = 0) val hiltAndroidRule = HiltAndroidRule(this) + @Inject lateinit var defaultRepository: DefaultRepository + + @Inject lateinit var dispatcherProvider: DefaultDispatcherProvider + + @Inject lateinit var sharedPreferencesHelper: SharedPreferencesHelper + + @Inject lateinit var resourceDataRulesExecutor: ResourceDataRulesExecutor + + private lateinit var applicationContext: Context + + private val configurationRegistry = Faker.buildTestConfigurationRegistry() private lateinit var viewModel: GeoWidgetLauncherViewModel - private lateinit var defaultRepository: DefaultRepository - private lateinit var mockDispatcherProvider: DispatcherProvider - private lateinit var sharedPreferencesHelper: SharedPreferencesHelper - private lateinit var resourceDataRulesExecutor: ResourceDataRulesExecutor + private val geoWidgetConfiguration = + GeoWidgetConfiguration( + appId = "appId", + id = "id", + registrationQuestionnaire = + QuestionnaireConfig( + id = "id", + ), + resourceConfig = + FhirResourceConfig(baseResource = ResourceConfig(resource = ResourceType.Location)), + servicePointConfig = null, + noResults = + NoResultsConfig( + title = "Message Title", + message = "Message text", + ), + ) + + private val location = + Location().apply { + id = "loc1" + name = "Root Location" + position = Location.LocationPositionComponent(DecimalType(-10.05), DecimalType(5.55)) + } @Before fun setUp() { - defaultRepository = mockk() - mockDispatcherProvider = mockk() - sharedPreferencesHelper = mockk() - resourceDataRulesExecutor = mockk() + hiltAndroidRule.inject() + applicationContext = ApplicationProvider.getApplicationContext() viewModel = GeoWidgetLauncherViewModel( - defaultRepository, - mockDispatcherProvider, - sharedPreferencesHelper, - resourceDataRulesExecutor, + defaultRepository = defaultRepository, + dispatcherProvider = dispatcherProvider, + sharedPreferencesHelper = sharedPreferencesHelper, + resourceDataRulesExecutor = resourceDataRulesExecutor, + configurationRegistry = configurationRegistry, + context = applicationContext, ) + runBlocking { defaultRepository.addOrUpdate(resource = location) } } - private fun getDefaultGeometry() = Geometry(coordinates = listOf(Coordinates(3.7, 41.53))) - - // clearLocations method clears all locations from the map @Test - fun test_clearLocations() { - // Arrange - val viewModel = GeoWidgetViewModel(mockDispatcherProvider) - val locations = - setOf( - Feature(id = "1", type = "Point"), - Feature(id = "2", type = "Point"), + fun testShowNoLocationDialogShouldNotSetLiveDataValueWhenConfigIsNull() = runTest { + val geoWidgetConfiguration = + GeoWidgetConfiguration( + appId = "appId", + id = "id", + registrationQuestionnaire = + QuestionnaireConfig( + id = "id", + ), + resourceConfig = + FhirResourceConfig(baseResource = ResourceConfig(resource = ResourceType.Location)), + servicePointConfig = null, ) - viewModel.addLocationsToMap(locations) - - // Act - viewModel.clearLocations() - - // Assert - assertEquals(0, viewModel.featuresFlow.value.size) - } - - // getServicePointKeyToType method returns a map of service point types - @Test - fun test_getServicePointKeyToType() { - // Arrange - val viewModel = GeoWidgetViewModel(mockDispatcherProvider) - - // Act - val servicePointMap = viewModel.getServicePointKeyToType() - - // Assert - assertEquals(ServicePointType.EPP, servicePointMap["epp"]) - assertEquals(ServicePointType.CEG, servicePointMap["ceg"]) - assertEquals(ServicePointType.CHRD1, servicePointMap["chrd1"]) - // ... (continue for all service point types) - } - - // GeoWidgetViewModel is properly constructed with a DispatcherProvider - @Test - fun test_constructor() { - // Arrange - val viewModel = GeoWidgetViewModel(mockDispatcherProvider) - - // Assert - assertNotNull(viewModel.dispatcherProvider) - } - // featuresFlow is properly initialized as a StateFlow - @Test - fun test_featuresFlowInitialization() { - // Arrange - val viewModel = GeoWidgetViewModel(mockDispatcherProvider) + viewModel.showNoLocationDialog(geoWidgetConfiguration) - // Assert - assertNotNull(viewModel.featuresFlow) + val value = viewModel.noLocationFoundDialog.value + assertNull(value) } - // addLocationsToMap method handles empty set of locations @Test - fun test_addLocationsToMap_emptySet() { - // Arrange - val viewModel = GeoWidgetViewModel(mockDispatcherProvider) - val locations = emptySet() - - // Act - viewModel.addLocationsToMap(locations) - - // Assert - assertEquals(0, viewModel.featuresFlow.value.size) + fun testShowNoLocationDialogShouldSetLiveDataValueWhenConfigIsPresent() = runTest { + viewModel.showNoLocationDialog(geoWidgetConfiguration) + val value = viewModel.noLocationFoundDialog.value + assertNotNull(value) + assertTrue(value!!) } - // addLocationToMap method handles null geometry @Test - fun test_addLocationToMap_nullGeometry() { - // Arrange - val viewModel = GeoWidgetViewModel(mockDispatcherProvider) - val feature = Feature(id = "1", type = "Point", geometry = null) - - // Act - viewModel.addLocationToMap(feature) - - // Assert - assertEquals(0, viewModel.featuresFlow.value.size) - } - - // addLocationToMap method handles null coordinates - @Test - fun test_addLocationToMap_nullCoordinates() { - // Arrange - val viewModel = GeoWidgetViewModel(mockDispatcherProvider) - val feature = Feature(id = "1", type = "Point", geometry = Geometry(null)) - - // Act - viewModel.addLocationToMap(feature) - - // Assert - assertEquals(0, viewModel.featuresFlow.value.size) + @Ignore("Fix kotlinx.coroutines.test.UncompletedCoroutinesError") + fun testEmitSnackBarState() { + runTest { + val barMessageConfig = SnackBarMessageConfig(message = "message") + val deferred = async { viewModel.snackBarStateFlow.first() } + viewModel.emitSnackBarState(barMessageConfig) + assertEquals(barMessageConfig.message, deferred.await().message) + } } } diff --git a/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/login/LoginActivityTest.kt b/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/login/LoginActivityTest.kt index a7483c896ba..3604b6c46fb 100644 --- a/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/login/LoginActivityTest.kt +++ b/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/login/LoginActivityTest.kt @@ -19,7 +19,9 @@ package org.smartregister.fhircore.quest.ui.login import android.content.Context import android.content.Intent import androidx.compose.material.ExperimentalMaterialApi +import androidx.lifecycle.Observer import androidx.test.core.app.ApplicationProvider +import androidx.test.platform.app.InstrumentationRegistry import dagger.hilt.android.testing.BindValue import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidTest @@ -28,7 +30,9 @@ import io.mockk.mockk import io.mockk.mockkObject import io.mockk.spyk import io.mockk.unmockkObject +import io.mockk.verify import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.runBlocking import org.junit.Assert import org.junit.Before import org.junit.Ignore @@ -61,6 +65,7 @@ class LoginActivityTest : RobolectricTest() { private val loginActivityController = Robolectric.buildActivity(Faker.TestLoginActivity::class.java) private lateinit var loginActivity: LoginActivity + val context = InstrumentationRegistry.getInstrumentation().targetContext!! @Before fun setUp() { @@ -78,11 +83,30 @@ class LoginActivityTest : RobolectricTest() { } @Test - fun testLaunchDialPadShouldStartActionDialActivity() { - loginActivity.loginViewModel.forgotPassword() + fun testForgotPasswordLoadsContact() { + val launchDialPadObserver = + Observer { dialPadUri -> + if (dialPadUri != null) { + Assert.assertEquals("1234567890", dialPadUri) + } + } + val context = InstrumentationRegistry.getInstrumentation().targetContext + try { + loginActivity.loginViewModel.launchDialPad.observeForever(launchDialPadObserver) + loginActivity.loginViewModel.forgotPassword(context) + } finally { + loginActivity.loginViewModel.launchDialPad.removeObserver(launchDialPadObserver) + } + } + + @Test + fun testLaunchDialPadStartsDialIntentWithCorrectPhoneNumber() { + val phoneNumber = "1234567890" + loginActivity.launchDialPad(phoneNumber) val resultIntent = shadowOf(loginActivity).nextStartedActivity + Assert.assertNotNull(resultIntent) Assert.assertEquals(Intent.ACTION_DIAL, resultIntent.action) - Assert.assertEquals("tel:0123456789", resultIntent.data.toString()) + Assert.assertEquals(phoneNumber, resultIntent.data?.schemeSpecificPart.toString()) } @Test @@ -125,9 +149,13 @@ class LoginActivityTest : RobolectricTest() { @Test @Ignore("Weird: Cannot set session pin") - fun testNavigateToScreenShouldLaunchPinLoginWithoutSetup() { + fun testNavigateToScreenShouldLaunchPinLoginWithoutSetup() = runBlocking { // Return a session pin, login with pin is enabled by default - secureSharedPreference.saveSessionPin("1234".toCharArray()) + val onSavedPinMock = mockk<() -> Unit>(relaxed = true) + secureSharedPreference.saveSessionPin(pin = "1234".toCharArray(), onSavedPin = onSavedPinMock) + + verify { onSavedPinMock.invoke() } + every { secureSharedPreference.retrieveSessionPin() } returns "1234" loginActivity.loginViewModel.updateNavigateHome(true) diff --git a/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/login/LoginViewModelTest.kt b/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/login/LoginViewModelTest.kt index 5f0b9d57f11..8f5c4bd19cd 100644 --- a/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/login/LoginViewModelTest.kt +++ b/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/login/LoginViewModelTest.kt @@ -16,6 +16,9 @@ package org.smartregister.fhircore.quest.ui.login +import android.content.Context +import androidx.lifecycle.Observer +import androidx.test.core.app.ApplicationProvider import androidx.work.OneTimeWorkRequest import androidx.work.WorkManager import androidx.work.WorkRequest @@ -53,6 +56,7 @@ import org.junit.Before import org.junit.Rule import org.junit.Test import org.robolectric.annotation.Config +import org.smartregister.fhircore.engine.configuration.ConfigType import org.smartregister.fhircore.engine.configuration.app.ConfigService import org.smartregister.fhircore.engine.data.local.DefaultRepository import org.smartregister.fhircore.engine.data.remote.auth.KeycloakService @@ -65,6 +69,7 @@ import org.smartregister.fhircore.engine.util.DispatcherProvider import org.smartregister.fhircore.engine.util.SecureSharedPreference import org.smartregister.fhircore.engine.util.SharedPreferenceKey import org.smartregister.fhircore.engine.util.SharedPreferencesHelper +import org.smartregister.fhircore.engine.util.extension.formatPhoneNumber import org.smartregister.fhircore.engine.util.extension.isDeviceOnline import org.smartregister.fhircore.engine.util.test.HiltActivityForTest import org.smartregister.fhircore.quest.app.fakes.Faker @@ -617,8 +622,50 @@ internal class LoginViewModelTest : RobolectricTest() { @Test fun testForgotPasswordLoadsContact() { - loginViewModel.forgotPassword() - Assert.assertEquals("tel:0123456789", loginViewModel.launchDialPad.value) + val context = ApplicationProvider.getApplicationContext() + val validContactNumber = "1234567890" + configurationRegistry.configCacheMap[ConfigType.Application.name] = + loginViewModel.applicationConfiguration.copy( + loginConfig = + loginViewModel.applicationConfiguration.loginConfig.copy( + supervisorContactNumber = validContactNumber, + ), + ) + val expectedFormattedNumber = validContactNumber.formatPhoneNumber(context) + val dialPadUriSlot = slot() + val launchDialPadObserver = Observer { dialPadUriSlot.captured = it } + + loginViewModel.launchDialPad.observeForever(launchDialPadObserver) + + try { + loginViewModel.forgotPassword(context) + Assert.assertEquals(expectedFormattedNumber, dialPadUriSlot.captured) + } finally { + loginViewModel.launchDialPad.removeObserver(launchDialPadObserver) + } + } + + @Test + fun testForgotPasswordWithValidContactNumber() { + val context = ApplicationProvider.getApplicationContext() + val validContactNumber = "1234567890" + configurationRegistry.configCacheMap[ConfigType.Application.name] = + loginViewModel.applicationConfiguration.copy( + loginConfig = + loginViewModel.applicationConfiguration.loginConfig.copy( + supervisorContactNumber = validContactNumber, + ), + ) + val expectedFormattedNumber = validContactNumber.formatPhoneNumber(context) + + val launchDialPadObserver = slot() + loginViewModel.launchDialPad.observeForever { launchDialPadObserver.captured = it } + + loginViewModel.forgotPassword(context) + + Assert.assertEquals(expectedFormattedNumber, launchDialPadObserver.captured) + + loginViewModel.launchDialPad.removeObserver { launchDialPadObserver.captured = it } } @Test diff --git a/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/main/AppMainActivityTest.kt b/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/main/AppMainActivityTest.kt index 79848a593ad..311c8d81463 100644 --- a/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/main/AppMainActivityTest.kt +++ b/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/main/AppMainActivityTest.kt @@ -25,7 +25,6 @@ import androidx.navigation.fragment.NavHostFragment import com.google.android.fhir.sync.CurrentSyncJobStatus import com.google.android.fhir.sync.SyncJobStatus import com.google.android.fhir.sync.SyncOperation -import com.google.android.gms.location.LocationServices import dagger.hilt.android.testing.BindValue import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidTest @@ -38,8 +37,6 @@ import io.mockk.slot import io.mockk.spyk import java.io.Serializable import java.time.OffsetDateTime -import junit.framework.TestCase -import kotlin.test.assertNotNull import kotlinx.coroutines.test.runTest import org.hl7.fhir.r4.model.QuestionnaireResponse import org.junit.Assert @@ -127,14 +124,15 @@ class AppMainActivityTest : ActivityRobolectricTest() { } @Test - fun testOnSyncWithSyncStateSucceded() { + fun testOnSyncWithSyncStateSucceeded() { + // Arrange val viewModel = appMainActivity.appMainViewModel val stateSucceded = CurrentSyncJobStatus.Succeeded(OffsetDateTime.now()) appMainActivity.onSync(stateSucceded) Assert.assertEquals( viewModel.formatLastSyncTimestamp(timestamp = stateSucceded.timestamp), - viewModel.retrieveLastSyncTimestamp(), + viewModel.getSyncTime(), ) } @@ -204,15 +202,4 @@ class AppMainActivityTest : ActivityRobolectricTest() { val resultLauncher = appMainActivity.startForResult Assert.assertNotNull(resultLauncher) } - - @Test - fun `setupLocationServices should launch location permissions dialog if permissions are not granted`() { - val fusedLocationProviderClient = - LocationServices.getFusedLocationProviderClient(appMainActivity) - assertNotNull(fusedLocationProviderClient) - TestCase.assertFalse(appMainActivity.hasLocationPermissions()) - - val dialog = appMainActivity.launchLocationPermissionsDialog() - assertNotNull(dialog) - } } diff --git a/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/main/AppMainViewModelTest.kt b/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/main/AppMainViewModelTest.kt index fab0d50698b..32fde4d4045 100644 --- a/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/main/AppMainViewModelTest.kt +++ b/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/main/AppMainViewModelTest.kt @@ -23,6 +23,7 @@ import android.widget.Toast import androidx.navigation.NavController import androidx.test.core.app.ApplicationProvider import androidx.work.WorkManager +import com.google.android.fhir.FhirEngine import com.google.android.fhir.sync.CurrentSyncJobStatus import com.google.gson.Gson import dagger.hilt.android.testing.BindValue @@ -70,13 +71,16 @@ import org.smartregister.fhircore.engine.util.DispatcherProvider import org.smartregister.fhircore.engine.util.SecureSharedPreference import org.smartregister.fhircore.engine.util.SharedPreferenceKey import org.smartregister.fhircore.engine.util.SharedPreferencesHelper +import org.smartregister.fhircore.engine.util.extension.formatDate import org.smartregister.fhircore.engine.util.extension.isDeviceOnline +import org.smartregister.fhircore.engine.util.extension.reformatDate import org.smartregister.fhircore.engine.util.extension.showToast import org.smartregister.fhircore.engine.util.test.HiltActivityForTest import org.smartregister.fhircore.quest.app.fakes.Faker import org.smartregister.fhircore.quest.navigation.MainNavigationScreen import org.smartregister.fhircore.quest.navigation.NavigationArg import org.smartregister.fhircore.quest.robolectric.RobolectricTest +import org.smartregister.fhircore.quest.ui.main.AppMainViewModel.Companion.SYNC_TIMESTAMP_OUTPUT_FORMAT import org.smartregister.fhircore.quest.ui.shared.models.QuestionnaireSubmission @HiltAndroidTest @@ -90,6 +94,8 @@ class AppMainViewModelTest : RobolectricTest() { @Inject lateinit var dispatcherProvider: DispatcherProvider + @Inject lateinit var fhirEngine: FhirEngine + @BindValue val configurationRegistry: ConfigurationRegistry = Faker.buildTestConfigurationRegistry() @@ -123,6 +129,7 @@ class AppMainViewModelTest : RobolectricTest() { dispatcherProvider = dispatcherProvider, workManager = workManager, fhirCarePlanGenerator = fhirCarePlanGenerator, + fhirEngine = fhirEngine, ), ) runBlocking { configurationRegistry.loadConfigurations("app/debug", application) } @@ -181,7 +188,7 @@ class AppMainViewModelTest : RobolectricTest() { ) Assert.assertEquals( appMainViewModel.formatLastSyncTimestamp(syncFinishedTimestamp), - sharedPreferencesHelper.read(SharedPreferenceKey.LAST_SYNC_TIMESTAMP.name, null), + appMainViewModel.getSyncTime(), ) coVerify { appMainViewModel.retrieveAppMainUiState() } } @@ -310,6 +317,33 @@ class AppMainViewModelTest : RobolectricTest() { } } + @Test + fun testGetSyncTimeWithMillisTimestamp() { + // Mocking a timestamp in milliseconds + val mockTimestamp = OffsetDateTime.now().toInstant().toEpochMilli().toString() + every { appMainViewModel.retrieveLastSyncTimestamp() } returns mockTimestamp + every { appMainViewModel.applicationConfiguration.dateFormat } returns "yyyy-MM-dd HH:mm:ss" + val syncTime = appMainViewModel.getSyncTime() + val expectedFormattedDate = + formatDate(mockTimestamp.toLong(), appMainViewModel.applicationConfiguration.dateFormat) + Assert.assertEquals(expectedFormattedDate, syncTime) + } + + @Test + fun testGetSyncTimeWithFormattedDate() { + val mockFormattedDate = "2023-10-10 10:10:10" + every { appMainViewModel.retrieveLastSyncTimestamp() } returns mockFormattedDate + every { appMainViewModel.applicationConfiguration.dateFormat } returns "yyyy-MM-dd" + val syncTime = appMainViewModel.getSyncTime() + val expectedReformattedDate = + reformatDate( + inputDateString = mockFormattedDate, + currentFormat = SYNC_TIMESTAMP_OUTPUT_FORMAT, + desiredFormat = appMainViewModel.applicationConfiguration.dateFormat, + ) + Assert.assertEquals(expectedReformattedDate, syncTime) + } + @Test @kotlinx.coroutines.ExperimentalCoroutinesApi fun testOnSubmitQuestionnaireShouldNeverUpdateTaskStatusWhenQuestionnaireTaskIdIsNull() = diff --git a/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/pdf/PdfGeneratorTest.kt b/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/pdf/PdfGeneratorTest.kt new file mode 100644 index 00000000000..567704580a3 --- /dev/null +++ b/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/pdf/PdfGeneratorTest.kt @@ -0,0 +1,70 @@ +/* + * Copyright 2021-2024 Ona Systems, Inc + * + * 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 org.smartregister.fhircore.quest.ui.pdf + +import android.content.Context +import android.print.PrintAttributes +import android.print.PrintDocumentAdapter +import android.print.PrintManager +import android.webkit.WebView +import android.webkit.WebViewClient +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import org.junit.Before +import org.junit.Test + +class PdfGeneratorTest { + + private lateinit var pdfGenerator: PdfGenerator + private val mockContext = mockk(relaxed = true) + private val mockPrintManager = mockk(relaxed = true) + private val mockWebView = mockk(relaxed = true) + private val mockPrintDocumentAdapter = mockk(relaxed = true) + + @Before + fun setUp() { + every { mockContext.getSystemService(Context.PRINT_SERVICE) } returns mockPrintManager + every { mockWebView.createPrintDocumentAdapter(any()) } returns mockPrintDocumentAdapter + pdfGenerator = PdfGenerator(mockContext, mockWebView) + } + + @Test + fun testPdfIsPrintedWithCorrectParameters() { + val pdfTitle = "SamplePDF" + + pdfGenerator.generatePdfWithHtml("", pdfTitle) {} + + // Capture the WebViewClient that is set on the WebView + val webViewClientSlot = slot() + verify { mockWebView.webViewClient = capture(webViewClientSlot) } + + // Manually invoke the onPageFinished method to simulate page load completion + webViewClientSlot.captured.onPageFinished(mockWebView, "url") + + // Verify createPrintDocumentAdapter and printManager.print calls + verify { mockWebView.createPrintDocumentAdapter(pdfTitle) } + verify { + mockPrintManager.print( + eq(pdfTitle), + eq(mockPrintDocumentAdapter), + any(), + ) + } + } +} diff --git a/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/pdf/PdfLauncherFragmentTest.kt b/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/pdf/PdfLauncherFragmentTest.kt new file mode 100644 index 00000000000..7f797681e49 --- /dev/null +++ b/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/pdf/PdfLauncherFragmentTest.kt @@ -0,0 +1,200 @@ +/* + * Copyright 2021-2024 Ona Systems, Inc + * + * 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 org.smartregister.fhircore.quest.ui.pdf + +import android.os.Bundle +import dagger.hilt.android.testing.BindValue +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.runBlocking +import org.hl7.fhir.r4.model.Binary +import org.hl7.fhir.r4.model.QuestionnaireResponse +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.robolectric.Robolectric +import org.smartregister.fhircore.engine.configuration.PdfConfig +import org.smartregister.fhircore.engine.util.extension.encodeJson +import org.smartregister.fhircore.quest.app.fakes.HiltTestActivity +import org.smartregister.fhircore.quest.robolectric.RobolectricTest + +@HiltAndroidTest +class PdfLauncherFragmentTest : RobolectricTest() { + + @get:Rule val hiltRule = HiltAndroidRule(this) + + @BindValue val pdfLauncherViewModel: PdfLauncherViewModel = mockk(relaxed = true) + + private val pdfGenerator: PdfGenerator = mockk(relaxed = true) + + @Before + fun setUp() { + hiltRule.inject() + } + + @Test + fun testPdfGeneration() = runBlocking { + val questionnaireResponse = QuestionnaireResponse().apply { questionnaire = "Questionnaire/id" } + val htmlBinary = Binary().apply { content = "mock content".toByteArray() } + + coEvery { pdfLauncherViewModel.retrieveQuestionnaireResponse(any(), any()) } returns + questionnaireResponse + coEvery { pdfLauncherViewModel.retrieveBinary(any()) } returns htmlBinary + + val pdfConfig = + PdfConfig( + title = "title", + titleSuffix = "suffix", + structureReference = "Binary/id", + subjectReference = "Patient/id", + questionnaireReferences = listOf("QuestionnaireResponse/id"), + ) + .encodeJson() + + val fragmentArgs = + Bundle().apply { putString(PdfLauncherFragment.EXTRA_PDF_CONFIG_KEY, pdfConfig) } + + val activity = Robolectric.buildActivity(HiltTestActivity::class.java).create().resume().get() + + val fragment = + PdfLauncherFragment().apply { + arguments = fragmentArgs + pdfGenerator = this@PdfLauncherFragmentTest.pdfGenerator + } + + activity.supportFragmentManager.beginTransaction().add(fragment, null).commitNow() + + coVerify { pdfLauncherViewModel.retrieveQuestionnaireResponse(any(), any()) } + coVerify { pdfLauncherViewModel.retrieveBinary(any()) } + verify { pdfGenerator.generatePdfWithHtml(any(), any(), any()) } + } + + @Test + fun testPdfGenerationWhenQuestionnaireResponseIsNull() = runBlocking { + val questionnaireResponse: QuestionnaireResponse? = null + val htmlBinary = Binary().apply { content = "mock content".toByteArray() } + + coEvery { pdfLauncherViewModel.retrieveQuestionnaireResponse(any(), any()) } returns + questionnaireResponse + coEvery { pdfLauncherViewModel.retrieveBinary(any()) } returns htmlBinary + + val pdfConfig = + PdfConfig( + title = "title", + titleSuffix = "suffix", + structureReference = "Binary/id", + subjectReference = "Patient/id", + questionnaireReferences = listOf("QuestionnaireResponse/id"), + ) + .encodeJson() + + val fragmentArgs = + Bundle().apply { putString(PdfLauncherFragment.EXTRA_PDF_CONFIG_KEY, pdfConfig) } + + val activity = Robolectric.buildActivity(HiltTestActivity::class.java).create().resume().get() + + val fragment = + PdfLauncherFragment().apply { + arguments = fragmentArgs + pdfGenerator = this@PdfLauncherFragmentTest.pdfGenerator + } + + activity.supportFragmentManager.beginTransaction().add(fragment, null).commitNow() + + coVerify { pdfLauncherViewModel.retrieveQuestionnaireResponse(any(), any()) } + coVerify { pdfLauncherViewModel.retrieveBinary(any()) } + verify(inverse = true) { pdfGenerator.generatePdfWithHtml(any(), any(), any()) } + } + + @Test + fun testPdfGenerationWhenHtmlBinaryIsNull() = runBlocking { + val questionnaireResponse = QuestionnaireResponse() + val htmlBinary: Binary? = null + + coEvery { pdfLauncherViewModel.retrieveQuestionnaireResponse(any(), any()) } returns + questionnaireResponse + coEvery { pdfLauncherViewModel.retrieveBinary(any()) } returns htmlBinary + + val pdfConfig = + PdfConfig( + title = "title", + titleSuffix = "suffix", + structureReference = "Binary/id", + subjectReference = "Patient/id", + questionnaireReferences = listOf("QuestionnaireResponse/id"), + ) + .encodeJson() + + val fragmentArgs = + Bundle().apply { putString(PdfLauncherFragment.EXTRA_PDF_CONFIG_KEY, pdfConfig) } + + val activity = Robolectric.buildActivity(HiltTestActivity::class.java).create().resume().get() + + val fragment = + PdfLauncherFragment().apply { + arguments = fragmentArgs + pdfGenerator = this@PdfLauncherFragmentTest.pdfGenerator + } + + activity.supportFragmentManager.beginTransaction().add(fragment, null).commitNow() + + coVerify { pdfLauncherViewModel.retrieveQuestionnaireResponse(any(), any()) } + coVerify { pdfLauncherViewModel.retrieveBinary(any()) } + verify(inverse = true) { pdfGenerator.generatePdfWithHtml(any(), any(), any()) } + } + + @Test + fun testPdfGenerationWhenQuestionnaireResponseAndHtmlBinaryIsNull() = runBlocking { + val questionnaireResponse: QuestionnaireResponse? = null + val htmlBinary: Binary? = null + + coEvery { pdfLauncherViewModel.retrieveQuestionnaireResponse(any(), any()) } returns + questionnaireResponse + coEvery { pdfLauncherViewModel.retrieveBinary(any()) } returns htmlBinary + + val pdfConfig = + PdfConfig( + title = "title", + titleSuffix = "suffix", + structureReference = "Binary/id", + subjectReference = "Patient/id", + questionnaireReferences = listOf("QuestionnaireResponse/id"), + ) + .encodeJson() + + val fragmentArgs = + Bundle().apply { putString(PdfLauncherFragment.EXTRA_PDF_CONFIG_KEY, pdfConfig) } + + val activity = Robolectric.buildActivity(HiltTestActivity::class.java).create().resume().get() + + val fragment = + PdfLauncherFragment().apply { + arguments = fragmentArgs + pdfGenerator = this@PdfLauncherFragmentTest.pdfGenerator + } + + activity.supportFragmentManager.beginTransaction().add(fragment, null).commitNow() + + coVerify { pdfLauncherViewModel.retrieveQuestionnaireResponse(any(), any()) } + coVerify { pdfLauncherViewModel.retrieveBinary(any()) } + verify(inverse = true) { pdfGenerator.generatePdfWithHtml(any(), any(), any()) } + } +} diff --git a/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/pin/PinLoginActivityTest.kt b/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/pin/PinLoginActivityTest.kt index 08ca6c42b7b..4b1f5b144ef 100644 --- a/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/pin/PinLoginActivityTest.kt +++ b/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/pin/PinLoginActivityTest.kt @@ -26,6 +26,7 @@ import io.mockk.mockk import io.mockk.mockkObject import io.mockk.spyk import io.mockk.unmockkObject +import kotlinx.coroutines.runBlocking import org.junit.After import org.junit.Assert import org.junit.Before @@ -69,10 +70,11 @@ class PinLoginActivityTest : RobolectricTest() { @Test fun testDialPadLaunched() { - pinLoginActivity.pinViewModel.forgotPin() + val phoneNumber = "1234567890" + pinLoginActivity.pinViewModel.launchDialPad.value = phoneNumber val resultIntent = Shadows.shadowOf(pinLoginActivity).nextStartedActivity Assert.assertEquals(Intent.ACTION_DIAL, resultIntent.action) - Assert.assertEquals("tel:####", resultIntent.data.toString()) + Assert.assertEquals(phoneNumber, resultIntent.data?.schemeSpecificPart.toString()) } @Test @@ -97,17 +99,21 @@ class PinLoginActivityTest : RobolectricTest() { @OptIn(ExperimentalMaterialApi::class) @Test - fun testNavigateToHomeLaunchesAppLMainActivity() { + fun testNavigateToHomeLaunchesAppMainActivity() = runBlocking { // Mock p2p Library then un mock it at the end of test mockkObject(P2PLibrary) every { P2PLibrary.init(any()) } returns mockk() // When new pin is setup the app navigates to home screen pinLoginActivity.pinViewModel.onSetPin("1234".toCharArray()) - val resultIntent = Shadows.shadowOf(pinLoginActivity).nextStartedActivity - Assert.assertNotNull(resultIntent) - val shadowIntent: ShadowIntent = Shadows.shadowOf(resultIntent) - Assert.assertEquals(AppMainActivity::class.java, shadowIntent.intentClass) + pinLoginActivity.pinViewModel.navigateToHome.observeForever { isNavigating -> + if (isNavigating == true) { + val resultIntent = Shadows.shadowOf(pinLoginActivity).nextStartedActivity + Assert.assertNotNull(resultIntent) + val shadowIntent: ShadowIntent = Shadows.shadowOf(resultIntent) + Assert.assertEquals(AppMainActivity::class.java, shadowIntent.intentClass) + } + } unmockkObject(P2PLibrary) } diff --git a/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/pin/PinViewModelTest.kt b/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/pin/PinViewModelTest.kt index 580dbe7cbd6..a279eeafbca 100644 --- a/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/pin/PinViewModelTest.kt +++ b/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/pin/PinViewModelTest.kt @@ -17,11 +17,16 @@ package org.smartregister.fhircore.quest.ui.pin import android.app.Application +import android.content.Context +import androidx.lifecycle.Observer import androidx.test.core.app.ApplicationProvider +import androidx.test.platform.app.InstrumentationRegistry import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidTest +import io.mockk.Runs import io.mockk.coEvery import io.mockk.every +import io.mockk.just import io.mockk.mockk import io.mockk.mockkStatic import io.mockk.slot @@ -31,16 +36,20 @@ import io.mockk.verifyOrder import java.util.Base64 import javax.inject.Inject import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.runTest import org.junit.Assert import org.junit.Before import org.junit.Rule import org.junit.Test +import org.robolectric.shadows.ShadowToast +import org.smartregister.fhircore.engine.R import org.smartregister.fhircore.engine.configuration.ConfigType import org.smartregister.fhircore.engine.util.DispatcherProvider import org.smartregister.fhircore.engine.util.SecureSharedPreference import org.smartregister.fhircore.engine.util.SharedPreferenceKey import org.smartregister.fhircore.engine.util.SharedPreferencesHelper +import org.smartregister.fhircore.engine.util.extension.formatPhoneNumber import org.smartregister.fhircore.engine.util.passwordHashString import org.smartregister.fhircore.quest.app.fakes.Faker import org.smartregister.fhircore.quest.robolectric.RobolectricTest @@ -115,13 +124,18 @@ class PinViewModelTest : RobolectricTest() { } @Test - fun testOnSetPin() { - pinViewModel.onSetPin("1990".toCharArray()) - + fun testOnSetPin() = runBlocking { val newPinSlot = slot() - verify { secureSharedPreference.saveSessionPin(capture(newPinSlot)) } + val onSavedPinLambdaSlot = slot<() -> Unit>() + + coEvery { secureSharedPreference.saveSessionPin(capture(newPinSlot), captureLambda()) } just + Runs + + pinViewModel.onSetPin("1990".toCharArray()) Assert.assertEquals("1990", newPinSlot.captured.concatToString()) + + onSavedPinLambdaSlot.captured.invoke() Assert.assertEquals(true, pinViewModel.navigateToHome.value) } @@ -148,9 +162,37 @@ class PinViewModelTest : RobolectricTest() { } @Test - fun testForgotPin() { - pinViewModel.forgotPin() - Assert.assertEquals("tel:####", pinViewModel.launchDialPad.value) + fun testForgotPinLaunchesDialer() { + configurationRegistry.configsJsonMap[ConfigType.Application.name] = + "{\"appId\":\"app\",\"configType\":\"application\",\"loginConfig\":{\"supervisorContactNumber\":\"1234567890\"}}" + + val context = InstrumentationRegistry.getInstrumentation().targetContext + + val expectedFormattedNumber = "1234567890".formatPhoneNumber(context) + + val launchDialPadObserver = + Observer { dialPadUri -> + if (dialPadUri != null) { + Assert.assertEquals(expectedFormattedNumber, dialPadUri) + } + } + + try { + pinViewModel.launchDialPad.observeForever(launchDialPadObserver) + pinViewModel.forgotPin(context) + } finally { + pinViewModel.launchDialPad.removeObserver(launchDialPadObserver) + } + } + + @Test + fun testForgotPinDisplaysToastWhenNoContactNumber() { + configurationRegistry.configsJsonMap[ConfigType.Application.name] = + "{\"appId\":\"app\",\"configType\":\"application\",\"loginConfig\":{}}" + val context = ApplicationProvider.getApplicationContext() + val expectedToastMessage = context.getString(R.string.missing_supervisor_contact) + pinViewModel.forgotPin(context) + Assert.assertEquals(expectedToastMessage, ShadowToast.getTextOfLatestToast()) } @Test @@ -184,9 +226,6 @@ class PinViewModelTest : RobolectricTest() { // Verify pin char array is overwritten in memory for valid pin Assert.assertEquals("******", loginPin.concatToString()) - // Verify the progressBar flag is set to hidden - Assert.assertFalse(pinViewModel.pinUiState.value.showProgressBar) - unmockkStatic(::passwordHashString) } } diff --git a/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/profile/ProfileFragmentTest.kt b/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/profile/ProfileFragmentTest.kt index 207e7b8f6d6..089ab4260e9 100644 --- a/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/profile/ProfileFragmentTest.kt +++ b/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/profile/ProfileFragmentTest.kt @@ -27,6 +27,7 @@ import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidTest import io.mockk.coEvery import io.mockk.coVerify +import io.mockk.every import io.mockk.just import io.mockk.mockk import io.mockk.runs @@ -147,7 +148,7 @@ class ProfileFragmentTest : RobolectricTest() { questionnaireResponse = questionnaireResponse, ) - coEvery { profileViewModel.retrieveProfileUiState(any(), any(), any(), any()) } just runs + every { profileViewModel.retrieveProfileUiState(any(), any(), any(), any()) } just runs coEvery { profileViewModel.emitSnackBarState(any()) } just runs runBlocking { profileFragment.handleQuestionnaireSubmission(questionnaireSubmission) } diff --git a/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/profile/ProfileViewModelTest.kt b/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/profile/ProfileViewModelTest.kt index 9c63ea2390b..56834abcbd0 100644 --- a/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/profile/ProfileViewModelTest.kt +++ b/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/profile/ProfileViewModelTest.kt @@ -48,13 +48,13 @@ import org.junit.Test import org.smartregister.fhircore.engine.configuration.ConfigurationRegistry import org.smartregister.fhircore.engine.configuration.profile.ManagingEntityConfig import org.smartregister.fhircore.engine.configuration.workflow.ApplicationWorkflow +import org.smartregister.fhircore.engine.data.local.ContentCache import org.smartregister.fhircore.engine.data.local.register.RegisterRepository import org.smartregister.fhircore.engine.domain.model.ActionConfig import org.smartregister.fhircore.engine.domain.model.OverflowMenuItemConfig import org.smartregister.fhircore.engine.domain.model.RepositoryResourceData import org.smartregister.fhircore.engine.domain.model.ResourceData import org.smartregister.fhircore.engine.rulesengine.ResourceDataRulesExecutor -import org.smartregister.fhircore.engine.util.DefaultDispatcherProvider import org.smartregister.fhircore.engine.util.DispatcherProvider import org.smartregister.fhircore.engine.util.extension.BLACK_COLOR_HEX_CODE import org.smartregister.fhircore.engine.util.extension.getActivity @@ -77,6 +77,9 @@ class ProfileViewModelTest : RobolectricTest() { @Inject lateinit var dispatcherProvider: DispatcherProvider @Inject lateinit var parser: IParser + + @Inject lateinit var contentCache: ContentCache + private val configurationRegistry: ConfigurationRegistry = Faker.buildTestConfigurationRegistry() private lateinit var profileViewModel: ProfileViewModel private lateinit var resourceData: ResourceData @@ -99,7 +102,6 @@ class ProfileViewModelTest : RobolectricTest() { spyk( RegisterRepository( fhirEngine = mockk(), - dispatcherProvider = DefaultDispatcherProvider(), sharedPreferencesHelper = mockk(), configurationRegistry = configurationRegistry, configService = mockk(), @@ -107,6 +109,8 @@ class ProfileViewModelTest : RobolectricTest() { fhirPathDataExtractor = mockk(), parser = parser, context = ApplicationProvider.getApplicationContext(), + dispatcherProvider = dispatcherProvider, + contentCache = contentCache, ), ) coEvery { diff --git a/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireActivityTest.kt b/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireActivityTest.kt index fb7c9edd1c2..2c80d743971 100644 --- a/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireActivityTest.kt +++ b/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireActivityTest.kt @@ -41,11 +41,11 @@ import io.mockk.unmockkStatic import io.mockk.verify import javax.inject.Inject import junit.framework.TestCase.assertEquals -import junit.framework.TestCase.assertFalse import junit.framework.TestCase.assertTrue import kotlin.test.assertNotNull import kotlin.time.Duration.Companion.seconds import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import org.hl7.fhir.r4.model.Enumerations @@ -68,9 +68,8 @@ import org.smartregister.fhircore.engine.data.local.DefaultRepository import org.smartregister.fhircore.engine.domain.model.ActionParameter import org.smartregister.fhircore.engine.domain.model.ActionParameterType import org.smartregister.fhircore.engine.domain.model.RuleConfig -import org.smartregister.fhircore.engine.util.DispatcherProvider import org.smartregister.fhircore.engine.util.extension.decodeResourceFromString -import org.smartregister.fhircore.engine.util.location.LocationUtils +import org.smartregister.fhircore.engine.util.extension.extractLogicalIdUuid import org.smartregister.fhircore.quest.R import org.smartregister.fhircore.quest.app.fakes.Faker import org.smartregister.fhircore.quest.robolectric.RobolectricTest @@ -89,8 +88,6 @@ class QuestionnaireActivityTest : RobolectricTest() { private lateinit var questionnaireActivityController: ActivityController private lateinit var questionnaireActivity: QuestionnaireActivity - @Inject lateinit var testDispatcherProvider: DispatcherProvider - @BindValue lateinit var defaultRepository: DefaultRepository @BindValue @@ -104,7 +101,6 @@ class QuestionnaireActivityTest : RobolectricTest() { } defaultRepository = mockk(relaxUnitFun = true) { - every { dispatcherProvider } returns testDispatcherProvider every { fhirEngine } returns spyk(this@QuestionnaireActivityTest.fhirEngine) } questionnaireConfig = @@ -185,18 +181,20 @@ class QuestionnaireActivityTest : RobolectricTest() { setupActivity() Assert.assertTrue(questionnaireActivity.supportFragmentManager.fragments.isNotEmpty()) - val firstFragment = questionnaireActivity.supportFragmentManager.fragments.firstOrNull() + val firstFragment = + questionnaireActivity.supportFragmentManager.fragments[ + questionnaireActivity.supportFragmentManager.fragments.size - 1, + ] Assert.assertTrue(firstFragment is QuestionnaireFragment) // Questionnaire should be the same val fragmentQuestionnaire = - questionnaireActivity.supportFragmentManager.fragments - .firstOrNull() + firstFragment ?.arguments ?.getString("questionnaire") ?.decodeResourceFromString() - Assert.assertEquals(questionnaire.id, fragmentQuestionnaire?.id) + Assert.assertEquals(questionnaire.id, fragmentQuestionnaire?.id!!.extractLogicalIdUuid()) val sortedQuestionnaireItemLinkIds = questionnaire.item.map { it.linkId }.sorted().joinToString(",") val sortedFragmentQuestionnaireItemLinkIds = @@ -206,36 +204,13 @@ class QuestionnaireActivityTest : RobolectricTest() { } @Test - fun testThatOnBackPressShowsConfirmationAlertDialog() = runTest { + fun testThatOnBackPressShowsConfirmationAlertDialog() = runBlocking { setupActivity() questionnaireActivity.onBackPressedDispatcher.onBackPressed() val dialog = shadowOf(ShadowAlertDialog.getLatestAlertDialog()) Assert.assertNotNull(dialog) } - @Test - fun `setupLocationServices should fetch location when location is enabled and permissions granted`() { - setupActivity() - assertTrue( - questionnaireActivity.viewModel.applicationConfiguration.logGpsLocation.contains( - LocationLogOptions.QUESTIONNAIRE, - ), - ) - - val fusedLocationProviderClient = - LocationServices.getFusedLocationProviderClient(questionnaireActivity) - assertNotNull(fusedLocationProviderClient) - shadowOf(questionnaireActivity) - .grantPermissions(android.Manifest.permission.ACCESS_FINE_LOCATION) - - assertTrue(LocationUtils.isLocationEnabled(questionnaireActivity)) - - questionnaireActivity.setupLocationServices() - assertTrue(questionnaireActivity.hasLocationPermissions()) - questionnaireActivity.fetchLocation() - assertNotNull(questionnaireActivity.currentLocation) - } - @Test fun `setupLocationServices should open location settings if location is disabled`() { setupActivity() @@ -263,26 +238,6 @@ class QuestionnaireActivityTest : RobolectricTest() { assertEquals(expectedIntent.component, startedIntent.component) } - @Test - fun `setupLocationServices should launch location permissions dialog if permissions are not granted`() { - setupActivity() - assertTrue( - questionnaireActivity.viewModel.applicationConfiguration.logGpsLocation.contains( - LocationLogOptions.QUESTIONNAIRE, - ), - ) - - val fusedLocationProviderClient = - LocationServices.getFusedLocationProviderClient(questionnaireActivity) - assertNotNull(fusedLocationProviderClient) - - assertTrue(LocationUtils.isLocationEnabled(questionnaireActivity)) - assertFalse(questionnaireActivity.hasLocationPermissions()) - - val dialog = questionnaireActivity.launchLocationPermissionsDialog() - assertNotNull(dialog) - } - private fun setupActivity() { val bundle = QuestionnaireActivity.intentBundle(questionnaireConfig, emptyList()) questionnaireActivityController = diff --git a/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireViewModelTest.kt b/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireViewModelTest.kt index 85b757ea2fc..2360f2061c5 100644 --- a/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireViewModelTest.kt +++ b/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireViewModelTest.kt @@ -25,8 +25,10 @@ import com.google.android.fhir.datacapture.extensions.logicalId import com.google.android.fhir.datacapture.mapping.ResourceMapper import com.google.android.fhir.db.ResourceNotFoundException import com.google.android.fhir.get +import com.google.android.fhir.knowledge.KnowledgeManager import com.google.android.fhir.search.Search import com.google.android.fhir.workflow.FhirOperator +import dagger.Lazy import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidTest import io.mockk.coEvery @@ -39,6 +41,8 @@ import io.mockk.runs import io.mockk.slot import io.mockk.spyk import io.mockk.unmockkObject +import io.mockk.verify +import java.io.File import java.util.Date import java.util.UUID import javax.inject.Inject @@ -48,11 +52,14 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.runTest import org.hl7.fhir.r4.model.Address +import org.hl7.fhir.r4.model.Attachment import org.hl7.fhir.r4.model.Basic import org.hl7.fhir.r4.model.BooleanType import org.hl7.fhir.r4.model.Bundle +import org.hl7.fhir.r4.model.CarePlan import org.hl7.fhir.r4.model.CodeableConcept import org.hl7.fhir.r4.model.Coding +import org.hl7.fhir.r4.model.Consent import org.hl7.fhir.r4.model.DateType import org.hl7.fhir.r4.model.Encounter import org.hl7.fhir.r4.model.Enumerations @@ -62,6 +69,7 @@ import org.hl7.fhir.r4.model.Flag import org.hl7.fhir.r4.model.Group import org.hl7.fhir.r4.model.IdType import org.hl7.fhir.r4.model.IntegerType +import org.hl7.fhir.r4.model.Library import org.hl7.fhir.r4.model.ListResource import org.hl7.fhir.r4.model.Location import org.hl7.fhir.r4.model.Observation @@ -69,12 +77,15 @@ import org.hl7.fhir.r4.model.Parameters import org.hl7.fhir.r4.model.Patient import org.hl7.fhir.r4.model.Questionnaire import org.hl7.fhir.r4.model.QuestionnaireResponse +import org.hl7.fhir.r4.model.QuestionnaireResponse.QuestionnaireResponseStatus +import org.hl7.fhir.r4.model.Reference import org.hl7.fhir.r4.model.Resource import org.hl7.fhir.r4.model.ResourceType import org.hl7.fhir.r4.model.StringType import org.hl7.fhir.r4.model.Type import org.junit.Assert import org.junit.Before +import org.junit.Ignore import org.junit.Rule import org.junit.Test import org.smartregister.fhircore.engine.configuration.ExtractedResourceUniquePropertyExpression @@ -84,6 +95,7 @@ import org.smartregister.fhircore.engine.configuration.LinkIdType import org.smartregister.fhircore.engine.configuration.QuestionnaireConfig import org.smartregister.fhircore.engine.configuration.UniqueIdAssignmentConfig import org.smartregister.fhircore.engine.configuration.app.ConfigService +import org.smartregister.fhircore.engine.data.local.ContentCache import org.smartregister.fhircore.engine.data.local.DefaultRepository import org.smartregister.fhircore.engine.domain.model.ActionParameter import org.smartregister.fhircore.engine.domain.model.ActionParameterType @@ -98,17 +110,22 @@ import org.smartregister.fhircore.engine.util.SharedPreferencesHelper import org.smartregister.fhircore.engine.util.extension.appendPractitionerInfo import org.smartregister.fhircore.engine.util.extension.asReference import org.smartregister.fhircore.engine.util.extension.decodeResourceFromString +import org.smartregister.fhircore.engine.util.extension.encodeResourceToString import org.smartregister.fhircore.engine.util.extension.extractLogicalIdUuid import org.smartregister.fhircore.engine.util.extension.find import org.smartregister.fhircore.engine.util.extension.isToday +import org.smartregister.fhircore.engine.util.extension.questionnaireResponseStatus import org.smartregister.fhircore.engine.util.extension.valueToString import org.smartregister.fhircore.engine.util.extension.yesterday import org.smartregister.fhircore.engine.util.fhirpath.FhirPathDataExtractor +import org.smartregister.fhircore.engine.util.validation.ResourceValidationRequestHandler import org.smartregister.fhircore.quest.app.fakes.Faker +import org.smartregister.fhircore.quest.assertResourceEquals import org.smartregister.fhircore.quest.robolectric.RobolectricTest import org.smartregister.fhircore.quest.ui.questionnaire.QuestionnaireViewModel.Companion.CONTAINED_LIST_TITLE import org.smartregister.model.practitioner.FhirPractitionerDetails import org.smartregister.model.practitioner.PractitionerDetails +import timber.log.Timber @HiltAndroidTest class QuestionnaireViewModelTest : RobolectricTest() { @@ -117,6 +134,8 @@ class QuestionnaireViewModelTest : RobolectricTest() { @Inject lateinit var sharedPreferencesHelper: SharedPreferencesHelper + @Inject lateinit var fhirValidatorRequestHandlerProvider: Lazy + @Inject lateinit var configService: ConfigService @Inject lateinit var resourceDataRulesExecutor: ResourceDataRulesExecutor @@ -129,6 +148,9 @@ class QuestionnaireViewModelTest : RobolectricTest() { @Inject lateinit var parser: IParser + @Inject lateinit var knowledgeManager: KnowledgeManager + + @Inject lateinit var contentCache: ContentCache private lateinit var samplePatientRegisterQuestionnaire: Questionnaire private lateinit var questionnaireConfig: QuestionnaireConfig private lateinit var questionnaireViewModel: QuestionnaireViewModel @@ -154,7 +176,6 @@ class QuestionnaireViewModelTest : RobolectricTest() { @ExperimentalCoroutinesApi fun setUp() { hiltRule.inject() - // Write practitioner and organization to shared preferences sharedPreferencesHelper.write( SharedPreferenceKey.PRACTITIONER_ID.name, @@ -175,6 +196,7 @@ class QuestionnaireViewModelTest : RobolectricTest() { fhirPathDataExtractor = fhirPathDataExtractor, parser = parser, context = context, + contentCache = contentCache, ), ) @@ -190,11 +212,12 @@ class QuestionnaireViewModelTest : RobolectricTest() { spyk( QuestionnaireViewModel( defaultRepository = defaultRepository, - dispatcherProvider = defaultRepository.dispatcherProvider, + dispatcherProvider = dispatcherProvider, fhirCarePlanGenerator = fhirCarePlanGenerator, resourceDataRulesExecutor = resourceDataRulesExecutor, transformSupportServices = mockk(), sharedPreferencesHelper = sharedPreferencesHelper, + fhirValidatorRequestHandlerProvider = fhirValidatorRequestHandlerProvider, fhirOperator = fhirOperator, fhirPathDataExtractor = fhirPathDataExtractor, configurationRegistry = configurationRegistry, @@ -220,6 +243,92 @@ class QuestionnaireViewModelTest : RobolectricTest() { } } + @Test + fun testHandleQuestionnaireSubmissionHasValidationErrorsExtractedResourcesContainingInvalidWhenInDebug() = + runTest { + mockkObject(ResourceMapper) + val questionnaire = + extractionQuestionnaire().apply { extension = samplePatientRegisterQuestionnaire.extension } + val questionnaireResponse = extractionQuestionnaireResponse() + val actionParameters = emptyList() + coEvery { defaultRepository.applyDbTransaction(any()) } answers + { + runBlocking { (firstArg() as suspend () -> Unit).invoke() } + } + val onSuccessfulSubmission = + spyk({ idsTypes: List, _: QuestionnaireResponse -> Timber.i(idsTypes.toString()) }) + coEvery { + ResourceMapper.extract( + questionnaire = questionnaire, + questionnaireResponse = questionnaireResponse, + structureMapExtractionContext = any(), + ) + } returns + Bundle().apply { + addEntry( + Bundle.BundleEntryComponent().apply { + resource = + patient.apply { + addLink().apply { + other = Reference("Group/1234") + type = Patient.LinkType.REFER + } + } + }, + ) + } + coEvery { defaultRepository.addOrUpdate(any(Boolean::class), any()) } just runs + coEvery { defaultRepository.loadResource(any(), ResourceType.Patient) } returns + Patient() + + questionnaireViewModel.handleQuestionnaireSubmission( + questionnaire = questionnaire, + currentQuestionnaireResponse = questionnaireResponse, + actionParameters = actionParameters, + context = context, + questionnaireConfig = questionnaireConfig, + onSuccessfulSubmission = onSuccessfulSubmission, + ) + + // Verify QuestionnaireResponse was validated + coVerify { + questionnaireViewModel.validateQuestionnaireResponse( + questionnaire, + questionnaireResponse, + context, + ) + } + // Verify perform extraction was invoked + coVerify { + questionnaireViewModel.performExtraction( + extractByStructureMap = true, + questionnaire = questionnaire, + questionnaireResponse = questionnaireResponse, + context = context, + ) + } + + verify { onSuccessfulSubmission(any(), any()) } + coVerify { questionnaireViewModel.validateWithFhirValidator(*anyVararg()) } + coVerify { + questionnaireViewModel.saveExtractedResources( + bundle = any(), + questionnaire = questionnaire, + questionnaireConfig = questionnaireConfig, + questionnaireResponse = questionnaireResponse, + context = context, + ) + } + coVerify { + questionnaireViewModel.updateResourcesLastUpdatedProperty( + actionParameters, + ) + } + + coVerify { onSuccessfulSubmission(any(), questionnaireResponse) } + unmockkObject(ResourceMapper) + } + // TODO Write integration test for QuestionnaireActivity to compliment this unit test; @Test fun testHandleQuestionnaireSubmission() = runTest { @@ -256,6 +365,10 @@ class QuestionnaireViewModelTest : RobolectricTest() { ResourceNotFoundException("QuestionnaireResponse", "") coEvery { fhirEngine.create(resource = anyVararg()) } returns listOf(patient.logicalId) coEvery { fhirEngine.update(resource = anyVararg()) } just runs + coEvery { defaultRepository.applyDbTransaction(any()) } answers + { + runBlocking { (firstArg() as suspend () -> Unit).invoke() } + } // Mock returned bundle after extraction refer to FhirExtractionTest.kt for extraction test coEvery { @@ -535,7 +648,6 @@ class QuestionnaireViewModelTest : RobolectricTest() { val questionnaire = questionnaireViewModel.retrieveQuestionnaire( questionnaireConfig = questionnaireConfig, - actionParameters = emptyList(), ) Assert.assertNotNull(questionnaire) @@ -543,10 +655,66 @@ class QuestionnaireViewModelTest : RobolectricTest() { } @Test - fun testRetrieveQuestionnaireShouldReturnPrePopulatedQuestionnaire() = runTest { + fun testRetrieveQuestionnaireShouldReturnValidQuestionnaireFromCache() = runTest { + coEvery { fhirEngine.get(ResourceType.Questionnaire, questionnaireConfig.id) } returns + samplePatientRegisterQuestionnaire + + coEvery { defaultRepository.loadResource(questionnaireConfig.id) } returns + samplePatientRegisterQuestionnaire + + contentCache.saveResource(samplePatientRegisterQuestionnaire) + val questionnaire = + questionnaireViewModel.retrieveQuestionnaire( + questionnaireConfig = questionnaireConfig, + ) + + Assert.assertEquals( + samplePatientRegisterQuestionnaire.idPart, + contentCache.getResource(ResourceType.Questionnaire, questionnaireConfig.id)?.idPart, + ) + Assert.assertNotNull(questionnaire) + Assert.assertEquals(questionnaireConfig.id, questionnaire?.id?.extractLogicalIdUuid()) + } + + @Test + fun testRetrieveQuestionnaireShouldReturnValidQuestionnaireFromDatabase() = runTest { + coEvery { fhirEngine.get(ResourceType.Questionnaire, questionnaireConfig.id) } returns + samplePatientRegisterQuestionnaire + + coEvery { defaultRepository.loadResource(questionnaireConfig.id) } returns + samplePatientRegisterQuestionnaire + + val questionnaire = + questionnaireViewModel.retrieveQuestionnaire( + questionnaireConfig = questionnaireConfig, + ) + + Assert.assertNotNull(questionnaire) + Assert.assertEquals(questionnaireConfig.id, questionnaire?.id?.extractLogicalIdUuid()) + + coVerify(exactly = 1) { defaultRepository.loadResource(questionnaireConfig.id) } + } + + @Test + fun testPopulateQuestionnaireShouldPrePopulatedQuestionnaireWithComputedValues() = runTest { + val questionnaireViewModelInstance = + QuestionnaireViewModel( + defaultRepository = defaultRepository, + dispatcherProvider = dispatcherProvider, + fhirCarePlanGenerator = fhirCarePlanGenerator, + resourceDataRulesExecutor = resourceDataRulesExecutor, + transformSupportServices = mockk(), + sharedPreferencesHelper = sharedPreferencesHelper, + fhirOperator = fhirOperator, + fhirValidatorRequestHandlerProvider = fhirValidatorRequestHandlerProvider, + fhirPathDataExtractor = fhirPathDataExtractor, + configurationRegistry = configurationRegistry, + ) val patientAgeLinkId = "patient-age" + val newQuestionnaireId = "new-${questionnaireConfig.id}" val newQuestionnaireConfig = questionnaireConfig.copy( + id = newQuestionnaireId, resourceIdentifier = patient.id, resourceType = patient.resourceType, barcodeLinkId = "patient-barcode", @@ -568,6 +736,7 @@ class QuestionnaireViewModelTest : RobolectricTest() { ) coEvery { fhirEngine.get(ResourceType.Questionnaire, newQuestionnaireConfig.id) } returns samplePatientRegisterQuestionnaire.apply { + id = newQuestionnaireId addItem( Questionnaire.QuestionnaireItemComponent().apply { linkId = patientAgeLinkId @@ -586,23 +755,23 @@ class QuestionnaireViewModelTest : RobolectricTest() { value = "20", ), ) - - val questionnaire = - questionnaireViewModel.retrieveQuestionnaire( - questionnaireConfig = newQuestionnaireConfig, - actionParameters = actionParameter, - ) + val questionnaire = questionnaireViewModelInstance.retrieveQuestionnaire(newQuestionnaireConfig) Assert.assertNotNull(questionnaire) + questionnaireViewModelInstance.populateQuestionnaire( + questionnaire!!, + newQuestionnaireConfig, + actionParameter, + ) // Questionnaire.item pre-populated - val questionnairePatientAgeItem = questionnaire?.find(patientAgeLinkId) + val questionnairePatientAgeItem = questionnaire.find(patientAgeLinkId) val itemValue: Type? = questionnairePatientAgeItem?.initial?.firstOrNull()?.value Assert.assertTrue(itemValue is IntegerType) Assert.assertEquals(20, itemValue?.primitiveValue()?.toInt()) // Barcode linkId updated val questionnaireBarcodeItem = - newQuestionnaireConfig.barcodeLinkId?.let { questionnaire?.find(it) } + newQuestionnaireConfig.barcodeLinkId?.let { questionnaire.find(it) } val barCodeItemValue: Type? = questionnaireBarcodeItem?.initial?.firstOrNull()?.value Assert.assertFalse(barCodeItemValue is StringType) Assert.assertNull( @@ -624,7 +793,10 @@ class QuestionnaireViewModelTest : RobolectricTest() { }, ) } - questionnaireViewModel.saveDraftQuestionnaire(questionnaireResponse) + questionnaireViewModel.saveDraftQuestionnaire( + questionnaireResponse, + QuestionnaireConfig("qr-id-1"), + ) Assert.assertEquals( QuestionnaireResponse.QuestionnaireResponseStatus.INPROGRESS, questionnaireResponse.status, @@ -632,6 +804,70 @@ class QuestionnaireViewModelTest : RobolectricTest() { coVerify { defaultRepository.addOrUpdate(resource = questionnaireResponse) } } + @Test + fun testSaveDraftQuestionnaireShouldUpdateSubjectAndQuestionnaireValues() = runTest { + val questionnaireResponse = + QuestionnaireResponse().apply { + addItem( + QuestionnaireResponse.QuestionnaireResponseItemComponent().apply { + addAnswer( + QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent() + .setValue(StringType("Sky is the limit")), + ) + }, + ) + } + questionnaireViewModel.saveDraftQuestionnaire( + questionnaireResponse, + QuestionnaireConfig( + "dc-household-registration", + resourceIdentifier = "group-id-1", + resourceType = ResourceType.Group, + ), + ) + Assert.assertEquals( + "Questionnaire/dc-household-registration", + questionnaireResponse.questionnaire, + ) + Assert.assertEquals( + "Group/group-id-1", + questionnaireResponse.subject.reference, + ) + coVerify { defaultRepository.addOrUpdate(resource = questionnaireResponse) } + } + + @Test + @Ignore("Re-check this test, it takes forever to run") + fun testSaveDraftQuestionnaireCallsAddOrUpdateForPaginatedForms() = runTest { + val pageItem = + QuestionnaireResponse.QuestionnaireResponseItemComponent().apply { + addItem( + QuestionnaireResponse.QuestionnaireResponseItemComponent().apply { + addAnswer( + QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent() + .setValue(StringType("Sky is the limit")), + ) + }, + ) + } + val questionnaireResponse = + QuestionnaireResponse().apply { + addItem( + pageItem, + ) + } + questionnaireViewModel.saveDraftQuestionnaire( + questionnaireResponse, + QuestionnaireConfig( + "dc-household-registration", + resourceIdentifier = "group-id-1", + resourceType = ResourceType.Group, + ), + ) + + coVerify { defaultRepository.addOrUpdate(resource = questionnaireResponse) } + } + @Test fun testUpdateResourcesLastUpdatedProperty() = runTest { val yesterday = yesterday() @@ -712,6 +948,230 @@ class QuestionnaireViewModelTest : RobolectricTest() { } } + @Test + fun testValidateQuestionnaireResponseWithRepeatedGroup() = runTest { + val questionnaireString = + """ + { + "resourceType": "Questionnaire", + "item": [ + { + "linkId": "systolic-bp", + "type": "integer", + "required": true + }, + { + "linkId": "blood-pressure-repeating-group", + "type": "group", + "required": false, + "repeats": true, + "item": [ + { + "linkId": "systolic-bp", + "type": "integer", + "required": true + } + ] + } + ] + } + """ + .trimIndent() + val questionnaireResponseString = + """ + { + "resourceType": "QuestionnaireResponse", + "item": [ + { + "linkId": "systolic-bp", + "answer": [ + { + "valueInteger": 123 + } + ] + }, + { + "linkId": "blood-pressure-repeating-group", + "item": [ + { + "linkId": "systolic-bp", + "answer": [ + { + "valueInteger": 124 + } + ] + } + ] + }, + { + "linkId": "blood-pressure-repeating-group", + "item": [ + { + "linkId": "systolic-bp", + "answer": [ + { + "valueInteger": 125 + } + ] + } + ] + }, + { + "linkId": "blood-pressure-repeating-group", + "item": [ + { + "linkId": "systolic-bp", + "answer": [ + { + "valueInteger": 126 + } + ] + } + ] + } + ] + } + """ + .trimIndent() + val questionnaire = parser.parseResource(questionnaireString) as Questionnaire + val questionnaireResponse = + parser.parseResource(questionnaireResponseString) as QuestionnaireResponse + val result = + questionnaireViewModel.validateQuestionnaireResponse( + questionnaire, + questionnaireResponse, + context, + ) + Assert.assertTrue(result) + } + + @Test + fun testValidateQuestionnaireResponseWithNestedRepeatedGroupShouldNotUpdateTheOriginalQuestionnaireResponse() = + runTest { + val questionnaireString = + """ + { + "resourceType": "Questionnaire", + "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": "page-1", + "type": "group", + "item": [ + { + "linkId": "systolic-bp", + "type": "integer", + "required": true + }, + { + "linkId": "blood-pressure-repeating-group", + "type": "group", + "required": false, + "repeats": true, + "item": [ + { + "linkId": "systolic-bp", + "type": "integer", + "required": true + } + ] + } + ] + } + ] + } + """ + .trimIndent() + val questionnaireResponseString = + """ + { + "resourceType": "QuestionnaireResponse", + "item": [ + { + "linkId": "page-1", + "item": [ + { + "linkId": "systolic-bp", + "answer": [ + { + "valueInteger": 123 + } + ] + }, + { + "linkId": "blood-pressure-repeating-group", + "item": [ + { + "linkId": "systolic-bp", + "answer": [ + { + "valueInteger": 124 + } + ] + } + ] + }, + { + "linkId": "blood-pressure-repeating-group", + "item": [ + { + "linkId": "systolic-bp", + "answer": [ + { + "valueInteger": 125 + } + ] + } + ] + }, + { + "linkId": "blood-pressure-repeating-group", + "item": [ + { + "linkId": "systolic-bp", + "answer": [ + { + "valueInteger": 126 + } + ] + } + ] + } + ] + } + ] + } + """ + .trimIndent() + val questionnaire = parser.parseResource(questionnaireString) as Questionnaire + val actualQuestionnaireResponse = + parser.parseResource(questionnaireResponseString) as QuestionnaireResponse + val result = + questionnaireViewModel.validateQuestionnaireResponse( + questionnaire, + actualQuestionnaireResponse, + context, + ) + val expectedQuestionnaireResponse = + parser.parseResource(questionnaireResponseString) as QuestionnaireResponse + Assert.assertTrue(result) + assertResourceEquals(expectedQuestionnaireResponse, actualQuestionnaireResponse) + } + @Test fun testExecuteCqlShouldInvokeRunCqlLibrary() = runTest { val bundle = @@ -729,15 +1189,38 @@ class QuestionnaireViewModelTest : RobolectricTest() { coEvery { fhirOperator.evaluateLibrary(any(), any(), any(), any()) } returns Parameters() - questionnaireViewModel.executeCql(patient, bundle, questionnaire) + val cqlLibrary = + Library().apply { + id = "Library/123" + url = "http://smartreg.org/Library/123" + name = "123" + version = "1.0.0" + status = Enumerations.PublicationStatus.ACTIVE + addContent( + Attachment().apply { + contentType = "text/cql" + data = "someCQL".toByteArray() + }, + ) + } + + knowledgeManager.install( + File.createTempFile(cqlLibrary.name, ".json").apply { + this.writeText(cqlLibrary.encodeResourceToString()) + }, + ) + fhirEngine.create(patient) + questionnaireViewModel.executeCql(patient, bundle, questionnaire) + coVerify { fhirOperator.evaluateLibrary( "http://smartreg.org/Library/123", patient.asReference().reference, null, - expressions = setOf(), + bundle, + null, ) } } @@ -746,6 +1229,9 @@ class QuestionnaireViewModelTest : RobolectricTest() { fun testGenerateCarePlan() = runTest { val bundle = Bundle().apply { addEntry(Bundle.BundleEntryComponent().apply { resource = patient }) } + coEvery { + fhirCarePlanGenerator.generateOrUpdateCarePlan(any(), any(), any(), any()) + } returns CarePlan() val questionnaireConfig = questionnaireConfig.copy(planDefinitions = listOf("planDefId")) questionnaireViewModel.generateCarePlan(patient, bundle, questionnaireConfig) @@ -893,10 +1379,58 @@ class QuestionnaireViewModelTest : RobolectricTest() { fun testSearchLatestQuestionnaireResponseShouldReturnLatestQuestionnaireResponse() = runTest(timeout = 90.seconds) { Assert.assertNull( - questionnaireViewModel.searchLatestQuestionnaireResponse( + questionnaireViewModel.searchQuestionnaireResponse( + resourceId = patient.logicalId, + resourceType = ResourceType.Patient, + questionnaireId = questionnaireConfig.id, + encounterId = null, + ), + ) + + val questionnaireResponses = + listOf( + QuestionnaireResponse().apply { + id = "qr1" + meta.lastUpdated = Date() + subject = patient.asReference() + questionnaire = samplePatientRegisterQuestionnaire.asReference().reference + }, + QuestionnaireResponse().apply { + id = "qr2" + meta.lastUpdated = yesterday() + subject = patient.asReference() + questionnaire = samplePatientRegisterQuestionnaire.asReference().reference + }, + ) + + // Add QuestionnaireResponse to database + fhirEngine.create( + patient, + samplePatientRegisterQuestionnaire, + *questionnaireResponses.toTypedArray(), + ) + + val latestQuestionnaireResponse = + questionnaireViewModel.searchQuestionnaireResponse( + resourceId = patient.logicalId, + resourceType = ResourceType.Patient, + questionnaireId = questionnaireConfig.id, + encounterId = null, + ) + Assert.assertNotNull(latestQuestionnaireResponse) + Assert.assertEquals("QuestionnaireResponse/qr1", latestQuestionnaireResponse?.id) + } + + @Test + fun testSearchLatestQuestionnaireResponseWhenSaveDraftIsTueShouldReturnLatestQuestionnaireResponse() = + runTest(timeout = 90.seconds) { + Assert.assertNull( + questionnaireViewModel.searchQuestionnaireResponse( resourceId = patient.logicalId, resourceType = ResourceType.Patient, questionnaireId = questionnaireConfig.id, + encounterId = null, + questionnaireResponseStatus = QuestionnaireResponseStatus.INPROGRESS.toCode(), ), ) @@ -907,12 +1441,14 @@ class QuestionnaireViewModelTest : RobolectricTest() { meta.lastUpdated = Date() subject = patient.asReference() questionnaire = samplePatientRegisterQuestionnaire.asReference().reference + status = QuestionnaireResponseStatus.INPROGRESS }, QuestionnaireResponse().apply { id = "qr2" meta.lastUpdated = yesterday() subject = patient.asReference() questionnaire = samplePatientRegisterQuestionnaire.asReference().reference + status = QuestionnaireResponseStatus.COMPLETED }, ) @@ -924,13 +1460,15 @@ class QuestionnaireViewModelTest : RobolectricTest() { ) val latestQuestionnaireResponse = - questionnaireViewModel.searchLatestQuestionnaireResponse( + questionnaireViewModel.searchQuestionnaireResponse( resourceId = patient.logicalId, resourceType = ResourceType.Patient, questionnaireId = questionnaireConfig.id, + encounterId = null, + questionnaireResponseStatus = QuestionnaireResponseStatus.INPROGRESS.toCode(), ) Assert.assertNotNull(latestQuestionnaireResponse) - Assert.assertEquals("qr1", latestQuestionnaireResponse?.id) + Assert.assertEquals("QuestionnaireResponse/qr1", latestQuestionnaireResponse?.id) } @Test @@ -1028,6 +1566,13 @@ class QuestionnaireViewModelTest : RobolectricTest() { Assert.assertEquals("Practitioner/12345", flag.author.reference) } + @Test + fun testAddPractitionerInfoAppendedCorrectlyOnConsentResource() { + val consent = Consent().apply { this.id = "123456" } + consent.appendPractitionerInfo("12345") + Assert.assertEquals("Practitioner/12345", consent.performer.first().reference) + } + @Test fun testSaveExtractedResourcesForEditedQuestionnaire() = runTest { val questionnaire = extractionQuestionnaire() @@ -1079,13 +1624,16 @@ class QuestionnaireViewModelTest : RobolectricTest() { } listResource.addEntry(listEntryComponent) addContained(listResource) + status = QuestionnaireResponse.QuestionnaireResponseStatus.COMPLETED } coEvery { - questionnaireViewModel.searchLatestQuestionnaireResponse( - patient.logicalId, - ResourceType.Patient, - questionnaireConfig.id, + questionnaireViewModel.searchQuestionnaireResponse( + resourceId = patient.logicalId, + resourceType = ResourceType.Patient, + questionnaireId = questionnaireConfig.id, + encounterId = null, + questionnaireResponseStatus = questionnaireConfig.questionnaireResponseStatus(), ) } returns previousQuestionnaireResponse @@ -1114,7 +1662,7 @@ class QuestionnaireViewModelTest : RobolectricTest() { context = context, ) - // The Observation ID for the extracted Obs should be the same as previousObs'Id + // The Observation ID for the extracted Obs should be the same as previous Obs Id Assert.assertTrue(questionnaireResponse.contained.firstOrNull() is ListResource) val listResource = questionnaireResponse.contained.firstOrNull() as ListResource val observationReference = @@ -1136,7 +1684,7 @@ class QuestionnaireViewModelTest : RobolectricTest() { } @Test - fun testLoadCqlInputResourcesFromQuestionnaireConfig() = runBlocking { + fun testLoadCqlInputResourcesFromQuestionnaireConfig() = runTest { val bundle = Bundle() // Define the expected CQL input resources @@ -1453,6 +2001,19 @@ class QuestionnaireViewModelTest : RobolectricTest() { @Test fun testThatPopulateQuestionnaireSetInitialDefaultValueForQuestionnaireInitialExpression() = runTest { + val questionnaireViewModelInstance = + QuestionnaireViewModel( + defaultRepository = defaultRepository, + dispatcherProvider = dispatcherProvider, + fhirCarePlanGenerator = fhirCarePlanGenerator, + resourceDataRulesExecutor = resourceDataRulesExecutor, + transformSupportServices = mockk(), + sharedPreferencesHelper = sharedPreferencesHelper, + fhirOperator = fhirOperator, + fhirValidatorRequestHandlerProvider = fhirValidatorRequestHandlerProvider, + fhirPathDataExtractor = fhirPathDataExtractor, + configurationRegistry = configurationRegistry, + ) val questionnaireWithDefaultDate = Questionnaire().apply { id = questionnaireConfig.id @@ -1475,7 +2036,7 @@ class QuestionnaireViewModelTest : RobolectricTest() { coEvery { fhirEngine.get(ResourceType.Questionnaire, questionnaireConfig.id) } returns questionnaireWithDefaultDate - questionnaireViewModel.populateQuestionnaire( + questionnaireViewModelInstance.populateQuestionnaire( questionnaireWithDefaultDate, questionnaireConfig, emptyList(), @@ -1489,8 +2050,107 @@ class QuestionnaireViewModelTest : RobolectricTest() { Assert.assertTrue(initialValueDate.isToday) } + @Test + fun testThatPopulateQuestionnaireSetInitialDefaultValueButExcludesFieldFromResponse() = + runTest(timeout = 90.seconds) { + val thisQuestionnaireConfig = + questionnaireConfig.copy( + resourceType = ResourceType.Patient, + resourceIdentifier = patient.logicalId, + type = QuestionnaireType.EDIT.name, + linkIds = + listOf( + LinkIdConfig("dateToday", LinkIdType.PREPOPULATION_EXCLUSION), + ), + ) + val questionnaireViewModelInstance = + QuestionnaireViewModel( + defaultRepository = defaultRepository, + dispatcherProvider = dispatcherProvider, + fhirCarePlanGenerator = fhirCarePlanGenerator, + resourceDataRulesExecutor = resourceDataRulesExecutor, + transformSupportServices = mockk(), + sharedPreferencesHelper = sharedPreferencesHelper, + fhirOperator = fhirOperator, + fhirValidatorRequestHandlerProvider = fhirValidatorRequestHandlerProvider, + fhirPathDataExtractor = fhirPathDataExtractor, + configurationRegistry = configurationRegistry, + ) + val questionnaireWithDefaultDate = + Questionnaire().apply { + id = thisQuestionnaireConfig.id + addItem( + Questionnaire.QuestionnaireItemComponent().apply { + linkId = "dateToday" + type = Questionnaire.QuestionnaireItemType.DATE + addExtension( + Extension( + "http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-initialExpression", + Expression().apply { + language = "text/fhirpath" + expression = "today()" + }, + ), + ) + }, + ) + } + + val questionnaireResponse = + QuestionnaireResponse().apply { + addItem( + QuestionnaireResponse.QuestionnaireResponseItemComponent().apply { + linkId = "dateToday" + addAnswer( + QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { + value = DateType(Date()) + }, + ) + }, + ) + setQuestionnaire( + thisQuestionnaireConfig.id.asReference(ResourceType.Questionnaire).reference, + ) + } + + coEvery { + fhirEngine.get( + thisQuestionnaireConfig.resourceType!!, + thisQuestionnaireConfig.resourceIdentifier!!, + ) + } returns patient + + coEvery { fhirEngine.search(any()) } returns + listOf( + SearchResult(questionnaireResponse, included = null, revIncluded = null), + ) + + val (result, _) = + questionnaireViewModelInstance.populateQuestionnaire( + questionnaire = questionnaireWithDefaultDate, + questionnaireConfig = thisQuestionnaireConfig, + actionParameters = emptyList(), + ) + + Assert.assertNotNull(result?.item) + Assert.assertTrue(result!!.item.isEmpty()) + } + @Test fun testThatPopulateQuestionnaireReturnsQuestionnaireResponseWithUnAnsweredRemoved() = runTest { + val questionnaireViewModelInstance = + QuestionnaireViewModel( + defaultRepository = defaultRepository, + dispatcherProvider = dispatcherProvider, + fhirCarePlanGenerator = fhirCarePlanGenerator, + resourceDataRulesExecutor = resourceDataRulesExecutor, + transformSupportServices = mockk(), + sharedPreferencesHelper = sharedPreferencesHelper, + fhirOperator = fhirOperator, + fhirValidatorRequestHandlerProvider = fhirValidatorRequestHandlerProvider, + fhirPathDataExtractor = fhirPathDataExtractor, + configurationRegistry = configurationRegistry, + ) val questionnaireConfig1 = questionnaireConfig.copy( resourceType = ResourceType.Patient, @@ -1575,7 +2235,7 @@ class QuestionnaireViewModelTest : RobolectricTest() { Assert.assertNotNull(questionnaireResponse.find("linkid-1")) val result = - questionnaireViewModel.populateQuestionnaire( + questionnaireViewModelInstance.populateQuestionnaire( questionnaireWithInitialValue, questionnaireConfig1, emptyList(), @@ -1583,4 +2243,125 @@ class QuestionnaireViewModelTest : RobolectricTest() { Assert.assertNotNull(result.first) Assert.assertTrue(result.first!!.find("linkid-1") == null) } + + @Test + fun testThatPopulateQuestionnaireReturnsQuestionnaireResponseWithIdValue() = runTest { + val questionnaireViewModelInstance = + QuestionnaireViewModel( + defaultRepository = defaultRepository, + dispatcherProvider = dispatcherProvider, + fhirCarePlanGenerator = fhirCarePlanGenerator, + resourceDataRulesExecutor = resourceDataRulesExecutor, + transformSupportServices = mockk(), + sharedPreferencesHelper = sharedPreferencesHelper, + fhirOperator = fhirOperator, + fhirValidatorRequestHandlerProvider = fhirValidatorRequestHandlerProvider, + fhirPathDataExtractor = fhirPathDataExtractor, + configurationRegistry = configurationRegistry, + ) + val questionnaireConfig1 = + questionnaireConfig.copy( + resourceType = ResourceType.Patient, + resourceIdentifier = patient.logicalId, + type = QuestionnaireType.EDIT.name, + ) + + val questionnaireWithInitialValue = + Questionnaire().apply { + id = questionnaireConfig1.id + addItem( + Questionnaire.QuestionnaireItemComponent().apply { + linkId = "group-1" + type = Questionnaire.QuestionnaireItemType.GROUP + addItem( + Questionnaire.QuestionnaireItemComponent().apply { + linkId = "linkid-1" + type = Questionnaire.QuestionnaireItemType.STRING + addInitial(Questionnaire.QuestionnaireItemInitialComponent(StringType("---"))) + }, + ) + }, + ) + + addItem( + Questionnaire.QuestionnaireItemComponent().apply { + linkId = "linkid-2" + type = Questionnaire.QuestionnaireItemType.STRING + }, + ) + } + val qrId = "qr-id-1" + val questionnaireResponse = + QuestionnaireResponse().apply { + id = qrId + addItem( + QuestionnaireResponse.QuestionnaireResponseItemComponent().apply { + linkId = "group-1" + addItem( + QuestionnaireResponse.QuestionnaireResponseItemComponent().apply { + linkId = "linkid-1" + }, + ) + }, + ) + } + coEvery { + fhirEngine.get(questionnaireConfig1.resourceType!!, questionnaireConfig1.resourceIdentifier!!) + } returns patient + + coEvery { fhirEngine.search(any()) } returns + listOf( + SearchResult(questionnaireResponse, included = null, revIncluded = null), + ) + + Assert.assertNotNull(questionnaireResponse.find("linkid-1")) + val result = + questionnaireViewModelInstance.populateQuestionnaire( + questionnaireWithInitialValue, + questionnaireConfig1, + emptyList(), + ) + Assert.assertNotNull(result.first) + Assert.assertEquals(qrId, result.first!!.id) + } + + @Test + fun testExcludeNestedItemFromQuestionnairePrepopulation() { + val item1 = QuestionnaireResponse.QuestionnaireResponseItemComponent().apply { linkId = "1" } + val item2 = QuestionnaireResponse.QuestionnaireResponseItemComponent().apply { linkId = "2" } + val item3 = + QuestionnaireResponse.QuestionnaireResponseItemComponent().apply { + linkId = "3" + item = + mutableListOf( + QuestionnaireResponse.QuestionnaireResponseItemComponent().apply { linkId = "3.1" }, + QuestionnaireResponse.QuestionnaireResponseItemComponent().apply { + linkId = "3.2" + item = + mutableListOf( + QuestionnaireResponse.QuestionnaireResponseItemComponent().apply { + linkId = "3.2.1" + }, + QuestionnaireResponse.QuestionnaireResponseItemComponent().apply { + linkId = "3.2.2" + }, + ) + }, + ) + } + + val items = mutableListOf(item1, item2, item3) + val exclusionMap = mapOf("2" to true, "3.1" to true, "3.2.2" to true) + val filteredItems = questionnaireViewModel.excludePrepopulationFields(items, exclusionMap) + Assert.assertEquals(2, filteredItems.size) + Assert.assertEquals("1", filteredItems.first().linkId) + val itemThree = filteredItems.last() + Assert.assertEquals("3", itemThree.linkId) + Assert.assertEquals(1, itemThree.item.size) + val itemThreePointTwo = itemThree.item.first() + Assert.assertEquals("3.2", itemThreePointTwo.linkId) + Assert.assertEquals(1, itemThreePointTwo.item.size) + val itemThreePointTwoOne = itemThreePointTwo.item.first() + Assert.assertEquals("3.2.1", itemThreePointTwoOne.linkId) + } } diff --git a/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/register/RegisterFragmentTest.kt b/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/register/RegisterFragmentTest.kt index e3c08fc1ed7..a4a903b17e3 100644 --- a/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/register/RegisterFragmentTest.kt +++ b/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/register/RegisterFragmentTest.kt @@ -22,8 +22,6 @@ import androidx.core.os.bundleOf import androidx.fragment.app.commitNow import androidx.navigation.testing.TestNavHostController import com.google.android.fhir.sync.CurrentSyncJobStatus -import com.google.android.fhir.sync.SyncJobStatus -import com.google.android.fhir.sync.SyncOperation import dagger.hilt.android.testing.BindValue import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidTest @@ -37,10 +35,8 @@ import io.mockk.spyk import io.mockk.verify import java.time.OffsetDateTime import javax.inject.Inject -import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.runTest -import org.hl7.fhir.r4.model.Patient import org.hl7.fhir.r4.model.QuestionnaireResponse import org.hl7.fhir.r4.model.ResourceType import org.junit.Assert @@ -93,8 +89,8 @@ class RegisterFragmentTest : RobolectricTest() { registerRepository = mockk(relaxed = true), configurationRegistry = configurationRegistry, sharedPreferencesHelper = Faker.buildSharedPreferencesHelper(), - dispatcherProvider = dispatcherProvider, resourceDataRulesExecutor = mockk(), + dispatcherProvider = dispatcherProvider, ), ) registerFragmentMock = mockk() @@ -131,14 +127,6 @@ class RegisterFragmentTest : RobolectricTest() { } } - @Test - fun testOnStopClearsSearchText() { - coEvery { registerFragmentMock.onStop() } just runs - registerFragmentMock.onStop() - verify { registerFragmentMock.onStop() } - Assert.assertEquals(registerViewModel.searchText.value, "") - } - @Test fun testOnSyncState() { val syncJobStatus = CurrentSyncJobStatus.Succeeded(OffsetDateTime.now()) @@ -148,7 +136,6 @@ class RegisterFragmentTest : RobolectricTest() { } @Test - @OptIn(ExperimentalCoroutinesApi::class) fun `test On changed emits a snack bar message`() = runTest { val snackBarMessageConfig = SnackBarMessageConfig( @@ -165,68 +152,6 @@ class RegisterFragmentTest : RobolectricTest() { coVerify { registerViewModel.emitSnackBarState(snackBarMessageConfig = snackBarMessageConfig) } } - @Test - @OptIn(ExperimentalMaterialApi::class, ExperimentalCoroutinesApi::class) - fun `test On Sync Progress emits progress percentage`() = runTest { - val downloadProgressSyncStatus = - CurrentSyncJobStatus.Running(SyncJobStatus.InProgress(SyncOperation.DOWNLOAD, 1000, 300)) - val uploadProgressSyncStatus: CurrentSyncJobStatus.Running = - CurrentSyncJobStatus.Running(SyncJobStatus.InProgress(SyncOperation.UPLOAD, 100, 85)) - - val registerFragment = spyk(registerFragment) - - coEvery { registerFragment.onSync(downloadProgressSyncStatus) } answers { callOriginal() } - coEvery { registerFragment.onSync(uploadProgressSyncStatus) } answers { callOriginal() } - - registerFragment.onSync(downloadProgressSyncStatus) - registerFragment.onSync(uploadProgressSyncStatus) - - coVerify(exactly = 1) { registerViewModel.emitPercentageProgressState(30, false) } - - coVerify(exactly = 1) { registerViewModel.emitPercentageProgressState(85, true) } - - coVerify(exactly = 1) { - registerFragment.emitPercentageProgress( - downloadProgressSyncStatus.inProgressSyncJob as SyncJobStatus.InProgress, - false, - ) - } - coVerify(exactly = 1) { - registerFragment.emitPercentageProgress( - uploadProgressSyncStatus.inProgressSyncJob as SyncJobStatus.InProgress, - true, - ) - } - } - - @Test - @OptIn(ExperimentalMaterialApi::class, ExperimentalCoroutinesApi::class) - fun `test On Sync Progress emits correct download progress percentage after a glitch`() = - runTest { - val downloadProgressSyncStatus: SyncJobStatus.InProgress = - SyncJobStatus.InProgress(SyncOperation.DOWNLOAD, 1000, 300) - val downloadProgressSyncStatusAfterGlitchReset: SyncJobStatus.InProgress = - SyncJobStatus.InProgress(SyncOperation.DOWNLOAD, 200, 100) - - val registerFragment = spyk(registerFragment) - - registerFragment.onSync(CurrentSyncJobStatus.Running(downloadProgressSyncStatus)) - registerFragment.onSync( - CurrentSyncJobStatus.Running(downloadProgressSyncStatusAfterGlitchReset), - ) - - coVerify(exactly = 1) { - registerFragment.emitPercentageProgress(downloadProgressSyncStatus, false) - } - - coVerify(exactly = 1) { - registerFragment.emitPercentageProgress(downloadProgressSyncStatusAfterGlitchReset, false) - } - - coVerify(exactly = 1) { registerViewModel.emitPercentageProgressState(30, false) } - coVerify(exactly = 1) { registerViewModel.emitPercentageProgressState(90, false) } - } - @Test fun testHandleQuestionnaireSubmissionCallsRegisterViewModelPaginateRegisterDataAndEmitSnackBarState() { val snackBarMessageConfig = SnackBarMessageConfig(message = "Family member added") diff --git a/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/register/RegisterViewModelTest.kt b/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/register/RegisterViewModelTest.kt index 4089ec71842..8f7e92738fc 100644 --- a/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/register/RegisterViewModelTest.kt +++ b/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/register/RegisterViewModelTest.kt @@ -29,6 +29,10 @@ import io.mockk.runs import io.mockk.spyk import io.mockk.verify import javax.inject.Inject +import kotlin.time.Duration.Companion.milliseconds +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import org.hl7.fhir.r4.model.Coding import org.hl7.fhir.r4.model.DateType @@ -60,6 +64,7 @@ import org.smartregister.fhircore.engine.util.SharedPreferenceKey import org.smartregister.fhircore.engine.util.SharedPreferencesHelper import org.smartregister.fhircore.quest.app.fakes.Faker import org.smartregister.fhircore.quest.robolectric.RobolectricTest +import org.smartregister.fhircore.quest.ui.shared.models.SearchQuery @HiltAndroidTest class RegisterViewModelTest : RobolectricTest() { @@ -68,6 +73,7 @@ class RegisterViewModelTest : RobolectricTest() { @Inject lateinit var resourceDataRulesExecutor: ResourceDataRulesExecutor @Inject lateinit var dispatcherProvider: DispatcherProvider + private val configurationRegistry: ConfigurationRegistry = Faker.buildTestConfigurationRegistry() private lateinit var registerViewModel: RegisterViewModel private lateinit var registerRepository: RegisterRepository @@ -87,8 +93,8 @@ class RegisterViewModelTest : RobolectricTest() { registerRepository = registerRepository, configurationRegistry = configurationRegistry, sharedPreferencesHelper = sharedPreferencesHelper, - dispatcherProvider = dispatcherProvider, resourceDataRulesExecutor = resourceDataRulesExecutor, + dispatcherProvider = dispatcherProvider, ), ) @@ -108,7 +114,7 @@ class RegisterViewModelTest : RobolectricTest() { pageSize = 10, ) registerViewModel.paginateRegisterData(registerId, false) - val paginatedRegisterData = registerViewModel.paginatedRegisterData.value + val paginatedRegisterData = registerViewModel.registerData.value Assert.assertNotNull(paginatedRegisterData) Assert.assertTrue(registerViewModel.pagesDataCache.isNotEmpty()) } @@ -116,7 +122,7 @@ class RegisterViewModelTest : RobolectricTest() { @Test @kotlinx.coroutines.ExperimentalCoroutinesApi fun testRetrieveRegisterUiState() = runTest { - every { registerViewModel.retrieveRegisterConfiguration(any()) } returns + val registerConfig = RegisterConfiguration( appId = "app", id = registerId, @@ -124,6 +130,10 @@ class RegisterViewModelTest : RobolectricTest() { FhirResourceConfig(baseResource = ResourceConfig(resource = ResourceType.Patient)), pageSize = 10, ) + configurationRegistry.configCacheMap[registerId] = registerConfig + registerViewModel.registerUiState.value = + registerViewModel.registerUiState.value.copy(registerId = registerId) + every { registerViewModel.paginateRegisterData(any(), any()) } just runs coEvery { registerRepository.countRegisterData(any()) } returns 200 registerViewModel.retrieveRegisterUiState( @@ -140,13 +150,12 @@ class RegisterViewModelTest : RobolectricTest() { val registerConfiguration = registerUiState.registerConfiguration Assert.assertNotNull(registerConfiguration) Assert.assertEquals("app", registerConfiguration?.appId) - Assert.assertEquals(200, registerUiState.totalRecordsCount) - Assert.assertEquals(20, registerUiState.pagesCount) } @Test - fun testOnEventSearchRegister() { - every { registerViewModel.retrieveRegisterConfiguration(any()) } returns + @kotlinx.coroutines.ExperimentalCoroutinesApi + fun testDebounceSearchQueryFlow() = runTest { + val registerConfig = RegisterConfiguration( appId = "app", id = registerId, @@ -154,14 +163,68 @@ class RegisterViewModelTest : RobolectricTest() { FhirResourceConfig(baseResource = ResourceConfig(resource = ResourceType.Patient)), pageSize = 10, ) - every { registerViewModel.registerUiState } returns - mutableStateOf(RegisterUiState(registerId = registerId)) + configurationRegistry.configCacheMap[registerId] = registerConfig + registerViewModel.registerUiState.value = + registerViewModel.registerUiState.value.copy(registerId = registerId) + coEvery { registerRepository.countRegisterData(any()) } returns 0L + + val results = mutableListOf() + val debounceJob = launch { + registerViewModel.debouncedSearchQueryFlow.collect { results.add(it.query) } + } + advanceUntilIdle() + // Search with empty string should paginate the data - registerViewModel.onEvent(RegisterEvent.SearchRegister("")) - verify { registerViewModel.paginateRegisterData(any(), any()) } + registerViewModel.onEvent(RegisterEvent.SearchRegister(SearchQuery.emptyText)) + + advanceTimeBy(3.milliseconds) + Assert.assertTrue(results.isNotEmpty()) + Assert.assertTrue(results.last().isBlank()) + + registerViewModel.onEvent(RegisterEvent.SearchRegister(SearchQuery("K"))) + registerViewModel.onEvent(RegisterEvent.SearchRegister(SearchQuery("Kh"))) + registerViewModel.onEvent(RegisterEvent.SearchRegister(SearchQuery("Kha"))) + registerViewModel.onEvent(RegisterEvent.SearchRegister(SearchQuery("Khan"))) + + advanceTimeBy(1010.milliseconds) + Assert.assertEquals(2, results.size) + Assert.assertEquals("Khan", results.last()) + debounceJob.cancel() + } + + @Test + fun testPerformSearchWithEmptyQuery() = runTest { + val registerConfig = + RegisterConfiguration( + appId = "app", + id = registerId, + fhirResource = + FhirResourceConfig(baseResource = ResourceConfig(resource = ResourceType.Patient)), + pageSize = 10, + ) + configurationRegistry.configCacheMap[registerId] = registerConfig + coEvery { registerRepository.countRegisterData(any()) } returns 0L + + // Search with empty string should paginate the data + registerViewModel.performSearch(registerId, SearchQuery.emptyText) + verify { registerViewModel.retrieveRegisterUiState(any(), any(), any(), any()) } + } + + @Test + fun testPerformSearchWithNonEmptyQuery() = runTest { + val registerConfig = + RegisterConfiguration( + appId = "app", + id = registerId, + fhirResource = + FhirResourceConfig(baseResource = ResourceConfig(resource = ResourceType.Patient)), + pageSize = 10, + ) + configurationRegistry.configCacheMap[registerId] = registerConfig + coEvery { registerRepository.countRegisterData(any()) } returns 0L // Search for the word 'Khan' should call the filterRegisterData function - registerViewModel.onEvent(RegisterEvent.SearchRegister("Khan")) + registerViewModel.performSearch(registerId, SearchQuery("Khan")) verify { registerViewModel.filterRegisterData(any()) } } diff --git a/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/report/measure/MeasureReportViewModelTest.kt b/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/report/measure/MeasureReportViewModelTest.kt index 5cb739577da..5949ebf6e78 100644 --- a/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/report/measure/MeasureReportViewModelTest.kt +++ b/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/report/measure/MeasureReportViewModelTest.kt @@ -47,10 +47,12 @@ import kotlin.test.assertEquals import kotlin.test.assertFailsWith import kotlin.test.assertNotNull import kotlin.time.Duration.Companion.seconds +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.runTest import org.hl7.fhir.r4.model.CodeableConcept import org.hl7.fhir.r4.model.Coding @@ -107,6 +109,9 @@ class MeasureReportViewModelTest : RobolectricTest() { @Inject lateinit var resourceDataRulesExecutor: ResourceDataRulesExecutor + @OptIn(ExperimentalCoroutinesApi::class) + private val unconfinedTestDispatcher = UnconfinedTestDispatcher() + @Inject lateinit var fhirEngine: FhirEngine private val measureReportRepository: MeasureReportRepository = mockk() private val fhirOperator: FhirOperator = mockk() @@ -146,6 +151,8 @@ class MeasureReportViewModelTest : RobolectricTest() { measureReportRepository = measureReportRepository, ), ) + + every { measureReportViewModel.dispatcherProvider.io() } returns Dispatchers.IO } @Test @@ -180,7 +187,7 @@ class MeasureReportViewModelTest : RobolectricTest() { url = "http://nourl.com", module = "Module1", ) - every { measureReportViewModel.evaluateMeasure(any(), any()) } just runs + coEvery { measureReportViewModel.evaluateMeasure(any(), any()) } just runs measureReportViewModel.reportTypeSelectorUiState.value = ReportTypeSelectorUiState(startDate = "21 Jan, 2022", endDate = "27 Jan, 2022") measureReportViewModel.onEvent( @@ -196,7 +203,7 @@ class MeasureReportViewModelTest : RobolectricTest() { Assert.assertEquals(viewModelConfig.first().id, reportConfiguration.id) Assert.assertEquals(viewModelConfig.first().module, reportConfiguration.module) - verify { + coVerify { measureReportViewModel.evaluateMeasure( navController = navController, practitionerId = "practitioner-id", @@ -298,13 +305,17 @@ class MeasureReportViewModelTest : RobolectricTest() { Assert.assertNotNull(sampleSubjectViewData.family, subjectViewData?.family) } - @Test() + @Test fun testEvaluateMeasureUtilizesPreviouslyGeneratedMeasureReportIfAvailable() = - runTest(timeout = 90.seconds) { - val subject = Group().apply { id = "groupId" } + runTest(timeout = 90.seconds, context = unconfinedTestDispatcher) { + val subject = + Group().apply { + id = "groupId" + name = "Test Group" + } val testMeasureReport = MeasureReport().apply { - id = "measureId" + id = "MeasureReport/measureId" measure = "http://nourl.com" type = MeasureReportType.INDIVIDUAL this.subject = subject.asReference() @@ -318,7 +329,7 @@ class MeasureReportViewModelTest : RobolectricTest() { val reportConfiguration = ReportConfiguration( - id = "measureId", + id = "ReportConfiguration/measureId", title = "Measure 1", description = "Measure report for testing", url = "http://nourl.com", @@ -337,16 +348,37 @@ class MeasureReportViewModelTest : RobolectricTest() { ) } returns listOf(testMeasureReport) + coEvery { measureReportRepository.fetchSubjects(any(ReportConfiguration::class)) } returns + listOf(subject.asReference().toString()) + measureReportViewModel.reportTypeSelectorUiState.value = ReportTypeSelectorUiState(startDate = "21 Jan, 2022", endDate = "27 Jan, 2022") measureReportViewModel.reportConfigurations.add(reportConfiguration) measureReportViewModel.evaluateMeasure(navController, null) + val measureReportListSlot = slot>() + coVerify { - measureReportViewModel.formatPopulationMeasureReports(listOf(testMeasureReport), any()) + measureReportViewModel.formatPopulationMeasureReports(capture(measureReportListSlot), any()) } + assertEquals(testMeasureReport.id, measureReportListSlot.captured.first().id) + assertEquals(testMeasureReport.measure, measureReportListSlot.captured.first().measure) + assertEquals(testMeasureReport.type, measureReportListSlot.captured.first().type) + assertEquals( + testMeasureReport.subject.reference, + measureReportListSlot.captured.first().subject.reference, + ) + assertEquals( + testMeasureReport.period.start.toString(), + measureReportListSlot.captured.first().period.start.toString(), + ) + assertEquals( + testMeasureReport.period.end.toString(), + measureReportListSlot.captured.first().period.end.toString(), + ) + coVerify(exactly = 0) { measureReportRepository.evaluatePopulationMeasure(any(), any(), any(), any(), any(), any()) } diff --git a/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/sdc/qrCode/CameraPermissionsDialogFragmentTest.kt b/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/sdc/qrCode/CameraPermissionsDialogFragmentTest.kt new file mode 100644 index 00000000000..d56dfa74946 --- /dev/null +++ b/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/sdc/qrCode/CameraPermissionsDialogFragmentTest.kt @@ -0,0 +1,87 @@ +/* + * Copyright 2021-2024 Ona Systems, Inc + * + * 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 org.smartregister.fhircore.quest.ui.sdc.qrCode + +import android.Manifest +import android.app.Application +import androidx.fragment.app.commitNow +import androidx.test.core.app.ApplicationProvider +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import io.mockk.spyk +import io.mockk.verify +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.robolectric.Shadows.shadowOf +import org.robolectric.util.ReflectionHelpers +import org.smartregister.fhircore.quest.hiltActivityForTestScenario +import org.smartregister.fhircore.quest.robolectric.RobolectricTest + +@HiltAndroidTest +class CameraPermissionsDialogFragmentTest : RobolectricTest() { + @get:Rule(order = 0) var hiltAndroidRule = HiltAndroidRule(this) + + private val applicationContext = ApplicationProvider.getApplicationContext() + + @Before + fun setUp() { + hiltAndroidRule.inject() + } + + @Test + fun onResumeShouldNotLaunchCameraPermissionRequestWhenCameraPermissionGranted() { + shadowOf(applicationContext).grantPermissions(Manifest.permission.CAMERA) + hiltActivityForTestScenario().use { scenario -> + scenario.onActivity { activity -> + val qrCodeFragment = CameraPermissionsDialogFragment() + val cameraPermissionRequestSpy = spyk(qrCodeFragment.cameraPermissionRequest) + ReflectionHelpers.setField( + qrCodeFragment, + "cameraPermissionRequest", + cameraPermissionRequestSpy, + ) + + activity.supportFragmentManager.commitNow { + add(qrCodeFragment, CameraPermissionsDialogFragmentTest::class.java.simpleName) + } + verify(exactly = 0) { cameraPermissionRequestSpy.launch(Manifest.permission.CAMERA) } + } + } + } + + @Test + fun onResumeShouldLaunchCameraPermissionRequestWhenPermissionDenied() { + shadowOf(applicationContext).denyPermissions(Manifest.permission.CAMERA) + hiltActivityForTestScenario().use { scenario -> + scenario.onActivity { activity -> + val qrCodeFragment = CameraPermissionsDialogFragment() + val cameraPermissionRequestSpy = spyk(qrCodeFragment.cameraPermissionRequest) + ReflectionHelpers.setField( + qrCodeFragment, + "cameraPermissionRequest", + cameraPermissionRequestSpy, + ) + + activity.supportFragmentManager.commitNow { + add(qrCodeFragment, CameraPermissionsDialogFragmentTest::class.java.simpleName) + } + verify { cameraPermissionRequestSpy.launch(Manifest.permission.CAMERA) } + } + } + } +} diff --git a/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/sdc/qrCode/EditTextQrCodeItemViewHolderFactoryTest.kt b/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/sdc/qrCode/EditTextQrCodeItemViewHolderFactoryTest.kt new file mode 100644 index 00000000000..7edddb6ecae --- /dev/null +++ b/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/sdc/qrCode/EditTextQrCodeItemViewHolderFactoryTest.kt @@ -0,0 +1,302 @@ +/* + * Copyright 2021-2024 Ona Systems, Inc + * + * 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 org.smartregister.fhircore.quest.ui.sdc.qrCode + +import android.view.MotionEvent +import android.widget.FrameLayout +import androidx.appcompat.app.AppCompatActivity +import androidx.core.os.bundleOf +import androidx.fragment.app.FragmentManager +import androidx.test.core.view.MotionEventBuilder +import com.google.android.fhir.datacapture.validation.NotValidated +import com.google.android.fhir.datacapture.views.QuestionnaireViewItem +import com.google.android.material.textfield.TextInputEditText +import com.google.android.material.textfield.TextInputLayout +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import io.mockk.every +import io.mockk.mockkConstructor +import io.mockk.unmockkConstructor +import kotlinx.coroutines.test.runTest +import org.hl7.fhir.r4.model.BooleanType +import org.hl7.fhir.r4.model.Extension +import org.hl7.fhir.r4.model.Questionnaire +import org.hl7.fhir.r4.model.QuestionnaireResponse +import org.hl7.fhir.r4.model.StringType +import org.junit.Assert +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.robolectric.Robolectric +import org.smartregister.fhircore.quest.R +import org.smartregister.fhircore.quest.hiltActivityForTestScenario +import org.smartregister.fhircore.quest.robolectric.RobolectricTest +import org.smartregister.fhircore.quest.ui.sdc.qrCode.scan.QRCodeScannerDialogFragment +import org.smartregister.fhircore.quest.util.QrCodeScanUtils + +@HiltAndroidTest +class EditTextQrCodeItemViewHolderFactoryTest : RobolectricTest() { + + @get:Rule(order = 0) var hiltAndroidRule = HiltAndroidRule(this) + + private val parentView = + FrameLayout( + Robolectric.buildActivity(AppCompatActivity::class.java).create().get().apply { + /** + * Using style 'com.google.android.material.R.style.Theme_Material3_DayNight' to prevent + * Robolectric [UnsupportedOperationException] error for 'attr/colorSurfaceVariant' + * https://github.com/robolectric/robolectric/issues/4961#issuecomment-488517645 + */ + setTheme(com.google.android.material.R.style.Theme_Material3_DayNight) + }, + ) + + @Before + fun setUp() { + hiltAndroidRule.inject() + } + + @Test + fun shouldUpdateTextCorrectlyWhenScanQrCodeReceived() { + mockkConstructor(QRCodeScannerDialogFragment::class) + val sampleQrCode = "d84fbd12-4f22-423a-8645-5525504e1bcb" + /** + * Using style 'com.google.android.material.R.style.Theme_Material3_DayNight' to prevent + * Robolectric [UnsupportedOperationException] error for 'attr/colorSurfaceVariant' + * https://github.com/robolectric/robolectric/issues/4961#issuecomment-488517645 + */ + hiltActivityForTestScenario(com.google.android.material.R.style.Theme_Material3_DayNight).use { + scenario -> + scenario.onActivity { activity -> + val parentView = FrameLayout(activity) + val viewHolder = EditTextQrCodeItemViewHolderFactory { _, _ -> }.create(parentView) + val textInputLayout = + viewHolder.itemView.findViewById(R.id.text_input_layout) + Assert.assertNotNull(textInputLayout) + val textInputEditText = + textInputLayout.findViewById(R.id.text_input_edit_text) + Assert.assertNotNull(textInputEditText) + every { + anyConstructed() + .show(any(), QrCodeScanUtils.QR_CODE_SCAN_UTILS_TAG) + } answers + { + activity.supportFragmentManager.setFragmentResult( + QRCodeScannerDialogFragment.RESULT_REQUEST_KEY, + bundleOf(QRCodeScannerDialogFragment.RESULT_REQUEST_KEY to sampleQrCode), + ) + } + + textInputEditText.dispatchTouchEvent( + MotionEventBuilder.newBuilder().setAction(MotionEvent.ACTION_UP).build(), + ) + Assert.assertEquals(sampleQrCode, textInputEditText.text.toString()) + } + } + unmockkConstructor(QRCodeScannerDialogFragment::class) + } + + @Test + fun shouldSetCorrectAnswerText() { + val sampleQrCode = "d84fbd12-4f22-423a-8645-5525504e1bcb" + val viewHolder = EditTextQrCodeItemViewHolderFactory { _, _ -> }.create(parentView) + viewHolder.bind( + QuestionnaireViewItem( + questionnaireItem = + Questionnaire.QuestionnaireItemComponent().apply { + linkId = "sample-text" + type = Questionnaire.QuestionnaireItemType.STRING + addExtension( + Extension( + "https://github.com/opensrp/android-fhir/StructureDefinition/qr-code-widget", + ), + ) + }, + questionnaireResponseItem = + QuestionnaireResponse.QuestionnaireResponseItemComponent().apply { + linkId = "sample-text" + addAnswer( + QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { + value = StringType(sampleQrCode) + }, + ) + }, + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ), + ) + val textInputEditText = + viewHolder.itemView.findViewById(R.id.text_input_edit_text) + Assert.assertEquals(sampleQrCode, textInputEditText.text.toString()) + } + + @Test + fun shouldSetInputDisabledWhenQuestionViewItemHasAnswerAndQuestionnaireItemIsReadOnly() { + val viewHolder = EditTextQrCodeItemViewHolderFactory { _, _ -> }.create(parentView) + viewHolder.bind( + QuestionnaireViewItem( + questionnaireItem = + Questionnaire.QuestionnaireItemComponent().apply { + linkId = "linkid-a" + readOnly = true + type = Questionnaire.QuestionnaireItemType.STRING + addExtension( + Extension( + "https://github.com/opensrp/android-fhir/StructureDefinition/qr-code-widget", + ), + ) + }, + questionnaireResponseItem = + QuestionnaireResponse.QuestionnaireResponseItemComponent().apply { linkId = "linkid-a" }, + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ), + ) + + viewHolder.itemView.findViewById(R.id.text_input_edit_text).apply { + Assert.assertTrue(this.isEnabled) + Assert.assertTrue(this.text.isNullOrBlank()) + } + + viewHolder.bind( + QuestionnaireViewItem( + questionnaireItem = + Questionnaire.QuestionnaireItemComponent().apply { + linkId = "linkid-a" + readOnly = true + type = Questionnaire.QuestionnaireItemType.STRING + addExtension( + Extension( + "https://github.com/opensrp/android-fhir/StructureDefinition/qr-code-widget", + ), + ) + }, + questionnaireResponseItem = + QuestionnaireResponse.QuestionnaireResponseItemComponent().apply { + linkId = "linkid-a" + addAnswer( + QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { + value = StringType("d84fbd12-4f22-423a-8645-5525504e1bcb") + }, + ) + }, + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ), + ) + + viewHolder.itemView.findViewById(R.id.text_input_edit_text).apply { + Assert.assertFalse(this.isEnabled) + Assert.assertEquals("d84fbd12-4f22-423a-8645-5525504e1bcb", this.text.toString()) + } + } + + @Test + fun shouldSetInputDisabledWhenQuestionViewItemHasAnswerAndIsSetOnceReadOnly() { + val viewHolder = EditTextQrCodeItemViewHolderFactory { _, _ -> }.create(parentView) + viewHolder.bind( + QuestionnaireViewItem( + questionnaireItem = + Questionnaire.QuestionnaireItemComponent().apply { + linkId = "linkid-a" + type = Questionnaire.QuestionnaireItemType.STRING + addExtension( + Extension( + "https://github.com/opensrp/android-fhir/StructureDefinition/qr-code-widget", + ) + .apply { addExtension("set-only-readonly", BooleanType(true)) }, + ) + }, + questionnaireResponseItem = + QuestionnaireResponse.QuestionnaireResponseItemComponent().apply { linkId = "linkid-a" }, + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ), + ) + + viewHolder.itemView.findViewById(R.id.text_input_edit_text).apply { + Assert.assertTrue(this.isEnabled) + Assert.assertTrue(this.text.isNullOrBlank()) + } + + viewHolder.bind( + QuestionnaireViewItem( + questionnaireItem = + Questionnaire.QuestionnaireItemComponent().apply { + linkId = "linkid-a" + type = Questionnaire.QuestionnaireItemType.STRING + addExtension( + Extension( + "https://github.com/opensrp/android-fhir/StructureDefinition/qr-code-widget", + ) + .apply { addExtension("set-only-readonly", BooleanType(true)) }, + ) + }, + questionnaireResponseItem = + QuestionnaireResponse.QuestionnaireResponseItemComponent().apply { + linkId = "linkid-a" + addAnswer( + QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { + value = StringType("d84fbd12-4f22-423a-8645-5525504e1bcb") + }, + ) + }, + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ), + ) + + viewHolder.itemView.findViewById(R.id.text_input_edit_text).apply { + Assert.assertFalse(this.isEnabled) + Assert.assertEquals("d84fbd12-4f22-423a-8645-5525504e1bcb", this.text.toString()) + } + } + + @Test + fun shouldCallOnQrCodeChangedWhenNewTextIsSet() = runTest { + var qrCode: String? = null + val viewHolder = + EditTextQrCodeItemViewHolderFactory { _, qrAnswer -> + qrCode = (qrAnswer?.value as? StringType)?.value + } + .create(parentView) + viewHolder.bind( + QuestionnaireViewItem( + questionnaireItem = + Questionnaire.QuestionnaireItemComponent().apply { + linkId = "linkid-a" + type = Questionnaire.QuestionnaireItemType.STRING + addExtension( + Extension( + "https://github.com/opensrp/android-fhir/StructureDefinition/qr-code-widget", + ), + ) + }, + questionnaireResponseItem = QuestionnaireResponse.QuestionnaireResponseItemComponent(), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ), + ) + val sampleCode = "d84fbd12-4f22-423a-8645-5525504e1bcb" + viewHolder.itemView + .findViewById(R.id.text_input_edit_text) + .setText(sampleCode) + + Assert.assertNotNull(qrCode) + Assert.assertEquals(sampleCode, qrCode) + } +} diff --git a/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/sdc/qrCode/EditTextQrCodeViewHolderFactoryTest.kt b/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/sdc/qrCode/EditTextQrCodeViewHolderFactoryTest.kt new file mode 100644 index 00000000000..3803f62fb2a --- /dev/null +++ b/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/sdc/qrCode/EditTextQrCodeViewHolderFactoryTest.kt @@ -0,0 +1,715 @@ +/* + * Copyright 2021-2024 Ona Systems, Inc + * + * 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 org.smartregister.fhircore.quest.ui.sdc.qrCode + +import android.view.View +import android.widget.Button +import android.widget.FrameLayout +import androidx.appcompat.app.AppCompatActivity +import androidx.recyclerview.widget.RecyclerView +import com.google.android.fhir.datacapture.validation.NotValidated +import com.google.android.fhir.datacapture.views.QuestionnaireViewItem +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import io.mockk.coVerify +import io.mockk.spyk +import kotlinx.coroutines.test.runTest +import org.hl7.fhir.r4.model.BooleanType +import org.hl7.fhir.r4.model.Extension +import org.hl7.fhir.r4.model.Questionnaire +import org.hl7.fhir.r4.model.QuestionnaireResponse +import org.hl7.fhir.r4.model.StringType +import org.junit.Assert +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.robolectric.Robolectric +import org.smartregister.fhircore.quest.R +import org.smartregister.fhircore.quest.robolectric.RobolectricTest + +@HiltAndroidTest +class EditTextQrCodeViewHolderFactoryTest : RobolectricTest() { + + @get:Rule(order = 0) var hiltAndroidRule = HiltAndroidRule(this) + + private val parentView = + FrameLayout( + Robolectric.buildActivity(AppCompatActivity::class.java).create().get().apply { + /** + * Using style 'com.google.android.material.R.style.Theme_Material3_DayNight' to prevent + * Robolectric [UnsupportedOperationException] error for 'attr/colorSurfaceVariant' + * https://github.com/robolectric/robolectric/issues/4961#issuecomment-488517645 + */ + setTheme(com.google.android.material.R.style.Theme_Material3_DayNight) + }, + ) + + private val sampleQrCode1 = "de49a176-c85b-4ced-8651-e27909d6b3f4" + private val sampleQrCode2 = "0f2cfbea-e2c9-47b1-941d-39184306eb74" + + @Before + fun setUp() { + hiltAndroidRule.inject() + } + + @Test + fun matcherCorrectlyMatchesQuestionnaireItem() { + val questionnaireItem = + Questionnaire.QuestionnaireItemComponent().apply { + addExtension( + Extension("https://github.com/opensrp/android-fhir/StructureDefinition/qr-code-widget"), + ) + } + + Assert.assertTrue(EditTextQrCodeViewHolderFactory.matcher(questionnaireItem)) + } + + @Test + fun qrCodeWidgetQuestionnaireItemWithExtensionSetOnceReadOnlyIsCorrectlyMarkedAsSetOnceReadOnly() { + val questionnaireItem = + Questionnaire.QuestionnaireItemComponent().apply { + addExtension( + Extension("https://github.com/opensrp/android-fhir/StructureDefinition/qr-code-widget") + .apply { addExtension("set-only-readonly", BooleanType(true)) }, + ) + } + val questionnaireViewItem = + QuestionnaireViewItem( + questionnaireItem, + QuestionnaireResponse.QuestionnaireResponseItemComponent(), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + + Assert.assertTrue(questionnaireViewItem.isSetOnceReadOnly) + } + + @Test + fun qrCodeWidgetQuestionnaireItemWithoutExtensionSetOnceReadOnlyIsCorrectlyMarkedAsNotSetOnceReadOnly() { + val questionnaireItem = + Questionnaire.QuestionnaireItemComponent().apply { + addExtension( + Extension("https://github.com/opensrp/android-fhir/StructureDefinition/qr-code-widget"), + ) + } + val questionnaireViewItem = + QuestionnaireViewItem( + questionnaireItem, + QuestionnaireResponse.QuestionnaireResponseItemComponent(), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + + Assert.assertFalse(questionnaireViewItem.isSetOnceReadOnly) + } + + @Test + fun shouldHideAddQrCodeButtonWhenQuestionnaireItemIsReadOnly() { + val viewHolder = EditTextQrCodeViewHolderFactory.create(parentView) + val questionnaireViewItem = + QuestionnaireViewItem( + questionnaireItem = + Questionnaire.QuestionnaireItemComponent().apply { + linkId = "linkId-a" + readOnly = true + addExtension( + Extension( + "https://github.com/opensrp/android-fhir/StructureDefinition/qr-code-widget", + ), + ) + }, + questionnaireResponseItem = QuestionnaireResponse.QuestionnaireResponseItemComponent(), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + viewHolder.bind(questionnaireViewItem) + Assert.assertEquals( + View.GONE, + viewHolder.itemView.findViewById(R.id.add_qr_code).visibility, + ) + } + + @Test + fun shouldHideAddQrCodeButtonWhenQuestionnaireItemRepeatsFalse() { + val viewHolder = EditTextQrCodeViewHolderFactory.create(parentView) + val questionnaireViewItem = + QuestionnaireViewItem( + questionnaireItem = + Questionnaire.QuestionnaireItemComponent().apply { + linkId = "linkId-a" + repeats = false + addExtension( + Extension( + "https://github.com/opensrp/android-fhir/StructureDefinition/qr-code-widget", + ), + ) + }, + questionnaireResponseItem = QuestionnaireResponse.QuestionnaireResponseItemComponent(), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + viewHolder.bind(questionnaireViewItem) + Assert.assertEquals( + View.GONE, + viewHolder.itemView.findViewById(R.id.add_qr_code).visibility, + ) + } + + @Test + fun shouldShowAddQrCodeButtonWhenQuestionnaireItemRepeatsTrue() { + val viewHolder = EditTextQrCodeViewHolderFactory.create(parentView) + val questionnaireViewItem = + QuestionnaireViewItem( + questionnaireItem = + Questionnaire.QuestionnaireItemComponent().apply { + linkId = "linkId-a" + repeats = true + addExtension( + Extension( + "https://github.com/opensrp/android-fhir/StructureDefinition/qr-code-widget", + ), + ) + }, + questionnaireResponseItem = QuestionnaireResponse.QuestionnaireResponseItemComponent(), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + viewHolder.bind(questionnaireViewItem) + Assert.assertEquals( + View.VISIBLE, + viewHolder.itemView.findViewById(R.id.add_qr_code).visibility, + ) + } + + @Test + fun shouldHideAddQrCodeButtonWhenQuestionnaireItemRepeatsAndIsReadOnly() { + val viewHolder = EditTextQrCodeViewHolderFactory.create(parentView) + val questionnaireViewItem = + QuestionnaireViewItem( + questionnaireItem = + Questionnaire.QuestionnaireItemComponent().apply { + linkId = "linkId-a" + repeats = true + readOnly = true + addExtension( + Extension( + "https://github.com/opensrp/android-fhir/StructureDefinition/qr-code-widget", + ), + ) + }, + questionnaireResponseItem = QuestionnaireResponse.QuestionnaireResponseItemComponent(), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + viewHolder.bind(questionnaireViewItem) + Assert.assertEquals( + View.GONE, + viewHolder.itemView.findViewById(R.id.add_qr_code).visibility, + ) + } + + @Test + fun shouldHaveASingleAnswerItemWhenDoesNotRepeat() { + val viewHolder = EditTextQrCodeViewHolderFactory.create(parentView) + val questionnaireViewItem = + QuestionnaireViewItem( + questionnaireItem = + Questionnaire.QuestionnaireItemComponent().apply { + linkId = "linkId-a" + repeats = false + addExtension( + Extension( + "https://github.com/opensrp/android-fhir/StructureDefinition/qr-code-widget", + ), + ) + }, + questionnaireResponseItem = + QuestionnaireResponse.QuestionnaireResponseItemComponent().apply { + linkId = "linkId-a" + addAnswer( + QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { + value = StringType(sampleQrCode1) + }, + ) + }, + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + viewHolder.bind(questionnaireViewItem) + val qrCodeAdapter = + viewHolder.itemView.findViewById(R.id.recycler_view_qr_codes).adapter + as? QrCodeViewItemAdapter + Assert.assertNotNull(qrCodeAdapter) + qrCodeAdapter!! + Assert.assertEquals(1, qrCodeAdapter.currentList.size) + Assert.assertEquals( + sampleQrCode1, + qrCodeAdapter.currentList.single().answers.single().valueStringType.value, + ) + } + + @Test + fun shouldAddAnEmptyItemWhenDoesNotRepeatAndNoAnswer() { + val viewHolder = EditTextQrCodeViewHolderFactory.create(parentView) + val questionnaireViewItem = + QuestionnaireViewItem( + questionnaireItem = + Questionnaire.QuestionnaireItemComponent().apply { + linkId = "linkId-a" + repeats = false + addExtension( + Extension( + "https://github.com/opensrp/android-fhir/StructureDefinition/qr-code-widget", + ), + ) + }, + questionnaireResponseItem = QuestionnaireResponse.QuestionnaireResponseItemComponent(), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + viewHolder.bind(questionnaireViewItem) + val qrCodeAdapter = + viewHolder.itemView.findViewById(R.id.recycler_view_qr_codes).adapter + as? QrCodeViewItemAdapter + Assert.assertNotNull(qrCodeAdapter) + qrCodeAdapter!! + Assert.assertEquals(1, qrCodeAdapter.currentList.size) + } + + @Test + fun shouldAddMultipleAnswerItemsWhenRepeatsTrue() { + val viewHolder = EditTextQrCodeViewHolderFactory.create(parentView) + val questionnaireViewItem = + QuestionnaireViewItem( + questionnaireItem = + Questionnaire.QuestionnaireItemComponent().apply { + linkId = "linkId-a" + repeats = true + addExtension( + Extension( + "https://github.com/opensrp/android-fhir/StructureDefinition/qr-code-widget", + ), + ) + }, + questionnaireResponseItem = + QuestionnaireResponse.QuestionnaireResponseItemComponent().apply { + linkId = "linkId-a" + addAnswer( + QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { + value = StringType(sampleQrCode1) + }, + ) + addAnswer( + QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { + value = StringType(sampleQrCode2) + }, + ) + }, + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + viewHolder.bind(questionnaireViewItem) + val qrCodeAdapter = + viewHolder.itemView.findViewById(R.id.recycler_view_qr_codes).adapter + as QrCodeViewItemAdapter + qrCodeAdapter.currentList.apply { + Assert.assertEquals(2, size) + Assert.assertEquals(sampleQrCode1, first().answers.single().valueStringType.value) + Assert.assertEquals(sampleQrCode2, last().answers.single().valueStringType.value) + } + } + + @Test + fun addQrCodeShouldBeClickableWhenRepeatsTrue() { + val viewHolder = EditTextQrCodeViewHolderFactory.create(parentView) + val questionnaireViewItem = + QuestionnaireViewItem( + questionnaireItem = + Questionnaire.QuestionnaireItemComponent().apply { + linkId = "linkId-a" + repeats = true + addExtension( + Extension( + "https://github.com/opensrp/android-fhir/StructureDefinition/qr-code-widget", + ), + ) + }, + questionnaireResponseItem = + QuestionnaireResponse.QuestionnaireResponseItemComponent().apply { + linkId = "linkId-a" + addAnswer( + QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { + value = StringType(sampleQrCode1) + }, + ) + addAnswer( + QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { + value = StringType(sampleQrCode2) + }, + ) + }, + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> }, + ) + viewHolder.bind(questionnaireViewItem) + Assert.assertTrue(viewHolder.itemView.findViewById