diff --git a/.github/actions/commonSetup/action.yml b/.github/actions/commonSetup/action.yml
index 5d19e50b00..e481396b14 100644
--- a/.github/actions/commonSetup/action.yml
+++ b/.github/actions/commonSetup/action.yml
@@ -3,11 +3,11 @@ description: "Prepares the machine"
runs:
using: "composite"
steps:
- - name: Set up JDK 11
+ - name: Set up JDK 17
uses: actions/setup-java@v2
with:
distribution: temurin
- java-version: "11"
+ java-version: "17"
- name: Make files executable
shell: bash
run: chmod +x ./gradlew
diff --git a/benchmark/src/main/AndroidManifest.xml b/benchmark/src/main/AndroidManifest.xml
deleted file mode 100644
index 40a1528389..0000000000
--- a/benchmark/src/main/AndroidManifest.xml
+++ /dev/null
@@ -1,2 +0,0 @@
-
-
diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts
index bd84994384..4fe27f3e24 100644
--- a/buildSrc/build.gradle.kts
+++ b/buildSrc/build.gradle.kts
@@ -11,7 +11,7 @@ repositories {
dependencies {
implementation("com.diffplug.spotless:spotless-plugin-gradle:6.6.0")
- implementation("com.android.tools.build:gradle:7.1.1")
+ implementation("com.android.tools.build:gradle:8.0.2")
implementation("app.cash.licensee:licensee-gradle-plugin:1.3.0")
implementation("com.osacky.flank.gradle:fladle:0.17.4")
@@ -19,6 +19,5 @@ dependencies {
implementation("com.spotify.ruler:ruler-gradle-plugin:1.2.1")
implementation("ca.uhn.hapi.fhir:hapi-fhir-structures-r4:6.0.1")
- implementation("com.squareup:kotlinpoet:1.9.0")
- implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.6.21")
+ implementation("com.squareup:kotlinpoet:1.12.0")
}
diff --git a/buildSrc/src/main/kotlin/Dependencies.kt b/buildSrc/src/main/kotlin/Dependencies.kt
index 2dd72a3208..303598f1ff 100644
--- a/buildSrc/src/main/kotlin/Dependencies.kt
+++ b/buildSrc/src/main/kotlin/Dependencies.kt
@@ -1,5 +1,5 @@
/*
- * Copyright 2022 Google LLC
+ * Copyright 2023 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -107,11 +107,11 @@ object Dependencies {
}
object Jackson {
- const val mainGroup = "com.fasterxml.jackson"
- const val coreGroup = "$mainGroup.core"
- const val dataformatGroup = "$mainGroup.dataformat"
- const val datatypeGroup = "$mainGroup.datatype"
- const val moduleGroup = "$mainGroup.module"
+ private const val mainGroup = "com.fasterxml.jackson"
+ private const val coreGroup = "$mainGroup.core"
+ private const val dataformatGroup = "$mainGroup.dataformat"
+ private const val datatypeGroup = "$mainGroup.datatype"
+ private const val moduleGroup = "$mainGroup.module"
const val annotations = "$coreGroup:jackson-annotations:${Versions.jackson}"
const val bom = "$mainGroup:jackson-bom:${Versions.jackson}"
@@ -168,22 +168,22 @@ object Dependencies {
}
const val androidFhirGroup = "com.google.android.fhir"
- const val androidFhirCommon = "$androidFhirGroup:common:${Versions.androidFhirCommon}"
const val androidFhirEngineModule = "engine"
+ const val androidFhirKnowledgeModule = "knowledge"
+ const val androidFhirCommon = "$androidFhirGroup:common:${Versions.androidFhirCommon}"
const val androidFhirEngine =
"$androidFhirGroup:$androidFhirEngineModule:${Versions.androidFhirEngine}"
+ const val androidFhirKnowledge = "$androidFhirGroup:knowledge:${Versions.androidFhirKnowledge}"
- const val lifecycleExtensions =
- "androidx.lifecycle:lifecycle-extensions:${Versions.Androidx.lifecycle}"
const val desugarJdkLibs = "com.android.tools:desugar_jdk_libs:${Versions.desugarJdkLibs}"
const val fhirUcum = "org.fhir:ucum:${Versions.fhirUcum}"
+ const val gson = "com.google.code.gson:gson:${Versions.gson}"
const val guava = "com.google.guava:guava:${Versions.guava}"
const val httpInterceptor = "com.squareup.okhttp3:logging-interceptor:${Versions.http}"
const val http = "com.squareup.okhttp3:okhttp:${Versions.http}"
const val mockWebServer = "com.squareup.okhttp3:mockwebserver:${Versions.http}"
const val jsonToolsPatch = "com.github.java-json-tools:json-patch:${Versions.jsonToolsPatch}"
- const val kotlinPoet = "com.squareup:kotlinpoet:${Versions.kotlinPoet}"
const val material = "com.google.android.material:material:${Versions.material}"
const val sqlcipher = "net.zetetic:android-database-sqlcipher:${Versions.sqlcipher}"
const val timber = "com.jakewharton.timber:timber:${Versions.timber}"
@@ -213,35 +213,35 @@ object Dependencies {
const val androidBenchmarkRunner = "androidx.benchmark.junit4.AndroidBenchmarkRunner"
const val androidJunitRunner = "androidx.test.runner.AndroidJUnitRunner"
+
// Makes Json assertions where the order of elements, tabs/whitespaces are not important.
const val jsonAssert = "org.skyscreamer:jsonassert:${Versions.jsonAssert}"
const val junit = "junit:junit:${Versions.junit}"
const val mockitoKotlin = "org.mockito.kotlin:mockito-kotlin:${Versions.mockitoKotlin}"
const val mockitoInline = "org.mockito:mockito-inline:${Versions.mockitoInline}"
const val robolectric = "org.robolectric:robolectric:${Versions.robolectric}"
- const val slf4j = "org.slf4j:slf4j-android:${Versions.slf4j}"
const val truth = "com.google.truth:truth:${Versions.truth}"
+
// Makes XML assertions where the order of elements, tabs/whitespaces are not important.
const val xmlUnit = "org.xmlunit:xmlunit-core:${Versions.xmlUnit}"
object Versions {
object Androidx {
- const val activity = "1.2.1"
- const val appCompat = "1.1.0"
- const val constraintLayout = "2.1.1"
- const val coreKtx = "1.2.0"
+ const val activity = "1.7.2"
+ const val appCompat = "1.6.1"
+ const val constraintLayout = "2.1.4"
+ const val coreKtx = "1.10.1"
const val datastorePref = "1.0.0"
- const val fragmentKtx = "1.3.1"
- const val lifecycle = "2.2.0"
- const val navigation = "2.3.4"
- const val recyclerView = "1.1.0"
- const val room = "2.4.2"
- const val sqliteKtx = "2.1.0"
- const val workRuntimeKtx = "2.7.1"
+ const val fragmentKtx = "1.6.0"
+ const val lifecycle = "2.6.1"
+ const val navigation = "2.6.0"
+ const val recyclerView = "1.3.0"
+ const val room = "2.5.2"
+ const val sqliteKtx = "2.3.1"
+ const val workRuntimeKtx = "2.8.1"
}
object Cql {
- const val antlr = "4.10.1"
const val engine = "2.4.0"
const val evaluator = "2.4.0"
const val translator = "2.4.0"
@@ -252,58 +252,60 @@ object Dependencies {
}
object Kotlin {
- const val kotlinCoroutinesCore = "1.6.4"
- const val stdlib = "1.6.10"
+ const val kotlinCoroutinesCore = "1.7.2"
+ const val stdlib = "1.8.20"
}
- const val androidFhirCommon = "0.1.0-alpha03"
- const val androidFhirEngine = "0.1.0-beta02"
- const val desugarJdkLibs = "1.1.5"
+ const val androidFhirCommon = "0.1.0-alpha04"
+ const val androidFhirEngine = "0.1.0-beta03"
+ const val androidFhirKnowledge = "0.1.0-alpha01"
+ const val desugarJdkLibs = "2.0.3"
const val caffeine = "2.9.1"
const val fhirUcum = "1.0.3"
+ const val gson = "2.9.1"
const val guava = "28.2-android"
// Hapi FHIR and HL7 Core Components are interlinked.
// Newer versions of HapiFhir don't work on Android due to the use of Caffeine 3+
// Wait for this to release (6.3): https://github.com/hapifhir/hapi-fhir/pull/4196
const val hapiFhir = "6.0.1"
+
// Newer versions don't work on Android due to Apache Commons Codec:
// Wait for this fix: https://github.com/hapifhir/org.hl7.fhir.core/issues/1046
const val hapiFhirCore = "5.6.36"
- const val http = "4.9.1"
- const val jackson = "2.14.1"
+ const val http = "4.11.0"
+ // Maximum version that supports Android API Level 24:
+ // https://github.com/FasterXML/jackson-databind/issues/3658
+ const val jackson = "2.13.5"
const val jsonToolsPatch = "1.13"
const val jsonAssert = "1.5.1"
- const val kotlinPoet = "1.9.0"
- const val material = "1.6.0"
- const val retrofit = "2.7.2"
- const val slf4j = "1.7.36"
- const val sqlcipher = "4.5.0"
+ const val material = "1.9.0"
+ const val retrofit = "2.9.0"
+ const val sqlcipher = "4.5.4"
const val timber = "5.0.1"
- const val truth = "1.1.3"
- const val woodstox = "6.2.7"
+ const val truth = "1.1.5"
+ const val woodstox = "6.5.1"
const val xerces = "2.12.2"
- const val xmlUnit = "2.9.0"
+ const val xmlUnit = "2.9.1"
// Test dependencies
-
object AndroidxTest {
const val benchmarkJUnit = "1.1.1"
- const val core = "1.4.0"
- const val archCore = "2.1.0"
+ const val core = "1.5.0"
+ const val archCore = "2.2.0"
const val extJunit = "1.1.5"
- const val rules = "1.4.0"
- const val runner = "1.5.2"
- const val fragmentVersion = "1.3.6"
+ const val rules = "1.5.0"
+ const val runner = "1.5.0"
+ const val fragmentVersion = "1.6.0"
}
- const val espresso = "3.4.0"
- const val jacoco = "0.8.7"
+ const val espresso = "3.5.1"
+ const val jacoco = "0.8.10"
const val junit = "4.13.2"
const val mockitoKotlin = "3.2.0"
const val mockitoInline = "4.0.0"
- const val robolectric = "4.7.3"
+ const val robolectric = "4.10.3"
object Mlkit {
const val barcodeScanning = "16.1.1"
@@ -324,7 +326,6 @@ object Dependencies {
fun Configuration.forceHapiVersion() {
// Removes newer versions of caffeine and manually imports 2.9
// Removes newer versions of hapi and keeps on 6.0.1
- // Removes newer versions of HL7 core and keeps it on 5.6.36
// (newer versions don't work on Android)
resolutionStrategy {
force(HapiFhir.caffeine)
@@ -352,4 +353,16 @@ object Dependencies {
force(HapiFhir.validationR5)
}
}
+
+ fun Configuration.forceJacksonVersion() {
+ resolutionStrategy {
+ force(Jackson.annotations)
+ force(Jackson.bom)
+ force(Jackson.core)
+ force(Jackson.databind)
+ force(Jackson.jaxbAnnotations)
+ force(Jackson.jsr310)
+ force(Jackson.dataformatXml)
+ }
+ }
}
diff --git a/buildSrc/src/main/kotlin/FirebaseTestLabConfig.kt b/buildSrc/src/main/kotlin/FirebaseTestLabConfig.kt
index e05e12cecc..50d6a1ffa7 100644
--- a/buildSrc/src/main/kotlin/FirebaseTestLabConfig.kt
+++ b/buildSrc/src/main/kotlin/FirebaseTestLabConfig.kt
@@ -1,5 +1,5 @@
/*
- * Copyright 2022 Google LLC
+ * Copyright 2023 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -21,14 +21,17 @@ import org.gradle.api.Project
import org.gradle.kotlin.dsl.apply
import org.gradle.kotlin.dsl.configure
-@Suppress("SdCardPath")
-fun Project.configureFirebaseTestLab() {
+fun Project.configureFirebaseTestLabForLibraries() {
apply(plugin = Plugins.BuildPlugins.fladle)
configure {
- commonConfigurationForFirebaseTestLab(this@configureFirebaseTestLab)
+ commonConfigurationForFirebaseTestLab(this@configureFirebaseTestLabForLibraries)
instrumentationApk.set(project.provider { "$buildDir/outputs/apk/androidTest/debug/*.apk" })
environmentVariables.set(
- mapOf("coverage" to "true", "coverageFile" to "/sdcard/Download/coverage.ec")
+ mapOf(
+ "coverage" to "true",
+ "coverageFilePath" to "/sdcard/Download/",
+ "clearPackageData" to "true"
+ )
)
flakyTestAttempts.set(3)
devices.set(
@@ -39,11 +42,9 @@ fun Project.configureFirebaseTestLab() {
"${project.extensions.getByType(LibraryExtension::class.java).defaultConfig.minSdk}",
"locale" to "en_US"
),
- mapOf("model" to "Nexus6P", "version" to "27", "locale" to "en_US"),
mapOf(
- "model" to "oriole",
- "version" to
- "${project.extensions.getByType(LibraryExtension::class.java).defaultConfig.targetSdk}",
+ "model" to "panther",
+ "version" to "${project.extensions.getByType(LibraryExtension::class.java).compileSdk}",
"locale" to "en_US"
),
)
@@ -57,14 +58,21 @@ fun Project.configureFirebaseTestLabForMicroBenchmark() {
commonConfigurationForFirebaseTestLab(this@configureFirebaseTestLabForMicroBenchmark)
instrumentationApk.set(project.provider { "$buildDir/outputs/apk/androidTest/release/*.apk" })
environmentVariables.set(
- mapOf("additionalTestOutputDir" to "/sdcard/Download", "no-isolated-storage" to "true")
+ mapOf(
+ "additionalTestOutputDir" to "/sdcard/Download",
+ "no-isolated-storage" to "true",
+ "clearPackageData" to "true"
+ )
)
- maxTestShards.set(4)
// some of the benchmark tests get timed-out in the default 15m
- testTimeout.set("30m")
+ testTimeout.set("45m")
devices.set(
listOf(
- mapOf("model" to "oriole", "version" to "32", "locale" to "en_US"),
+ mapOf(
+ "model" to "panther",
+ "version" to "${project.extensions.getByType(LibraryExtension::class.java).compileSdk}",
+ "locale" to "en_US"
+ ),
)
)
}
@@ -77,9 +85,9 @@ private fun FlankGradleExtension.commonConfigurationForFirebaseTestLab(project:
"${project.rootDir}/demo/build/outputs/apk/androidTest/debug/demo-debug-androidTest.apk"
}
)
- useOrchestrator.set(false)
+ useOrchestrator.set(true)
+ maxTestShards.set(20)
directoriesToPull.set(listOf("/sdcard/Download"))
- filesToDownload.set(listOf(".*/sdcard/Download/.*.ec", ".*/sdcard/Download/.*.json"))
resultsBucket.set("android-fhir-build-artifacts")
resultsDir.set(
if (project.providers.environmentVariable("KOKORO_BUILD_ARTIFACTS_SUBDIR").isPresent) {
diff --git a/buildSrc/src/main/kotlin/JacocoConfig.kt b/buildSrc/src/main/kotlin/JacocoConfig.kt
index 42253d764f..cfc37a412f 100644
--- a/buildSrc/src/main/kotlin/JacocoConfig.kt
+++ b/buildSrc/src/main/kotlin/JacocoConfig.kt
@@ -1,5 +1,5 @@
/*
- * Copyright 2022 Google LLC
+ * Copyright 2023 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -48,7 +48,6 @@ fun Project.createJacocoTestReportTask() {
dependsOn(
setOf(
"testDebugUnitTest", // Generates unit test coverage report
- "createDebugCoverageReport", // Generates instrumentation test coverage report
)
)
reports {
@@ -100,7 +99,7 @@ fun Project.createJacocoTestReportTask() {
/** Configures jacoco test options in the `LibraryExtension`. */
fun LibraryExtension.configureJacocoTestOptions() {
- buildTypes { getByName("debug") { isTestCoverageEnabled = true } }
+ buildTypes { getByName("debug") { enableUnitTestCoverage = true } }
testOptions {
unitTests.isIncludeAndroidResources = true
unitTests.isReturnDefaultValues = true
diff --git a/buildSrc/src/main/kotlin/Java.kt b/buildSrc/src/main/kotlin/Java.kt
index f1b4ab0806..3759cfefa0 100644
--- a/buildSrc/src/main/kotlin/Java.kt
+++ b/buildSrc/src/main/kotlin/Java.kt
@@ -1,5 +1,5 @@
/*
- * Copyright 2022 Google LLC
+ * Copyright 2023 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -32,8 +32,7 @@ import org.gradle.api.JavaVersion
* limitations under the License.
*/
-object Java {
- val sourceCompatibility = JavaVersion.VERSION_1_8
- val targetCompatibility = JavaVersion.VERSION_1_8
- val kotlinJvmTarget = JavaVersion.VERSION_1_8
-}
+/* Delete this file, and the sourceCompatibility and targetCompatibility blocks when we upgrade the
+AGP to 8.1. See: https://kotlinlang.org/docs/gradle-configure-project.html#gradle-java-toolchains-support
+ */
+val javaVersion = JavaVersion.VERSION_11
diff --git a/buildSrc/src/main/kotlin/LicenseeConfig.kt b/buildSrc/src/main/kotlin/LicenseeConfig.kt
index b7901eceaa..df1acc08bb 100644
--- a/buildSrc/src/main/kotlin/LicenseeConfig.kt
+++ b/buildSrc/src/main/kotlin/LicenseeConfig.kt
@@ -1,5 +1,5 @@
/*
- * Copyright 2022 Google LLC
+ * Copyright 2023 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -59,6 +59,9 @@ fun Project.configureLicensee() {
allowDependency("net.zetetic", "android-database-sqlcipher", "4.5.0") {
because("Custom license, essentially BSD-3. https://www.zetetic.net/sqlcipher/license/")
}
+ allowDependency("net.zetetic", "android-database-sqlcipher", "4.5.4") {
+ because("Custom license, essentially BSD-3. https://www.zetetic.net/sqlcipher/license/")
+ }
// Jakarta XML Binding API
allowDependency("jakarta.xml.bind", "jakarta.xml.bind-api", "2.3.3") {
diff --git a/buildSrc/src/main/kotlin/Plugins.kt b/buildSrc/src/main/kotlin/Plugins.kt
index ded8fb0943..3a7a565213 100644
--- a/buildSrc/src/main/kotlin/Plugins.kt
+++ b/buildSrc/src/main/kotlin/Plugins.kt
@@ -1,5 +1,5 @@
/*
- * Copyright 2022 Google LLC
+ * Copyright 2023 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -44,9 +44,8 @@ object Plugins {
const val flankGradlePlugin = "com.osacky.flank.gradle:fladle:0.17.4"
object Versions {
- const val androidGradlePlugin = "7.2.1"
+ const val androidGradlePlugin = "8.0.2"
const val benchmarkPlugin = "1.1.0"
- // Change dokka to 1.7.20 once androidGradlePlugin upgrades to 7.3+
- const val dokka = "1.6.10"
+ const val dokka = "1.7.20"
}
}
diff --git a/buildSrc/src/main/kotlin/Releases.kt b/buildSrc/src/main/kotlin/Releases.kt
index ed812e8e26..1d49065c4e 100644
--- a/buildSrc/src/main/kotlin/Releases.kt
+++ b/buildSrc/src/main/kotlin/Releases.kt
@@ -1,5 +1,5 @@
/*
- * Copyright 2022 Google LLC
+ * Copyright 2023 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -18,10 +18,7 @@ import org.gradle.api.Project
import org.gradle.api.artifacts.repositories.PasswordCredentials
import org.gradle.api.publish.PublishingExtension
import org.gradle.api.publish.maven.MavenPublication
-import org.gradle.api.tasks.bundling.Jar
import org.gradle.kotlin.dsl.configure
-import org.gradle.kotlin.dsl.create
-import org.gradle.kotlin.dsl.credentials
import org.gradle.kotlin.dsl.get
import org.gradle.kotlin.dsl.getByType
import org.gradle.kotlin.dsl.register
@@ -41,10 +38,12 @@ object Releases {
const val groupId = "org.smartregister"
// Libraries
+ // After releasing a new version of a library, you will need to bump up the library version
+ // in Dependencies.kt (in a separate PR)
object Common : LibraryArtifact {
override val artifactId = "common"
- override val version = "0.1.0-alpha03-preview4-SNAPSHOT"
+ override val version = "0.1.0-alpha04"
override val name = "Android FHIR Common Library"
}
@@ -62,7 +61,7 @@ object Releases {
object Workflow : LibraryArtifact {
override val artifactId = "workflow"
- override val version = "0.1.0-alpha02-preview10-SNAPSHOT"
+ override val version = "0.1.0-alpha03"
override val name = "Android FHIR Workflow Library"
}
@@ -96,24 +95,18 @@ object Releases {
}
fun Project.publishArtifact(artifact: LibraryArtifact) {
+ val variantToPublish = "release"
+ project.extensions
+ .getByType()
+ .publishing.singleVariant(variantToPublish) { withSourcesJar() }
afterEvaluate {
configure {
publications {
- register("release", MavenPublication::class) {
- from(components["release"])
+ register(variantToPublish) {
groupId = Releases.groupId
artifactId = artifact.artifactId
version = artifact.version
- // Also publish source code for developers' convenience
- artifact(
- tasks.create("androidSourcesJar") {
- archiveClassifier.set("sources")
-
- val android =
- project.extensions.getByType()
- from(android.sourceSets.getByName("main").java.srcDirs)
- }
- )
+ from(components[variantToPublish])
pom {
name.set(artifact.name)
licenses {
diff --git a/buildSrc/src/main/kotlin/Sdk.kt b/buildSrc/src/main/kotlin/Sdk.kt
index adaf6e9e67..5f4636329e 100644
--- a/buildSrc/src/main/kotlin/Sdk.kt
+++ b/buildSrc/src/main/kotlin/Sdk.kt
@@ -1,5 +1,5 @@
/*
- * Copyright 2022 Google LLC
+ * Copyright 2023 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -15,13 +15,10 @@
*/
object Sdk {
- const val compileSdk = 31
+ const val compileSdk = 33
const val targetSdk = 31
// Engine and SDC must support API 24.
// Remove desugaring when upgrading it to 26.
const val minSdk = 24
-
- // Workflow requires minSDK 26
- const val minSdkWorkflow = 26
}
diff --git a/buildSrc/src/main/kotlin/SpotlessConfig.kt b/buildSrc/src/main/kotlin/SpotlessConfig.kt
index 22e7482c42..6f699f22bc 100644
--- a/buildSrc/src/main/kotlin/SpotlessConfig.kt
+++ b/buildSrc/src/main/kotlin/SpotlessConfig.kt
@@ -1,5 +1,5 @@
/*
- * Copyright 2022 Google LLC
+ * Copyright 2022-2023 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -36,6 +36,7 @@ fun Project.configureSpotless() {
// It is necessary to tell spotless the top level of a file in order to apply config to it
// See: https://github.com/diffplug/spotless/issues/135
)
+ toggleOffOn()
}
kotlinGradle {
target("*.gradle.kts")
diff --git a/buildSrc/src/main/kotlin/codegen/SearchParameterRepositoryGenerator.kt b/buildSrc/src/main/kotlin/codegen/SearchParameterRepositoryGenerator.kt
index 6b9fd6b594..cff6ec7086 100644
--- a/buildSrc/src/main/kotlin/codegen/SearchParameterRepositoryGenerator.kt
+++ b/buildSrc/src/main/kotlin/codegen/SearchParameterRepositoryGenerator.kt
@@ -1,5 +1,5 @@
/*
- * Copyright 2022 Google LLC
+ * Copyright 2023 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -21,6 +21,7 @@ import com.squareup.kotlinpoet.CodeBlock
import com.squareup.kotlinpoet.FileSpec
import com.squareup.kotlinpoet.FunSpec
import com.squareup.kotlinpoet.KModifier
+import com.squareup.kotlinpoet.ParameterSpec
import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy
import java.io.File
import java.util.Locale
@@ -41,8 +42,10 @@ import org.hl7.fhir.r4.model.SearchParameter
* should be regenerated to reflect any change.
*
* To do this, replace the content of the file `codegen/src/main/res/search-parameters.json` with
- * the content at `http://www.hl7.org/fhir/search-parameters.json` and run the `main` function in
- * the `codegen` module.
+ * the content at `http://www.hl7.org/fhir/search-parameters.json` and execute the gradle task
+ * `generateSearchParamsTask`. If you are using Android Studio, you can usually find this task in
+ * the Gradle tasks under other. Alternatively, you clean and rebuild the project to ensure changes
+ * take effect.
*/
internal data class SearchParamDefinition(
val className: ClassName,
@@ -63,6 +66,7 @@ internal object SearchParameterRepositoryGenerator {
private val searchParamMap: HashMap> = HashMap()
private val searchParamDefinitionClass = ClassName(indexPackage, "SearchParamDefinition")
+ private val baseResourceSearchParameters = mutableListOf()
fun generate(bundle: Bundle, outputPath: File, testOutputPath: File) {
for (entry in bundle.entry) {
@@ -81,6 +85,15 @@ internal object SearchParameterRepositoryGenerator {
path = path.value
)
)
+ if (hashMapKey == "Resource")
+ baseResourceSearchParameters.add(
+ SearchParamDefinition(
+ className = searchParamDefinitionClass,
+ name = searchParameter.name,
+ paramTypeCode = searchParameter.type.toCode().toUpperCase(Locale.US),
+ path = path.value
+ )
+ )
}
}
@@ -94,8 +107,33 @@ internal object SearchParameterRepositoryGenerator {
)
.addModifiers(KModifier.INTERNAL)
.addKdoc(generatedComment)
- .beginControlFlow("return when (resource.fhirType())")
-
+ .beginControlFlow("val resourceSearchParams = when (resource.fhirType())")
+
+ // Function for base resource search parameters
+ val baseParamResourceSpecName = ParameterSpec.builder("resourceName", String::class).build()
+ val getBaseResourceSearchParamListFunction =
+ FunSpec.builder("getBaseResourceSearchParamsList")
+ .addParameter(baseParamResourceSpecName)
+ .apply {
+ addModifiers(KModifier.PRIVATE)
+ returns(
+ ClassName("kotlin.collections", "List").parameterizedBy(searchParamDefinitionClass)
+ )
+ beginControlFlow("return buildList(capacity = %L)", baseResourceSearchParameters.size)
+ baseResourceSearchParameters.forEach { definition ->
+ addStatement(
+ "add(%T(%S, %T.%L, %P))",
+ definition.className,
+ definition.name,
+ Enumerations.SearchParamType::class,
+ definition.paramTypeCode,
+ "$" + "${baseParamResourceSpecName.name}." + definition.path.substringAfter(".")
+ )
+ }
+ endControlFlow() // end buildList
+ }
+ .build()
+ fileSpec.addFunction(getBaseResourceSearchParamListFunction)
// Helper function used in SearchParameterRepositoryGeneratedTest
val testHelperFunctionCodeBlock =
CodeBlock.builder().addStatement("val resourceList = listOf<%T>(", Resource::class.java)
@@ -140,6 +178,11 @@ internal object SearchParameterRepositoryGenerator {
}
getSearchParamListFunction.addStatement("else -> emptyList()").endControlFlow()
+ // This will now return the list of search parameter for the resource + search parameters
+ // defined in base resource i.e. _profile, _tag, _id, _security, _lastUpdated, _source
+ getSearchParamListFunction.addStatement(
+ "return resourceSearchParams + getBaseResourceSearchParamsList(resource.fhirType())"
+ )
fileSpec.addFunction(getSearchParamListFunction.build()).build().writeTo(outputPath)
testHelperFunctionCodeBlock.add(")\n")
@@ -175,8 +218,7 @@ internal object SearchParameterRepositoryGenerator {
return if (searchParam.base.size == 1) {
mapOf(searchParam.base.single().valueAsString to searchParam.expression)
} else {
- searchParam
- .expression
+ searchParam.expression
.split("|")
.groupBy { splitString -> splitString.split(".").first().trim().removePrefix("(") }
.mapValues { it.value.joinToString(" | ") { join -> join.trim() } }
diff --git a/catalog/build.gradle.kts b/catalog/build.gradle.kts
index d103159702..19cfc139ad 100644
--- a/catalog/build.gradle.kts
+++ b/catalog/build.gradle.kts
@@ -7,22 +7,21 @@ plugins {
configureRuler()
android {
+ namespace = "com.google.android.fhir.catalog"
compileSdk = Sdk.compileSdk
-
defaultConfig {
applicationId = Releases.Catalog.applicationId
minSdk = Sdk.minSdk
targetSdk = Sdk.targetSdk
versionCode = Releases.Catalog.versionCode
versionName = Releases.Catalog.versionName
-
testInstrumentationRunner = Dependencies.androidJunitRunner
}
buildFeatures { viewBinding = true }
buildTypes {
- getByName("release") {
+ release {
isMinifyEnabled = false
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"))
}
@@ -31,18 +30,16 @@ android {
// Flag to enable support for the new language APIs
// See https://developer.android.com/studio/write/java8-support
isCoreLibraryDesugaringEnabled = true
-
- sourceCompatibility = Java.sourceCompatibility
- targetCompatibility = Java.targetCompatibility
+ sourceCompatibility = javaVersion
+ targetCompatibility = javaVersion
}
- packagingOptions {
+ packaging {
resources.excludes.addAll(
listOf("META-INF/ASL2.0", "META-INF/ASL-2.0.txt", "META-INF/LGPL-3.0.txt")
)
}
-
- kotlinOptions { jvmTarget = Java.kotlinJvmTarget.toString() }
+ kotlin { jvmToolchain(11) }
}
dependencies {
diff --git a/catalog/src/main/AndroidManifest.xml b/catalog/src/main/AndroidManifest.xml
index 81c03488a7..342b41fba5 100644
--- a/catalog/src/main/AndroidManifest.xml
+++ b/catalog/src/main/AndroidManifest.xml
@@ -14,11 +14,7 @@
See the License for the specific language governing permissions and
limitations under the License.
-->
-
+
diff --git a/catalog/src/main/assets/component_quantity.json b/catalog/src/main/assets/component_quantity.json
new file mode 100644
index 0000000000..93f5d1b682
--- /dev/null
+++ b/catalog/src/main/assets/component_quantity.json
@@ -0,0 +1,28 @@
+{
+ "resourceType": "Questionnaire",
+ "item": [
+ {
+ "linkId": "1",
+ "text": "Enter length",
+ "type": "quantity",
+ "extension": [
+ {
+ "url": "http://hl7.org/fhir/StructureDefinition/questionnaire-unitOption",
+ "valueCoding": {
+ "system": "http://unitsofmeasure.org",
+ "code": "cm",
+ "display": "centimeter"
+ }
+ },
+ {
+ "url": "http://hl7.org/fhir/StructureDefinition/questionnaire-unitOption",
+ "valueCoding": {
+ "system": "http://unitsofmeasure.org",
+ "code": "[in_i]",
+ "display": "inch"
+ }
+ }
+ ]
+ }
+ ]
+}
\ No newline at end of file
diff --git a/catalog/src/main/assets/component_quantity_with_validation.json b/catalog/src/main/assets/component_quantity_with_validation.json
new file mode 100644
index 0000000000..39760c474c
--- /dev/null
+++ b/catalog/src/main/assets/component_quantity_with_validation.json
@@ -0,0 +1,29 @@
+{
+ "resourceType": "Questionnaire",
+ "item": [
+ {
+ "linkId": "1",
+ "text": "Enter length",
+ "type": "quantity",
+ "required": true,
+ "extension": [
+ {
+ "url": "http://hl7.org/fhir/StructureDefinition/questionnaire-unitOption",
+ "valueCoding": {
+ "system": "http://unitsofmeasure.org",
+ "code": "cm",
+ "display": "centimeter"
+ }
+ },
+ {
+ "url": "http://hl7.org/fhir/StructureDefinition/questionnaire-unitOption",
+ "valueCoding": {
+ "system": "http://unitsofmeasure.org",
+ "code": "[in_i]",
+ "display": "inch"
+ }
+ }
+ ]
+ }
+ ]
+}
\ No newline at end of file
diff --git a/catalog/src/main/assets/component_text_fields.json b/catalog/src/main/assets/component_text_fields.json
index 6c1f527842..60420e61b8 100644
--- a/catalog/src/main/assets/component_text_fields.json
+++ b/catalog/src/main/assets/component_text_fields.json
@@ -2,13 +2,12 @@
"resourceType": "Questionnaire",
"item": [
{
- "linkId": "1.1",
- "text": "Register new patient",
+ "linkId": "1",
"type": "string",
"item": [
{
- "linkId": "1-first-name",
- "text": "First Name",
+ "linkId": "1.1",
+ "text": "Enter a string",
"type": "display",
"extension": [
{
@@ -29,12 +28,12 @@
]
},
{
- "linkId": "2.2",
- "type": "string",
+ "linkId": "2",
+ "type": "integer",
"item": [
{
- "linkId": "1-family-name",
- "text": "Family Name",
+ "linkId": "2.1",
+ "text": "Enter an integer",
"type": "display",
"extension": [
{
@@ -55,12 +54,48 @@
]
},
{
- "linkId": "3.3",
+ "linkId": "3",
+ "type": "decimal",
+ "item": [
+ {
+ "linkId": "3.1",
+ "text": "Enter a decimal",
+ "type": "display",
+ "extension": [
+ {
+ "url": "http://hl7.org/fhir/StructureDefinition/questionnaire-itemControl",
+ "valueCodeableConcept": {
+ "coding": [
+ {
+ "system": "http://hl7.org/fhir/questionnaire-item-control",
+ "code": "flyover",
+ "display": "Fly-over"
+ }
+ ],
+ "text": "Flyover"
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "linkId": "4",
"type": "integer",
+ "extension": [
+ {
+ "url": "http://hl7.org/fhir/StructureDefinition/questionnaire-unit",
+ "valueCoding": {
+ "system": "http://unitsofmeasure.org",
+ "code": "kg",
+ "display": "kilogram"
+ }
+ }
+ ],
"item": [
{
- "linkId": "1-id-number",
- "text": "ID number",
+ "linkId": "4.1",
+ "text": "Enter an integer (with unit)",
"type": "display",
"extension": [
{
@@ -81,12 +116,22 @@
]
},
{
- "linkId": "4.4",
+ "linkId": "5",
"type": "decimal",
+ "extension": [
+ {
+ "url": "http://hl7.org/fhir/StructureDefinition/questionnaire-unit",
+ "valueCoding": {
+ "system": "http://unitsofmeasure.org",
+ "code": "kg",
+ "display": "kilogram"
+ }
+ }
+ ],
"item": [
{
- "linkId": "1-mobile-number",
- "text": "Mobile number",
+ "linkId": "5.1",
+ "text": "Enter a decimal (with unit)",
"type": "display",
"extension": [
{
diff --git a/catalog/src/main/assets/component_text_fields_with_validation.json b/catalog/src/main/assets/component_text_fields_with_validation.json
index 31901c2f0f..3bacc243b5 100644
--- a/catalog/src/main/assets/component_text_fields_with_validation.json
+++ b/catalog/src/main/assets/component_text_fields_with_validation.json
@@ -2,14 +2,13 @@
"resourceType": "Questionnaire",
"item": [
{
- "linkId": "1.1",
- "text": "Register new patient",
+ "linkId": "1",
"type": "string",
"required": true,
"item": [
{
- "linkId": "1-first-name",
- "text": "First Name",
+ "linkId": "1.1",
+ "text": "Enter a string",
"type": "display",
"extension": [
{
@@ -30,13 +29,40 @@
]
},
{
- "linkId": "2.2",
- "type": "string",
+ "linkId": "2",
+ "type": "integer",
+ "required": true,
+ "item": [
+ {
+ "linkId": "2.1",
+ "text": "Enter an integer",
+ "type": "display",
+ "extension": [
+ {
+ "url": "http://hl7.org/fhir/StructureDefinition/questionnaire-itemControl",
+ "valueCodeableConcept": {
+ "coding": [
+ {
+ "system": "http://hl7.org/fhir/questionnaire-item-control",
+ "code": "flyover",
+ "display": "Fly-over"
+ }
+ ],
+ "text": "Flyover"
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "linkId": "3",
+ "type": "decimal",
"required": true,
"item": [
{
- "linkId": "1-family-name",
- "text": "Family Name",
+ "linkId": "3.1",
+ "text": "Enter a decimal",
"type": "display",
"extension": [
{
@@ -57,13 +83,23 @@
]
},
{
- "linkId": "3.3",
+ "linkId": "4",
"type": "integer",
"required": true,
+ "extension": [
+ {
+ "url": "http://hl7.org/fhir/StructureDefinition/questionnaire-unit",
+ "valueCoding": {
+ "system": "http://unitsofmeasure.org",
+ "code": "kg",
+ "display": "kilogram"
+ }
+ }
+ ],
"item": [
{
- "linkId": "1-id-number",
- "text": "ID number",
+ "linkId": "4.1",
+ "text": "Enter an integer (with unit)",
"type": "display",
"extension": [
{
@@ -84,13 +120,23 @@
]
},
{
- "linkId": "4.4",
+ "linkId": "5",
"type": "decimal",
"required": true,
+ "extension": [
+ {
+ "url": "http://hl7.org/fhir/StructureDefinition/questionnaire-unit",
+ "valueCoding": {
+ "system": "http://unitsofmeasure.org",
+ "code": "kg",
+ "display": "kilogram"
+ }
+ }
+ ],
"item": [
{
- "linkId": "1-mobile-number",
- "text": "Mobile number",
+ "linkId": "5.1",
+ "text": "Enter a decimal (with unit)",
"type": "display",
"extension": [
{
diff --git a/catalog/src/main/java/com/google/android/fhir/catalog/BehaviorListFragment.kt b/catalog/src/main/java/com/google/android/fhir/catalog/BehaviorListFragment.kt
index bf1d6abe03..3706ad0f38 100644
--- a/catalog/src/main/java/com/google/android/fhir/catalog/BehaviorListFragment.kt
+++ b/catalog/src/main/java/com/google/android/fhir/catalog/BehaviorListFragment.kt
@@ -1,5 +1,5 @@
/*
- * Copyright 2022 Google LLC
+ * Copyright 2022-2023 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -21,15 +21,18 @@ import android.view.Gravity
import android.view.View
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
+import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.findNavController
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView
+import kotlinx.coroutines.launch
class BehaviorListFragment : Fragment(R.layout.behavior_list_fragment) {
private val viewModel: BehaviorListViewModel by viewModels()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
setUpBehaviorsRecyclerView()
+ (activity as? MainActivity)?.showOpenQuestionnaireMenu(true)
}
override fun onResume() {
@@ -63,14 +66,20 @@ class BehaviorListFragment : Fragment(R.layout.behavior_list_fragment) {
}
private fun launchQuestionnaireFragment(behavior: BehaviorListViewModel.Behavior) {
- findNavController()
- .navigate(
- BehaviorListFragmentDirections.actionBehaviorsFragmentToGalleryQuestionnaireFragment(
- context?.getString(behavior.textId) ?: "",
- behavior.questionnaireFileName,
- null,
- behavior.workFlow
+ viewLifecycleOwner.lifecycleScope.launch {
+ findNavController()
+ .navigate(
+ MainNavGraphDirections.actionGlobalGalleryQuestionnaireFragment(
+ questionnaireTitleKey = context?.getString(behavior.textId) ?: "",
+ questionnaireJsonStringKey =
+ getQuestionnaireJsonStringFromAssets(
+ context = requireContext(),
+ backgroundContext = coroutineContext,
+ fileName = behavior.questionnaireFileName,
+ ),
+ workflow = behavior.workFlow
+ )
)
- )
+ }
}
}
diff --git a/catalog/src/main/java/com/google/android/fhir/catalog/ComponentListFragment.kt b/catalog/src/main/java/com/google/android/fhir/catalog/ComponentListFragment.kt
index b85654c921..e562c23ed2 100644
--- a/catalog/src/main/java/com/google/android/fhir/catalog/ComponentListFragment.kt
+++ b/catalog/src/main/java/com/google/android/fhir/catalog/ComponentListFragment.kt
@@ -1,5 +1,5 @@
/*
- * Copyright 2022 Google LLC
+ * Copyright 2022-2023 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -22,9 +22,11 @@ import android.view.View
import androidx.appcompat.app.AppCompatActivity
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
+import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.findNavController
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView
+import kotlinx.coroutines.launch
/** Fragment for the component list. */
class ComponentListFragment : Fragment(R.layout.component_list_fragment) {
@@ -33,6 +35,7 @@ class ComponentListFragment : Fragment(R.layout.component_list_fragment) {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
setUpComponentsRecyclerView()
+ (activity as? MainActivity)?.showOpenQuestionnaireMenu(true)
}
override fun onResume() {
@@ -84,14 +87,26 @@ class ComponentListFragment : Fragment(R.layout.component_list_fragment) {
}
private fun launchQuestionnaireFragment(component: ComponentListViewModel.Component) {
- findNavController()
- .navigate(
- ComponentListFragmentDirections.actionComponentsFragmentToGalleryQuestionnaireFragment(
- context?.getString(component.textId) ?: "",
- component.questionnaireFile,
- component.questionnaireFileWithValidation,
- component.workflow
+ viewLifecycleOwner.lifecycleScope.launch {
+ findNavController()
+ .navigate(
+ MainNavGraphDirections.actionGlobalGalleryQuestionnaireFragment(
+ questionnaireTitleKey = context?.getString(component.textId) ?: "",
+ questionnaireJsonStringKey =
+ getQuestionnaireJsonStringFromAssets(
+ context = requireContext(),
+ backgroundContext = coroutineContext,
+ fileName = component.questionnaireFile,
+ ),
+ questionnaireWithValidationJsonStringKey =
+ getQuestionnaireJsonStringFromAssets(
+ context = requireContext(),
+ backgroundContext = coroutineContext,
+ fileName = component.questionnaireFileWithValidation,
+ ),
+ workflow = component.workflow
+ )
)
- )
+ }
}
}
diff --git a/catalog/src/main/java/com/google/android/fhir/catalog/ComponentListViewModel.kt b/catalog/src/main/java/com/google/android/fhir/catalog/ComponentListViewModel.kt
index 70411f8338..ad0fc616d4 100644
--- a/catalog/src/main/java/com/google/android/fhir/catalog/ComponentListViewModel.kt
+++ b/catalog/src/main/java/com/google/android/fhir/catalog/ComponentListViewModel.kt
@@ -1,5 +1,5 @@
/*
- * Copyright 2022 Google LLC
+ * Copyright 2023 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -113,6 +113,12 @@ class ComponentListViewModel(application: Application, private val state: SavedS
"component_slider.json",
"component_slider_with_validation.json"
),
+ QUANTITY(
+ R.drawable.ic_unitoptions,
+ R.string.component_name_quantity,
+ "component_quantity.json",
+ "component_quantity_with_validation.json"
+ ),
ATTACHMENT(
R.drawable.ic_attachment,
R.string.component_name_attachment,
@@ -120,7 +126,7 @@ class ComponentListViewModel(application: Application, private val state: SavedS
"component_attachment_with_validation.json"
),
REPEATED_GROUP(
- R.drawable.ic_textfield,
+ R.drawable.ic_repeatgroups,
R.string.component_name_repeated_group,
"component_repeated_group.json",
),
@@ -151,6 +157,7 @@ class ComponentListViewModel(application: Application, private val state: SavedS
ViewItem.ComponentItem(Component.DATE_PICKER),
ViewItem.ComponentItem(Component.DATE_TIME_PICKER),
ViewItem.ComponentItem(Component.SLIDER),
+ ViewItem.ComponentItem(Component.QUANTITY),
ViewItem.ComponentItem(Component.ATTACHMENT),
ViewItem.ComponentItem(Component.REPEATED_GROUP),
ViewItem.HeaderItem(Header.MISC_COMPONENTS),
diff --git a/catalog/src/main/java/com/google/android/fhir/catalog/DemoQuestionnaireFragment.kt b/catalog/src/main/java/com/google/android/fhir/catalog/DemoQuestionnaireFragment.kt
index 37174916d0..590506b669 100644
--- a/catalog/src/main/java/com/google/android/fhir/catalog/DemoQuestionnaireFragment.kt
+++ b/catalog/src/main/java/com/google/android/fhir/catalog/DemoQuestionnaireFragment.kt
@@ -1,5 +1,5 @@
/*
- * Copyright 2022 Google LLC
+ * Copyright 2023 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -85,10 +85,10 @@ class DemoQuestionnaireFragment : Fragment() {
childFragmentManager.setFragmentResultListener(SUBMIT_REQUEST_KEY, viewLifecycleOwner) { _, _ ->
onSubmitQuestionnaireClick()
}
- updateArguments()
if (savedInstanceState == null) {
addQuestionnaireFragment()
}
+ (activity as? MainActivity)?.showOpenQuestionnaireMenu(false)
}
override fun onResume() {
@@ -103,8 +103,7 @@ class DemoQuestionnaireFragment : Fragment() {
NavHostFragment.findNavController(this).navigateUp()
true
}
- // TODO https://github.com/google/android-fhir/issues/1088
- R.id.submit_questionnaire -> {
+ com.google.android.fhir.datacapture.R.id.submit_questionnaire -> {
onSubmitQuestionnaireClick()
true
}
@@ -129,27 +128,16 @@ class DemoQuestionnaireFragment : Fragment() {
setHasOptionsMenu(true)
}
- private fun updateArguments() {
- requireArguments().putString(QUESTIONNAIRE_FILE_PATH_KEY, args.questionnaireFilePathKey)
- requireArguments()
- .putString(
- QUESTIONNAIRE_FILE_WITH_VALIDATION_PATH_KEY,
- args.questionnaireFileWithValidationPathKey
- )
- }
-
private fun addQuestionnaireFragment() {
viewLifecycleOwner.lifecycleScope.launch {
if (childFragmentManager.findFragmentByTag(QUESTIONNAIRE_FRAGMENT_TAG) == null) {
childFragmentManager.commit {
setReorderingAllowed(true)
- add(
- R.id.container,
+ val questionnaireFragment =
QuestionnaireFragment.builder()
- .setQuestionnaire(viewModel.getQuestionnaireJson())
- .build(),
- QUESTIONNAIRE_FRAGMENT_TAG
- )
+ .apply { setQuestionnaire(args.questionnaireJsonStringKey!!) }
+ .build()
+ add(R.id.container, questionnaireFragment, QUESTIONNAIRE_FRAGMENT_TAG)
}
}
}
@@ -162,15 +150,15 @@ class DemoQuestionnaireFragment : Fragment() {
*/
private fun replaceQuestionnaireFragmentWithQuestionnaireJson() {
// TODO: remove check once all files are added
- if (args.questionnaireFileWithValidationPathKey.isNullOrEmpty()) {
+ if (args.questionnaireWithValidationJsonStringKey.isNullOrEmpty()) {
return
}
viewLifecycleOwner.lifecycleScope.launch {
val questionnaireJsonString =
if (isErrorState) {
- viewModel.getQuestionnaireWithValidationJson()
+ args.questionnaireWithValidationJsonStringKey!!
} else {
- viewModel.getQuestionnaireJson()
+ args.questionnaireJsonStringKey!!
}
childFragmentManager.commit {
setReorderingAllowed(true)
@@ -226,9 +214,6 @@ class DemoQuestionnaireFragment : Fragment() {
companion object {
const val QUESTIONNAIRE_FRAGMENT_TAG = "questionnaire-fragment-tag"
- const val QUESTIONNAIRE_FILE_PATH_KEY = "questionnaire-file-path-key"
- const val QUESTIONNAIRE_FILE_WITH_VALIDATION_PATH_KEY =
- "questionnaire-file-with-validation-path-key"
}
}
diff --git a/catalog/src/main/java/com/google/android/fhir/catalog/DemoQuestionnaireViewModel.kt b/catalog/src/main/java/com/google/android/fhir/catalog/DemoQuestionnaireViewModel.kt
index dba615bbd0..264225325f 100644
--- a/catalog/src/main/java/com/google/android/fhir/catalog/DemoQuestionnaireViewModel.kt
+++ b/catalog/src/main/java/com/google/android/fhir/catalog/DemoQuestionnaireViewModel.kt
@@ -1,5 +1,5 @@
/*
- * Copyright 2022 Google LLC
+ * Copyright 2022-2023 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -19,55 +19,13 @@ package com.google.android.fhir.catalog
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.SavedStateHandle
-import androidx.lifecycle.viewModelScope
import ca.uhn.fhir.context.FhirContext
import ca.uhn.fhir.context.FhirVersionEnum
-import com.google.android.fhir.catalog.DemoQuestionnaireFragment.Companion.QUESTIONNAIRE_FILE_PATH_KEY
-import com.google.android.fhir.catalog.DemoQuestionnaireFragment.Companion.QUESTIONNAIRE_FILE_WITH_VALIDATION_PATH_KEY
-import kotlinx.coroutines.launch
-import kotlinx.coroutines.withContext
import org.hl7.fhir.r4.model.QuestionnaireResponse
class DemoQuestionnaireViewModel(application: Application, private val state: SavedStateHandle) :
AndroidViewModel(application) {
- private val backgroundContext = viewModelScope.coroutineContext
- private var questionnaireJson: String? = null
- private var questionnaireWithValidationJson: String? = null
-
- init {
- viewModelScope.launch {
- getQuestionnaireJson()
- // TODO remove check once all files are added
- if (!state.get(QUESTIONNAIRE_FILE_WITH_VALIDATION_PATH_KEY).isNullOrEmpty()) {
- getQuestionnaireWithValidationJson()
- }
- }
- }
fun getQuestionnaireResponseJson(response: QuestionnaireResponse) =
FhirContext.forCached(FhirVersionEnum.R4).newJsonParser().encodeResourceToString(response)
-
- suspend fun getQuestionnaireJson(): String {
- return withContext(backgroundContext) {
- if (questionnaireJson == null) {
- questionnaireJson = readFileFromAssets(state[QUESTIONNAIRE_FILE_PATH_KEY]!!)
- }
- questionnaireJson!!
- }
- }
-
- suspend fun getQuestionnaireWithValidationJson(): String {
- return withContext(backgroundContext) {
- if (questionnaireWithValidationJson == null) {
- questionnaireWithValidationJson =
- readFileFromAssets(state[QUESTIONNAIRE_FILE_WITH_VALIDATION_PATH_KEY]!!)
- }
- questionnaireWithValidationJson!!
- }
- }
-
- private suspend fun readFileFromAssets(filename: String) =
- withContext(backgroundContext) {
- getApplication().assets.open(filename).bufferedReader().use { it.readText() }
- }
}
diff --git a/catalog/src/main/java/com/google/android/fhir/catalog/LayoutListFragment.kt b/catalog/src/main/java/com/google/android/fhir/catalog/LayoutListFragment.kt
index 0f0b37036b..080bc1fb16 100644
--- a/catalog/src/main/java/com/google/android/fhir/catalog/LayoutListFragment.kt
+++ b/catalog/src/main/java/com/google/android/fhir/catalog/LayoutListFragment.kt
@@ -1,5 +1,5 @@
/*
- * Copyright 2022 Google LLC
+ * Copyright 2022-2023 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -21,9 +21,11 @@ import android.view.Gravity
import android.view.View
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
+import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.findNavController
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView
+import kotlinx.coroutines.launch
/** Fragment for the layout list. */
class LayoutListFragment : Fragment(R.layout.layout_list_fragment) {
@@ -38,6 +40,7 @@ class LayoutListFragment : Fragment(R.layout.layout_list_fragment) {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
setUpLayoutsRecyclerView()
+ (activity as? MainActivity)?.showOpenQuestionnaireMenu(true)
}
private fun setUpLayoutsRecyclerView() {
@@ -67,14 +70,20 @@ class LayoutListFragment : Fragment(R.layout.layout_list_fragment) {
}
private fun launchQuestionnaireFragment(layout: LayoutListViewModel.Layout) {
- findNavController()
- .navigate(
- LayoutListFragmentDirections.actionLayoutsFragmentToGalleryQuestionnaireFragment(
- context?.getString(layout.textId) ?: "",
- layout.questionnaireFileName,
- null,
- layout.workflow
+ viewLifecycleOwner.lifecycleScope.launch {
+ findNavController()
+ .navigate(
+ MainNavGraphDirections.actionGlobalGalleryQuestionnaireFragment(
+ questionnaireTitleKey = context?.getString(layout.textId) ?: "",
+ questionnaireJsonStringKey =
+ getQuestionnaireJsonStringFromAssets(
+ context = requireContext(),
+ backgroundContext = coroutineContext,
+ fileName = layout.questionnaireFileName
+ ),
+ workflow = layout.workflow
+ )
)
- )
+ }
}
}
diff --git a/catalog/src/main/java/com/google/android/fhir/catalog/MainActivity.kt b/catalog/src/main/java/com/google/android/fhir/catalog/MainActivity.kt
index 5461689c44..4093da4026 100644
--- a/catalog/src/main/java/com/google/android/fhir/catalog/MainActivity.kt
+++ b/catalog/src/main/java/com/google/android/fhir/catalog/MainActivity.kt
@@ -1,5 +1,5 @@
/*
- * Copyright 2021 Google LLC
+ * Copyright 2021-2023 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -16,21 +16,58 @@
package com.google.android.fhir.catalog
+import android.net.Uri
import android.os.Bundle
+import android.view.Menu
+import android.view.MenuItem
import android.widget.TextView
+import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.widget.Toolbar
+import androidx.lifecycle.lifecycleScope
import androidx.navigation.Navigation
+import androidx.navigation.findNavController
import androidx.navigation.ui.NavigationUI
import com.google.android.material.bottomnavigation.BottomNavigationView
+import kotlinx.coroutines.launch
class MainActivity : AppCompatActivity(R.layout.activity_main) {
+ private var showOpenQuestionnaireMenu = true
+ val getContentLauncher =
+ registerForActivityResult(ActivityResultContracts.GetContent()) {
+ it?.let { launchQuestionnaireFragment(it) }
+ }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setSupportActionBar(findViewById(R.id.toolbar))
setUpBottomNavigationView()
}
+ override fun onCreateOptionsMenu(menu: Menu): Boolean {
+ menuInflater.inflate(R.menu.open_questionnaire_menu, menu)
+ return true
+ }
+
+ override fun onPrepareOptionsMenu(menu: Menu): Boolean {
+ menu.findItem(R.id.select_questionnaire_menu).isVisible = showOpenQuestionnaireMenu
+ return true
+ }
+
+ override fun onOptionsItemSelected(item: MenuItem): Boolean {
+ return when (item.itemId) {
+ R.id.select_questionnaire_menu -> {
+ getContentLauncher.launch("application/json")
+ true
+ }
+ else -> super.onOptionsItemSelected(item)
+ }
+ }
+
+ fun showOpenQuestionnaireMenu(showMenu: Boolean) {
+ showOpenQuestionnaireMenu = showMenu
+ invalidateOptionsMenu()
+ }
+
fun showBottomNavigationView(value: Int) {
findViewById(R.id.bottom_navigation_view).visibility = value
}
@@ -52,4 +89,22 @@ class MainActivity : AppCompatActivity(R.layout.activity_main) {
val bottomNavigationView = findViewById(R.id.bottom_navigation_view)
NavigationUI.setupWithNavController(bottomNavigationView, navController)
}
+
+ private fun launchQuestionnaireFragment(uri: Uri) {
+ lifecycleScope.launch {
+ findNavController(R.id.nav_host_fragment)
+ .navigate(
+ MainNavGraphDirections.actionGlobalGalleryQuestionnaireFragment(
+ questionnaireTitleKey = "",
+ questionnaireJsonStringKey =
+ getQuestionnaireJsonStringFromFileUri(
+ context = applicationContext,
+ backgroundContext = coroutineContext,
+ uri = uri
+ ),
+ workflow = WorkflowType.DEFAULT,
+ )
+ )
+ }
+ }
}
diff --git a/catalog/src/main/java/com/google/android/fhir/catalog/ModalBottomSheetFragment.kt b/catalog/src/main/java/com/google/android/fhir/catalog/ModalBottomSheetFragment.kt
index 0626571962..004b0dd362 100644
--- a/catalog/src/main/java/com/google/android/fhir/catalog/ModalBottomSheetFragment.kt
+++ b/catalog/src/main/java/com/google/android/fhir/catalog/ModalBottomSheetFragment.kt
@@ -1,5 +1,5 @@
/*
- * Copyright 2021 Google LLC
+ * Copyright 2021-2023 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -60,6 +60,7 @@ class ModalBottomSheetFragment : BottomSheetDialogFragment() {
)
NavHostFragment.findNavController(this).navigateUp()
}
+ (activity as? MainActivity)?.showOpenQuestionnaireMenu(false)
}
companion object {
diff --git a/catalog/src/main/java/com/google/android/fhir/catalog/QuestionnaireFileOperationUtil.kt b/catalog/src/main/java/com/google/android/fhir/catalog/QuestionnaireFileOperationUtil.kt
new file mode 100644
index 0000000000..d5958593a4
--- /dev/null
+++ b/catalog/src/main/java/com/google/android/fhir/catalog/QuestionnaireFileOperationUtil.kt
@@ -0,0 +1,44 @@
+/*
+ * Copyright 2023 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.android.fhir.catalog
+
+import android.content.Context
+import android.net.Uri
+import java.io.BufferedReader
+import kotlin.coroutines.CoroutineContext
+import kotlinx.coroutines.withContext
+
+suspend fun getQuestionnaireJsonStringFromAssets(
+ context: Context,
+ backgroundContext: CoroutineContext,
+ fileName: String
+): String {
+ return withContext(backgroundContext) {
+ context.assets.open(fileName).bufferedReader().use { it.readText() }
+ }
+}
+
+suspend fun getQuestionnaireJsonStringFromFileUri(
+ context: Context,
+ backgroundContext: CoroutineContext,
+ uri: Uri
+): String {
+ return withContext(backgroundContext) {
+ val reader = BufferedReader(context.contentResolver.openInputStream(uri)?.reader())
+ reader.use { reader -> reader.readText() }
+ }
+}
diff --git a/catalog/src/main/java/com/google/android/fhir/catalog/QuestionnaireResponseFragment.kt b/catalog/src/main/java/com/google/android/fhir/catalog/QuestionnaireResponseFragment.kt
index 025c485824..cbb06ff427 100644
--- a/catalog/src/main/java/com/google/android/fhir/catalog/QuestionnaireResponseFragment.kt
+++ b/catalog/src/main/java/com/google/android/fhir/catalog/QuestionnaireResponseFragment.kt
@@ -1,5 +1,5 @@
/*
- * Copyright 2021 Google LLC
+ * Copyright 2021-2023 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -46,6 +46,7 @@ class QuestionnaireResponseFragment : Fragment() {
setCloseOnClickListener()
view.findViewById(R.id.questionnaire_response_tv).text =
JSONObject(args.questionnaireResponse).toString(2)
+ (activity as? MainActivity)?.showOpenQuestionnaireMenu(false)
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
diff --git a/catalog/src/main/res/drawable/ic_repeatgroups.xml b/catalog/src/main/res/drawable/ic_repeatgroups.xml
new file mode 100644
index 0000000000..86ae4b4b2c
--- /dev/null
+++ b/catalog/src/main/res/drawable/ic_repeatgroups.xml
@@ -0,0 +1,13 @@
+
+
+
diff --git a/catalog/src/main/res/menu/open_questionnaire_menu.xml b/catalog/src/main/res/menu/open_questionnaire_menu.xml
new file mode 100644
index 0000000000..6550c86ccf
--- /dev/null
+++ b/catalog/src/main/res/menu/open_questionnaire_menu.xml
@@ -0,0 +1,10 @@
+
diff --git a/catalog/src/main/res/navigation/nav_graph.xml b/catalog/src/main/res/navigation/nav_graph.xml
index 8428f73225..e9c457908e 100644
--- a/catalog/src/main/res/navigation/nav_graph.xml
+++ b/catalog/src/main/res/navigation/nav_graph.xml
@@ -35,10 +35,6 @@
android:id="@+id/action_componentsFragment_to_behaviorsFragment"
app:destination="@id/behaviorListFragment"
/>
-
-
-
-
-
+
-
+
Components
Layouts
Behaviors
- Multiple choice
- Single choice
Boolean choice
+ Single choice
+ Multiple choice
+ Dropdown
+ Modal
Open choice
Text field
+ Auto Complete
Date picker
DateTime picker
- Modal
Slider
- Dropdown
- Auto Complete
+ Quantity
Help
Item Media
Item Answer Media
@@ -69,4 +70,5 @@
Error
Widgets
Miscellaneous components
+ Open questionnaire
diff --git a/codelabs/datacapture/README.md b/codelabs/datacapture/README.md
index 85615d0f70..41a8504378 100644
--- a/codelabs/datacapture/README.md
+++ b/codelabs/datacapture/README.md
@@ -57,7 +57,9 @@ Connect your Android device via USB to your host, or
and click Run (![Run button](images/image2.png "Run button")) in the Android
Studio toolbar.
-![Hello World app](images/image5.png "Hello World app")
+
+
+
As you can see there isn't much here yet, so let's get right into displaying a
questionnaire in your app!
@@ -180,7 +182,9 @@ Let's run the codelab by clicking Run
(![Run button](images/image2.png "Run button")) in the Android Studio toolbar.
You should see something similar to this:
-TODO: Screenshot showing questionnaire rendered in an emulator
+
+
+
Navigate through the questionnaire and try entering some answers. There are a
few different answer widgets used, including booleans, text, and dates, which
@@ -287,4 +291,4 @@ in your application and synchronize data with a remote FHIR server.
### Learn More
-https://github.com/google/android-fhir/wiki/Structured-Data-Capture-Library
\ No newline at end of file
+https://github.com/google/android-fhir/wiki/Structured-Data-Capture-Library
diff --git a/codelabs/datacapture/images/image6.png b/codelabs/datacapture/images/image6.png
new file mode 100644
index 0000000000..12ae72afa3
Binary files /dev/null and b/codelabs/datacapture/images/image6.png differ
diff --git a/codelabs/engine/.gitignore b/codelabs/engine/.gitignore
new file mode 100644
index 0000000000..d8e6fd459c
--- /dev/null
+++ b/codelabs/engine/.gitignore
@@ -0,0 +1,4 @@
+.gradle/
+.idea/
+build/
+local.properties
diff --git a/codelabs/engine/README.md b/codelabs/engine/README.md
new file mode 100644
index 0000000000..fef62669b3
--- /dev/null
+++ b/codelabs/engine/README.md
@@ -0,0 +1,374 @@
+# Manage FHIR resources using FHIR Engine Library
+
+## Before you begin
+
+### What you'll build
+
+In this codelab, you'll build an Android app using FHIR Engine Library. Your app
+will use FHIR Engine Library to download FHIR resources from a FHIR server, and
+upload any local changes to the server.
+
+### What you'll learn
+
+* How to create a local HAPI FHIR server using Docker
+* How to integrate FHIR Engine Library into your Android application
+* How to use the Sync API to set up a one-time or periodic job to download and
+ upload FHIR resources
+* How to use the Search API
+* How to use the Data Access APIs to create, read, update, and delete FHIR
+ resources locally
+
+### What you'll need
+
+* Docker ([get Docker](https://docs.docker.com/get-docker/))
+* A recent version of
+ [Android Studio (v4.1.2+)](https://developer.android.com/studio)
+* [Android Emulator](https://developer.android.com/studio/run/emulator) or a
+ physical Android device running Android 7.0 Nougat or later
+* The sample code
+* Basic knowledge of Android development in Kotlin
+
+If you haven't built Android apps before, you can
+start by [building your first
+app](https://developer.android.com/training/basics/firstapp).
+
+## Set up a local HAPI FHIR server with test data
+
+[HAPI FHIR](https://hapifhir.io/hapi-fhir/) is a popular open-source FHIR
+server. We will use a local HAPI FHIR server in our codelab for the Android app
+to connect to.
+
+### Set up the local HAPI FHIR server
+
+1. Run the following command in a terminal to get the latest image of HAPI
+ FHIR
+
+```shell
+docker pull hapiproject/hapi:latest
+```
+
+2. Create a HAPI FHIR container by either
+ using Docker Desktop to run the previously download image `hapiproject/hapi`, or
+ running the following command
+
+```shell
+docker run -p 8080:8080 hapiproject/hapi:latest
+```
+
+Learn [more](https://github.com/hapifhir/hapi-fhir-jpaserver-starter#running-via-docker-hub).
+
+3. Inspect the server by opening the URL `http://localhost:8080/` in a browser.
+ You should see the HAPI FHIR web interface.
+
+![HAPI FHIR web interface](images/image4.png "HAPI FHIR web interface")
+
+### Populate the local HAPI FHIR server with test data
+
+To test our application, we'll need some test data on the server. We'll use
+synthetic data generated by Synthea.
+
+1. First, we need to download sample data from [synthea-samples](
+ https://github.com/synthetichealth/synthea-sample-data/tree/master/downloads).
+ Download and unzip `synthea_sample_data_fhir_r4_sep2019.zip`. The un-zipped
+ sample data has numerous `.json` files, each being a transaction bundle for an
+ individual patient.
+
+2. We'll upload test data for three patients to the local HAPI FHIR server.
+ Run the following command in the directory containing JSON files
+
+```shell
+curl -X POST -H "Content-Type: application/json" -d @./Aaron697_Brekke496_2fa15bc7-8866-461a-9000-f739e425860a.json http://localhost:8080/fhir/
+curl -X POST -H "Content-Type: application/json" -d @./Aaron697_Stiedemann542_41166989-975d-4d17-b9de-17f94cb3eec1.json http://localhost:8080/fhir/
+curl -X POST -H "Content-Type: application/json" -d @./Abby752_Kuvalis369_2b083021-e93f-4991-bf49-fd4f20060ef8.json http://localhost:8080/fhir/
+```
+
+3. To upload test data for all patients to the server, run
+
+```shell
+for f in *.json; do curl -X POST -H "Content-Type: application/json" -d @$f http://localhost:8080/fhir/ ; done
+```
+
+However, this can take a long time to complete and is not necessary for the
+codelab.
+
+4. Verify that the test data is available on the server by opening the URL
+ `http://localhost:8080/fhir/Patient/` in a browser. You should see the text
+ `HTTP 200 OK` and the `Response Body` section of the page containing patient
+ data in a FHIR Bundle as the search result with a `total` count.
+
+![Test data on server](images/image5.png "Test data on server")
+
+## Set up the Android app
+
+### Download the Code
+
+To download the code for this codelab, clone the Android FHIR SDK repo: `git
+clone https://github.com/google/android-fhir.git`
+
+The starter project for this codelab is located in `codelabs/engine`.
+
+### Import the app into Android Studio
+
+Let's start by importing the starter app into Android Studio.
+
+Open Android Studio, select **Import Project (Gradle, Eclipse ADT, etc.)** and
+choose the `codelabs/engine/` folder from the source code that you have
+downloaded earlier.
+
+![Android Studio start screen](images/image1.png "Android Studio start screen")
+
+### Sync your project with Gradle files
+
+For your convenience, the FHIR Engine Library dependencies have already been
+add to the project. This allows you to integrate the FHIR Engine Library in your
+app. Observe the following lines to the end of the
+`app/build.gradle.kts` file of your project:
+
+```kotlin
+dependencies {
+ // ...
+
+ implementation("com.google.android.fhir:engine:0.1.0-beta03")
+}
+```
+
+To be sure that all dependencies are available to your app, you should sync your
+project with gradle files at this point.
+
+Select **Sync Project with Gradle Files** (![Gradle sync button](images/image3.png "Gradle sync button"))from the Android Studio
+toolbar. You an also run the app again to check the dependencies are working
+correctly.
+
+### Run the starter app
+
+Now that you have imported the project into Android Studio, you are ready to run
+the app for the first time.
+
+[Start the Android Studio emulator](
+https://developer.android.com/studio/run/emulator), and click Run
+(![Run button](images/image2.png "Run button")) in the Android Studio
+toolbar.
+
+
+
+## Create FHIR Engine instance
+
+To use the FHIR Engine Library, you need an instance of FHIR Engine. It will be
+the entry point of FHIR Engine APIs.
+
+1. Open `FhirApplication.kt`
+ (**app/src/main/java/com/google/android/fhir/codelabs/engine**).
+2. In function `onCreate()`, add the following code to initialize FHIR Engine:
+
+```kotlin
+FhirEngineProvider.init(
+ FhirEngineConfiguration(
+ enableEncryptionIfSupported = true,
+ RECREATE_AT_OPEN,
+ ServerConfiguration(
+ baseUrl = "http://10.0.2.2:8080/fhir/",
+ httpLogger =
+ HttpLogger(
+ HttpLogger.Configuration(
+ if (BuildConfig.DEBUG) HttpLogger.Level.BODY else HttpLogger.Level.BASIC
+ )
+ ) { Log.d("App-HttpLog", it) },
+ ),
+ )
+)
+```
+
+This initializes FHIR Engine by setting a number of configurations. Pay
+attention to the `baseUrl` in `ServerConfiguration`. The IP address `10.0.2.2`
+is reserved for the localhost accessible from the Android emulator. Learn
+[more](https://developer.android.com/studio/run/emulator-networking).
+
+3. In `FhirApplication` class, add the following property to lazily
+ instantiate an actual FHIR Engine instance:
+
+```kotlin
+ private val fhirEngine: FhirEngine by lazy { FhirEngineProvider.getInstance(this) }
+```
+
+4. Finally, add the following code as a convenience method for the rest of the
+ codelab:
+
+```kotlin
+companion object {
+ fun fhirEngine(context: Context) = (context.applicationContext as FhirApplication).fhirEngine
+}
+```
+
+## Sync data with FHIR server
+
+1. Create a new class `DownloadWorkManagerImpl.kt`:
+
+```kotlin
+class DownloadWorkManagerImpl : DownloadWorkManager {
+ private val urls = LinkedList(listOf("Patient"))
+
+ override suspend fun getNextRequest(): Request? {
+ val url = urls.poll() ?: return null
+ return Request.of(url)
+ }
+
+ override suspend fun getSummaryRequestUrls() = mapOf()
+
+ override suspend fun processResponse(response: Resource): Collection {
+ var bundleCollection: Collection = mutableListOf()
+ if (response is Bundle && response.type == Bundle.BundleType.SEARCHSET) {
+ bundleCollection = response.entry.map { it.resource }
+ }
+ return bundleCollection
+ }
+}
+```
+
+2. Create a new class `FhirSyncWorker.kt`
+
+```kotlin
+class FhirSyncWorker(appContext: Context, workerParams: WorkerParameters) :
+ FhirSyncWorker(appContext, workerParams) {
+
+ override fun getDownloadWorkManager() = DownloadWorkManagerImpl()
+
+ override fun getConflictResolver() = AcceptLocalConflictResolver
+
+ override fun getFhirEngine() = FhirApplication.fhirEngine(applicationContext)
+}
+```
+
+3. In `PatientListViewModel.kt`, add the following code to the body of
+ `triggerOneTimeSync()` function
+
+```kotlin
+viewModelScope.launch {
+ Sync.oneTimeSync(getApplication())
+ .shareIn(this, SharingStarted.Eagerly, 10)
+ .collect { _pollState.emit(it) }
+ }
+```
+
+4. In `PatientListFragment.kt`, add the following code to the body of function
+ `handleSyncJobStatus`
+
+```kotlin
+when (syncJobStatus) {
+ is SyncJobStatus.Finished -> {
+ Toast.makeText(requireContext(), "Sync Finished", Toast.LENGTH_SHORT).show()
+ viewModel.searchPatientsByName("")
+ }
+ else -> {}
+}
+```
+
+Now click the `Sync` button in the menu, and you should see the patients in your
+local FHIR server being downloaded to the application.
+
+
+
+## Modify and upload patient data
+
+In `PatientListViewModel.kt`, add the following code to `triggerUpdate` function
+
+```kotlin
+
+ viewModelScope.launch {
+ val fhirEngine = FhirApplication.fhirEngine(getApplication())
+
+ val patientsFromWakefield =
+ fhirEngine.search {
+ filter(
+ Patient.ADDRESS_CITY,
+ {
+ modifier = StringFilterModifier.CONTAINS
+ value = "Wakefield"
+ }
+ )
+ }
+
+ val patientsFromTaunton =
+ fhirEngine.search {
+ filter(
+ Patient.ADDRESS_CITY,
+ {
+ modifier = StringFilterModifier.CONTAINS
+ value = "Taunton"
+ }
+ )
+ }
+
+ patientsFromWakefield.forEach {
+ it.address.first().city = "Taunton"
+ fhirEngine.update(it)
+ }
+
+ patientsFromTaunton.forEach {
+ it.address.first().city = "Wakefield"
+ fhirEngine.update(it)
+ }
+
+ triggerOneTimeSync()
+ }
+```
+
+Now click the `Update` button in the menu, you should see the address city for
+patient `Aaron697` and `Abby752` are swapped.
+
+Open the URL `http://localhost:8080/fhir/Patient/` in a browser and verify that
+the address city for these patients are updated on the local server.
+
+## Search for patients by name
+
+1. In `PatientListViewModel.kt` change the signature of function
+ `getSearchResults()` to `getSearchResults(nameQuery: String = "")`.
+
+2. Modify the function body and add the following code to the `search`
+ function call
+
+```kotlin
+if (nameQuery.isNotEmpty()) {
+ filter(
+ Patient.NAME,
+ {
+ modifier = StringFilterModifier.CONTAINS
+ value = nameQuery
+ }
+ )
+}
+```
+
+3. Add the following code to `searchPatientsByName`
+
+```kotlin
+updatePatientList { getSearchResults(nameQuery) }
+```
+
+Relaunch the app, now you can search for patients by name.
+
+## Congratulations!
+
+You have used the FHIR Engine Library to manage FHIR resources in your app:
+
+* Use Sync API to sync FHIR resources with a FHIR server
+* Use Data Access API to create, read, update, and delete local FHIR resources
+* Use Search API to search local FHIR resources
+
+### What we've covered
+
+* How to set up a local HAPI FHIR server
+* How to upload test data to the local HAPI FHIR Server
+* How to build an Android app using the FHIR Engine Library
+* How to use Sync API, Data Access API, and Search API in the FHIR Engine
+ Library
+
+### Next Steps
+
+* Explore the documentation for the FHIR Engine Library
+* Explore the advanced features of the Search API
+* Apply the FHIR Engine Library in your own Android app
+
+### Learn More
+
+* [FHIR Engine developer documentation](https://github.com/google/android-fhir/wiki/FHIR-Engine-Library)
+
diff --git a/testing/.gitignore b/codelabs/engine/app/.gitignore
similarity index 100%
rename from testing/.gitignore
rename to codelabs/engine/app/.gitignore
diff --git a/codelabs/engine/app/build.gradle.kts b/codelabs/engine/app/build.gradle.kts
new file mode 100644
index 0000000000..e31cb98661
--- /dev/null
+++ b/codelabs/engine/app/build.gradle.kts
@@ -0,0 +1,51 @@
+plugins {
+ id("com.android.application")
+ id("kotlin-android")
+}
+
+android {
+ compileSdk = 31
+
+ defaultConfig {
+ applicationId = "com.google.android.fhir.codelabs.engine"
+ minSdk = 24
+ targetSdk = 31
+ versionCode = 1
+ versionName = "1.0"
+
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ }
+
+ buildFeatures { viewBinding = true }
+ buildTypes {
+ release {
+ isMinifyEnabled = false
+ proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
+ }
+ }
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_1_8
+ targetCompatibility = JavaVersion.VERSION_1_8
+ }
+ kotlinOptions {
+ jvmTarget = "1.8"
+ }
+ packaging {
+ resources.excludes.addAll(listOf("META-INF/ASL-2.0.txt", "META-INF/LGPL-3.0.txt"))
+ }
+}
+
+dependencies {
+ implementation("androidx.core:core-ktx:1.7.0")
+ implementation("androidx.appcompat:appcompat:1.4.0")
+ implementation("com.google.android.material:material:1.4.0")
+ implementation("androidx.constraintlayout:constraintlayout:2.1.2")
+ implementation("androidx.work:work-runtime-ktx:2.7.1")
+
+ testImplementation("junit:junit:4.+")
+ androidTestImplementation("androidx.test.ext:junit:1.1.3")
+ androidTestImplementation("androidx.test.espresso:espresso-core:3.4.0")
+
+ implementation("com.google.android.fhir:engine:0.1.0-beta03")
+ implementation("androidx.fragment:fragment-ktx:1.5.5")
+}
diff --git a/codelabs/engine/app/proguard-rules.pro b/codelabs/engine/app/proguard-rules.pro
new file mode 100644
index 0000000000..2f9dc5a47e
--- /dev/null
+++ b/codelabs/engine/app/proguard-rules.pro
@@ -0,0 +1,21 @@
+# Add project specific ProGuard rules here.
+# You can control the set of applied configuration files using the
+# proguardFiles setting in build.gradle.kts.
+#
+# For more details, see
+# http://developer.android.com/guide/developing/tools/proguard.html
+
+# If your project uses WebView with JS, uncomment the following
+# and specify the fully qualified class name to the JavaScript interface
+# class:
+#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
+# public *;
+#}
+
+# Uncomment this to preserve the line number information for
+# debugging stack traces.
+#-keepattributes SourceFile,LineNumberTable
+
+# If you keep the line number information, uncomment this to
+# hide the original source file name.
+#-renamesourcefileattribute SourceFile
diff --git a/codelabs/engine/app/src/main/AndroidManifest.xml b/codelabs/engine/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000000..8bc958c025
--- /dev/null
+++ b/codelabs/engine/app/src/main/AndroidManifest.xml
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/codelabs/engine/app/src/main/java/com/google/android/fhir/codelabs/engine/FhirApplication.kt b/codelabs/engine/app/src/main/java/com/google/android/fhir/codelabs/engine/FhirApplication.kt
new file mode 100644
index 0000000000..9e13fa8b04
--- /dev/null
+++ b/codelabs/engine/app/src/main/java/com/google/android/fhir/codelabs/engine/FhirApplication.kt
@@ -0,0 +1,55 @@
+/*
+ * Copyright 2023 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.android.fhir.codelabs.engine
+
+import android.app.Application
+import android.content.Context
+import android.util.Log
+import com.google.android.fhir.DatabaseErrorStrategy.RECREATE_AT_OPEN
+import com.google.android.fhir.FhirEngine
+import com.google.android.fhir.FhirEngineConfiguration
+import com.google.android.fhir.FhirEngineProvider
+import com.google.android.fhir.ServerConfiguration
+import com.google.android.fhir.sync.remote.HttpLogger
+
+class FhirApplication : Application() {
+ private val fhirEngine: FhirEngine by lazy { FhirEngineProvider.getInstance(this) }
+
+ override fun onCreate() {
+ super.onCreate()
+
+ FhirEngineProvider.init(
+ FhirEngineConfiguration(
+ enableEncryptionIfSupported = true,
+ RECREATE_AT_OPEN,
+ ServerConfiguration(
+ baseUrl = "http://10.0.2.2:8080/fhir/",
+ httpLogger =
+ HttpLogger(
+ HttpLogger.Configuration(
+ if (BuildConfig.DEBUG) HttpLogger.Level.BODY else HttpLogger.Level.BASIC
+ )
+ ) { Log.d("App-HttpLog", it) },
+ ),
+ )
+ )
+ }
+
+ companion object {
+ fun fhirEngine(context: Context) = (context.applicationContext as FhirApplication).fhirEngine
+ }
+}
diff --git a/workflow-testing/src/main/java/com/google/android/fhir/workflow/testing/DateUtils.kt b/codelabs/engine/app/src/main/java/com/google/android/fhir/codelabs/engine/MainActivity.kt
similarity index 62%
rename from workflow-testing/src/main/java/com/google/android/fhir/workflow/testing/DateUtils.kt
rename to codelabs/engine/app/src/main/java/com/google/android/fhir/codelabs/engine/MainActivity.kt
index ba96c2d9e0..148d02fba0 100644
--- a/workflow-testing/src/main/java/com/google/android/fhir/workflow/testing/DateUtils.kt
+++ b/codelabs/engine/app/src/main/java/com/google/android/fhir/codelabs/engine/MainActivity.kt
@@ -1,5 +1,5 @@
/*
- * Copyright 2022 Google LLC
+ * Copyright 2023 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -14,15 +14,14 @@
* limitations under the License.
*/
-package com.google.android.fhir.workflow.testing
+package com.google.android.fhir.codelabs.engine
-import java.time.LocalDate
-import org.hl7.fhir.r4.model.DateType
+import android.os.Bundle
+import androidx.appcompat.app.AppCompatActivity
-val DateType.toLocalDate: LocalDate
- get() =
- LocalDate.of(
- year,
- month + 1,
- day,
- )
+class MainActivity : AppCompatActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContentView(R.layout.activity_main)
+ }
+}
diff --git a/codelabs/engine/app/src/main/java/com/google/android/fhir/codelabs/engine/PatientItemRecyclerViewAdapter.kt b/codelabs/engine/app/src/main/java/com/google/android/fhir/codelabs/engine/PatientItemRecyclerViewAdapter.kt
new file mode 100644
index 0000000000..819c9cb394
--- /dev/null
+++ b/codelabs/engine/app/src/main/java/com/google/android/fhir/codelabs/engine/PatientItemRecyclerViewAdapter.kt
@@ -0,0 +1,45 @@
+/*
+ * Copyright 2023 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.android.fhir.codelabs.engine
+
+import android.view.LayoutInflater
+import android.view.ViewGroup
+import androidx.recyclerview.widget.DiffUtil
+import androidx.recyclerview.widget.ListAdapter
+import com.google.android.fhir.codelabs.engine.databinding.PatientListItemViewBinding
+import org.hl7.fhir.r4.model.Patient
+
+class PatientItemRecyclerViewAdapter :
+ ListAdapter(PatientItemDiffCallback()) {
+
+ class PatientItemDiffCallback : DiffUtil.ItemCallback() {
+ override fun areItemsTheSame(oldItem: Patient, newItem: Patient) = oldItem.id == newItem.id
+ override fun areContentsTheSame(oldItem: Patient, newItem: Patient) =
+ oldItem.equalsDeep(newItem)
+ }
+
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PatientItemViewHolder {
+ return PatientItemViewHolder(
+ PatientListItemViewBinding.inflate(LayoutInflater.from(parent.context), parent, false)
+ )
+ }
+
+ override fun onBindViewHolder(holder: PatientItemViewHolder, position: Int) {
+ val item = currentList[position]
+ holder.bind(item)
+ }
+}
diff --git a/codelabs/engine/app/src/main/java/com/google/android/fhir/codelabs/engine/PatientItemViewHolder.kt b/codelabs/engine/app/src/main/java/com/google/android/fhir/codelabs/engine/PatientItemViewHolder.kt
new file mode 100644
index 0000000000..409441019f
--- /dev/null
+++ b/codelabs/engine/app/src/main/java/com/google/android/fhir/codelabs/engine/PatientItemViewHolder.kt
@@ -0,0 +1,37 @@
+/*
+ * Copyright 2023 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.android.fhir.codelabs.engine
+
+import android.widget.TextView
+import androidx.recyclerview.widget.RecyclerView
+import com.google.android.fhir.codelabs.engine.databinding.PatientListItemViewBinding
+import org.hl7.fhir.r4.model.Patient
+
+class PatientItemViewHolder(binding: PatientListItemViewBinding) :
+ RecyclerView.ViewHolder(binding.root) {
+
+ private val nameTextView: TextView = binding.name
+ private val genderTextView: TextView = binding.gender
+ private val cityTextView = binding.city
+
+ fun bind(patientItem: Patient) {
+ nameTextView.text =
+ patientItem.name.first().let { it.given.joinToString(separator = " ") + " " + it.family }
+ genderTextView.text = patientItem.gender.display
+ cityTextView.text = patientItem.address.singleOrNull()?.city
+ }
+}
diff --git a/codelabs/engine/app/src/main/java/com/google/android/fhir/codelabs/engine/PatientListFragment.kt b/codelabs/engine/app/src/main/java/com/google/android/fhir/codelabs/engine/PatientListFragment.kt
new file mode 100644
index 0000000000..1f97b514c8
--- /dev/null
+++ b/codelabs/engine/app/src/main/java/com/google/android/fhir/codelabs/engine/PatientListFragment.kt
@@ -0,0 +1,147 @@
+/*
+ * Copyright 2023 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.android.fhir.codelabs.engine
+
+import android.content.Context
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.Menu
+import android.view.MenuInflater
+import android.view.MenuItem
+import android.view.View
+import android.view.ViewGroup
+import android.view.inputmethod.InputMethodManager
+import androidx.activity.OnBackPressedCallback
+import androidx.appcompat.widget.SearchView
+import androidx.core.view.MenuHost
+import androidx.core.view.MenuProvider
+import androidx.fragment.app.Fragment
+import androidx.fragment.app.viewModels
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.lifecycleScope
+import androidx.lifecycle.repeatOnLifecycle
+import com.google.android.fhir.codelabs.engine.databinding.FragmentPatientListViewBinding
+import com.google.android.fhir.sync.SyncJobStatus
+import kotlinx.coroutines.launch
+
+class PatientListFragment : Fragment() {
+ private lateinit var searchView: SearchView
+ private var _binding: FragmentPatientListViewBinding? = null
+ private val binding
+ get() = _binding!!
+
+ private val viewModel: PatientListViewModel by viewModels()
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?,
+ ): View {
+ _binding = FragmentPatientListViewBinding.inflate(inflater, container, false)
+ return binding.root
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+
+ initSearchView()
+ initMenu()
+
+ PatientItemRecyclerViewAdapter().apply {
+ binding.patientList.adapter = this
+ viewModel.liveSearchedPatients.observe(viewLifecycleOwner) { submitList(it) }
+ }
+
+ viewLifecycleOwner.lifecycleScope.launch {
+ viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
+ // Show the user a message when the sync is finished and then refresh the list of patients
+ // on the UI by sending a search patient request
+ viewModel.pollState.collect { handleSyncJobStatus(it) }
+ }
+ }
+ }
+
+ private fun handleSyncJobStatus(syncJobStatus: SyncJobStatus) {}
+
+ private fun initMenu() {
+ (requireActivity() as MenuHost).addMenuProvider(
+ object : MenuProvider {
+ override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
+ menuInflater.inflate(R.menu.menu, menu)
+ }
+
+ override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
+ return when (menuItem.itemId) {
+ R.id.sync -> {
+ viewModel.triggerOneTimeSync()
+ true
+ }
+ R.id.update -> {
+ viewModel.triggerUpdate()
+ true
+ }
+ else -> false
+ }
+ }
+ },
+ viewLifecycleOwner,
+ Lifecycle.State.RESUMED
+ )
+ }
+
+ private fun initSearchView() {
+ searchView = binding.search
+ searchView.setOnQueryTextListener(
+ object : SearchView.OnQueryTextListener {
+ override fun onQueryTextChange(newText: String): Boolean {
+ viewModel.searchPatientsByName(newText)
+ return true
+ }
+
+ override fun onQueryTextSubmit(query: String): Boolean {
+ viewModel.searchPatientsByName(query)
+ return true
+ }
+ }
+ )
+ searchView.setOnQueryTextFocusChangeListener { view, focused ->
+ if (!focused) {
+ (requireActivity().getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager)
+ .hideSoftInputFromWindow(view.windowToken, 0)
+ }
+ }
+ requireActivity()
+ .onBackPressedDispatcher.addCallback(
+ viewLifecycleOwner,
+ object : OnBackPressedCallback(true) {
+ override fun handleOnBackPressed() {
+ if (searchView.query.isNotEmpty()) {
+ searchView.setQuery("", true)
+ } else {
+ isEnabled = false
+ activity?.onBackPressed()
+ }
+ }
+ }
+ )
+ }
+
+ override fun onDestroyView() {
+ super.onDestroyView()
+ _binding = null
+ }
+}
diff --git a/codelabs/engine/app/src/main/java/com/google/android/fhir/codelabs/engine/PatientListViewModel.kt b/codelabs/engine/app/src/main/java/com/google/android/fhir/codelabs/engine/PatientListViewModel.kt
new file mode 100644
index 0000000000..72eea49766
--- /dev/null
+++ b/codelabs/engine/app/src/main/java/com/google/android/fhir/codelabs/engine/PatientListViewModel.kt
@@ -0,0 +1,72 @@
+/*
+ * Copyright 2023 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.android.fhir.codelabs.engine
+
+import android.app.Application
+import androidx.lifecycle.AndroidViewModel
+import androidx.lifecycle.MutableLiveData
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.google.android.fhir.search.Order
+import com.google.android.fhir.search.search
+import com.google.android.fhir.sync.SyncJobStatus
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.launch
+import org.hl7.fhir.r4.model.Patient
+
+class PatientListViewModel(application: Application) : AndroidViewModel(application) {
+ private val _pollState = MutableSharedFlow()
+
+ val pollState: Flow
+ get() = _pollState
+
+ val liveSearchedPatients = MutableLiveData>()
+
+ init {
+ updatePatientList { getSearchResults() }
+ }
+
+ fun triggerOneTimeSync() {}
+
+ /*
+ Fetches patients stored locally based on the city they are in, and then updates the city field for
+ each patient. Once that is complete, trigger a new sync so the changes can be uploaded.
+ */
+ fun triggerUpdate() {}
+
+ fun searchPatientsByName(nameQuery: String) {}
+
+ /**
+ * [updatePatientList] calls the search and count lambda and updates the live data values
+ * accordingly. It is initially called when this [ViewModel] is created. Later its called by the
+ * client every time search query changes or data-sync is completed.
+ */
+ private fun updatePatientList(
+ search: suspend () -> List,
+ ) {
+ viewModelScope.launch { liveSearchedPatients.value = search() }
+ }
+
+ private suspend fun getSearchResults(): List {
+ val patients: MutableList = mutableListOf()
+ FhirApplication.fhirEngine(this.getApplication())
+ .search { sort(Patient.GIVEN, Order.ASCENDING) }
+ .let { patients.addAll(it) }
+ return patients
+ }
+}
diff --git a/codelabs/engine/app/src/main/res/drawable-v24/ic_launcher_foreground.xml b/codelabs/engine/app/src/main/res/drawable-v24/ic_launcher_foreground.xml
new file mode 100644
index 0000000000..fcafc1db6e
--- /dev/null
+++ b/codelabs/engine/app/src/main/res/drawable-v24/ic_launcher_foreground.xml
@@ -0,0 +1,32 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/codelabs/engine/app/src/main/res/drawable/ic_launcher_background.xml b/codelabs/engine/app/src/main/res/drawable/ic_launcher_background.xml
new file mode 100644
index 0000000000..4449305937
--- /dev/null
+++ b/codelabs/engine/app/src/main/res/drawable/ic_launcher_background.xml
@@ -0,0 +1,202 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/codelabs/engine/app/src/main/res/layout/activity_main.xml b/codelabs/engine/app/src/main/res/layout/activity_main.xml
new file mode 100644
index 0000000000..930ebdacc0
--- /dev/null
+++ b/codelabs/engine/app/src/main/res/layout/activity_main.xml
@@ -0,0 +1,17 @@
+
+
+
+
+
+
diff --git a/codelabs/engine/app/src/main/res/layout/fragment_patient_list_view.xml b/codelabs/engine/app/src/main/res/layout/fragment_patient_list_view.xml
new file mode 100644
index 0000000000..83e5765cff
--- /dev/null
+++ b/codelabs/engine/app/src/main/res/layout/fragment_patient_list_view.xml
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
diff --git a/codelabs/engine/app/src/main/res/layout/patient_list_item_view.xml b/codelabs/engine/app/src/main/res/layout/patient_list_item_view.xml
new file mode 100644
index 0000000000..abfd6b785b
--- /dev/null
+++ b/codelabs/engine/app/src/main/res/layout/patient_list_item_view.xml
@@ -0,0 +1,50 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/codelabs/engine/app/src/main/res/menu/menu.xml b/codelabs/engine/app/src/main/res/menu/menu.xml
new file mode 100644
index 0000000000..7e010e24ac
--- /dev/null
+++ b/codelabs/engine/app/src/main/res/menu/menu.xml
@@ -0,0 +1,5 @@
+
+
diff --git a/codelabs/engine/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/codelabs/engine/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
new file mode 100644
index 0000000000..58f4e55f2c
--- /dev/null
+++ b/codelabs/engine/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/codelabs/engine/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/codelabs/engine/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
new file mode 100644
index 0000000000..58f4e55f2c
--- /dev/null
+++ b/codelabs/engine/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/codelabs/engine/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/codelabs/engine/app/src/main/res/mipmap-hdpi/ic_launcher.webp
new file mode 100644
index 0000000000..c209e78ecd
Binary files /dev/null and b/codelabs/engine/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ
diff --git a/codelabs/engine/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/codelabs/engine/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp
new file mode 100644
index 0000000000..b2dfe3d1ba
Binary files /dev/null and b/codelabs/engine/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ
diff --git a/codelabs/engine/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/codelabs/engine/app/src/main/res/mipmap-mdpi/ic_launcher.webp
new file mode 100644
index 0000000000..4f0f1d64e5
Binary files /dev/null and b/codelabs/engine/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ
diff --git a/codelabs/engine/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/codelabs/engine/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp
new file mode 100644
index 0000000000..62b611da08
Binary files /dev/null and b/codelabs/engine/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ
diff --git a/codelabs/engine/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/codelabs/engine/app/src/main/res/mipmap-xhdpi/ic_launcher.webp
new file mode 100644
index 0000000000..948a3070fe
Binary files /dev/null and b/codelabs/engine/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ
diff --git a/codelabs/engine/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/codelabs/engine/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp
new file mode 100644
index 0000000000..1b9a6956b3
Binary files /dev/null and b/codelabs/engine/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ
diff --git a/codelabs/engine/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/codelabs/engine/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp
new file mode 100644
index 0000000000..28d4b77f9f
Binary files /dev/null and b/codelabs/engine/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ
diff --git a/codelabs/engine/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/codelabs/engine/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp
new file mode 100644
index 0000000000..9287f50836
Binary files /dev/null and b/codelabs/engine/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ
diff --git a/codelabs/engine/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/codelabs/engine/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
new file mode 100644
index 0000000000..aa7d6427e6
Binary files /dev/null and b/codelabs/engine/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ
diff --git a/codelabs/engine/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/codelabs/engine/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp
new file mode 100644
index 0000000000..9126ae37cb
Binary files /dev/null and b/codelabs/engine/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ
diff --git a/codelabs/engine/app/src/main/res/values-night/themes.xml b/codelabs/engine/app/src/main/res/values-night/themes.xml
new file mode 100644
index 0000000000..89787c76da
--- /dev/null
+++ b/codelabs/engine/app/src/main/res/values-night/themes.xml
@@ -0,0 +1,22 @@
+
+
+
+
diff --git a/codelabs/engine/app/src/main/res/values/colors.xml b/codelabs/engine/app/src/main/res/values/colors.xml
new file mode 100644
index 0000000000..0de1bf3588
--- /dev/null
+++ b/codelabs/engine/app/src/main/res/values/colors.xml
@@ -0,0 +1,10 @@
+
+
+ #FFBB86FC
+ #FF6200EE
+ #FF3700B3
+ #FF03DAC5
+ #FF018786
+ #FF000000
+ #FFFFFFFF
+
diff --git a/codelabs/engine/app/src/main/res/values/dimens.xml b/codelabs/engine/app/src/main/res/values/dimens.xml
new file mode 100644
index 0000000000..2ee1a7bf7d
--- /dev/null
+++ b/codelabs/engine/app/src/main/res/values/dimens.xml
@@ -0,0 +1,6 @@
+
+
+ 24dp
+ 20dp
+ 2dp
+
diff --git a/codelabs/engine/app/src/main/res/values/strings.xml b/codelabs/engine/app/src/main/res/values/strings.xml
new file mode 100644
index 0000000000..b6d60d5382
--- /dev/null
+++ b/codelabs/engine/app/src/main/res/values/strings.xml
@@ -0,0 +1,5 @@
+
+ FHIR Engine Codelab
+ Sync
+ Update
+
diff --git a/codelabs/engine/app/src/main/res/values/themes.xml b/codelabs/engine/app/src/main/res/values/themes.xml
new file mode 100644
index 0000000000..7f2ee46bb1
--- /dev/null
+++ b/codelabs/engine/app/src/main/res/values/themes.xml
@@ -0,0 +1,22 @@
+
+
+
+
diff --git a/codelabs/engine/app/src/main/res/xml/network_security_config.xml b/codelabs/engine/app/src/main/res/xml/network_security_config.xml
new file mode 100644
index 0000000000..c8b049a38d
--- /dev/null
+++ b/codelabs/engine/app/src/main/res/xml/network_security_config.xml
@@ -0,0 +1,6 @@
+
+
+
+ 10.0.2.2
+
+
diff --git a/codelabs/engine/build.gradle.kts b/codelabs/engine/build.gradle.kts
new file mode 100644
index 0000000000..77acf04960
--- /dev/null
+++ b/codelabs/engine/build.gradle.kts
@@ -0,0 +1,23 @@
+// Top-level build file where you can add configuration options common to all sub-projects/modules.
+buildscript {
+ repositories {
+ google()
+ mavenCentral()
+ gradlePluginPortal()
+ }
+ dependencies {
+ classpath("com.android.tools.build:gradle:7.0.4")
+ classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.6.10")
+
+ // NOTE: Do not place your application dependencies here; they belong
+ // in the individual module build.gradle.kts files
+ }
+}
+
+allprojects {
+ repositories {
+ google()
+ mavenCentral()
+ gradlePluginPortal()
+ }
+}
\ No newline at end of file
diff --git a/codelabs/engine/gradle.properties b/codelabs/engine/gradle.properties
new file mode 100644
index 0000000000..98bed167dc
--- /dev/null
+++ b/codelabs/engine/gradle.properties
@@ -0,0 +1,21 @@
+# Project-wide Gradle settings.
+# IDE (e.g. Android Studio) users:
+# Gradle settings configured through the IDE *will override*
+# any settings specified in this file.
+# For more details on how to configure your build environment visit
+# http://www.gradle.org/docs/current/userguide/build_environment.html
+# Specifies the JVM arguments used for the daemon process.
+# The setting is particularly useful for tweaking memory settings.
+org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
+# When configured, Gradle will run in incubating parallel mode.
+# This option should only be used with decoupled projects. More details, visit
+# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
+# org.gradle.parallel=true
+# AndroidX package structure to make it clearer which packages are bundled with the
+# Android operating system, and which are packaged with your app"s APK
+# https://developer.android.com/topic/libraries/support-library/androidx-rn
+android.useAndroidX=true
+# Automatically convert third-party libraries to use AndroidX
+android.enableJetifier=true
+# Kotlin code style for this project: "official" or "obsolete":
+kotlin.code.style=official
\ No newline at end of file
diff --git a/codelabs/engine/gradle/wrapper/gradle-wrapper.jar b/codelabs/engine/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 0000000000..e708b1c023
Binary files /dev/null and b/codelabs/engine/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/codelabs/engine/gradle/wrapper/gradle-wrapper.properties b/codelabs/engine/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000000..b2a400b0e0
--- /dev/null
+++ b/codelabs/engine/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,6 @@
+#Fri Jan 28 09:30:50 GMT 2022
+distributionBase=GRADLE_USER_HOME
+distributionUrl=https\://services.gradle.org/distributions/gradle-7.0.2-bin.zip
+distributionPath=wrapper/dists
+zipStorePath=wrapper/dists
+zipStoreBase=GRADLE_USER_HOME
diff --git a/codelabs/engine/gradlew b/codelabs/engine/gradlew
new file mode 100755
index 0000000000..4f906e0c81
--- /dev/null
+++ b/codelabs/engine/gradlew
@@ -0,0 +1,185 @@
+#!/usr/bin/env sh
+
+#
+# Copyright 2015 the original author or 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
+##
+##############################################################################
+
+# 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
+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='"-Xmx64m" "-Xms64m"'
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD="maximum"
+
+warn () {
+ echo "$*"
+}
+
+die () {
+ echo
+ echo "$*"
+ echo
+ exit 1
+}
+
+# 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
+ ;;
+esac
+
+CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
+
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD="$JAVA_HOME/jre/sh/java"
+ else
+ JAVACMD="$JAVA_HOME/bin/java"
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD="java"
+ which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+fi
+
+# Increase the maximum file descriptors if we can.
+if [ "$cygwin" = "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
+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
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if [ "$cygwin" = "true" -o "$msys" = "true" ] ; 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\""
+ fi
+ i=`expr $i + 1`
+ 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"
+
+exec "$JAVACMD" "$@"
diff --git a/codelabs/engine/gradlew.bat b/codelabs/engine/gradlew.bat
new file mode 100644
index 0000000000..ac1b06f938
--- /dev/null
+++ b/codelabs/engine/gradlew.bat
@@ -0,0 +1,89 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+
+@if "%DEBUG%" == "" @echo off
+@rem ##########################################################################
+@rem
+@rem Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%" == "" set DIRNAME=.
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if "%ERRORLEVEL%" == "0" goto execute
+
+echo.
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo.
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
+
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
+
+:end
+@rem End local scope for the variables with windows NT shell
+if "%ERRORLEVEL%"=="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
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/codelabs/engine/images/image1.png b/codelabs/engine/images/image1.png
new file mode 100644
index 0000000000..da812c134c
Binary files /dev/null and b/codelabs/engine/images/image1.png differ
diff --git a/codelabs/engine/images/image2.png b/codelabs/engine/images/image2.png
new file mode 100644
index 0000000000..d3b018e871
Binary files /dev/null and b/codelabs/engine/images/image2.png differ
diff --git a/codelabs/engine/images/image3.png b/codelabs/engine/images/image3.png
new file mode 100644
index 0000000000..7c9ea77bb1
Binary files /dev/null and b/codelabs/engine/images/image3.png differ
diff --git a/codelabs/engine/images/image4.png b/codelabs/engine/images/image4.png
new file mode 100644
index 0000000000..5323defa4f
Binary files /dev/null and b/codelabs/engine/images/image4.png differ
diff --git a/codelabs/engine/images/image5.png b/codelabs/engine/images/image5.png
new file mode 100644
index 0000000000..61a13d0c2e
Binary files /dev/null and b/codelabs/engine/images/image5.png differ
diff --git a/codelabs/engine/images/image6.png b/codelabs/engine/images/image6.png
new file mode 100644
index 0000000000..e6c461a5bb
Binary files /dev/null and b/codelabs/engine/images/image6.png differ
diff --git a/codelabs/engine/images/image7.png b/codelabs/engine/images/image7.png
new file mode 100644
index 0000000000..c671dd5eb9
Binary files /dev/null and b/codelabs/engine/images/image7.png differ
diff --git a/codelabs/engine/settings.gradle.kts b/codelabs/engine/settings.gradle.kts
new file mode 100644
index 0000000000..15a801b10a
--- /dev/null
+++ b/codelabs/engine/settings.gradle.kts
@@ -0,0 +1 @@
+include(":app")
diff --git a/common/build.gradle.kts b/common/build.gradle.kts
index 5de1bf50cf..17ac60024a 100644
--- a/common/build.gradle.kts
+++ b/common/build.gradle.kts
@@ -21,18 +21,15 @@ publishArtifact(Releases.Common)
createJacocoTestReportTask()
android {
+ namespace = "com.google.android.fhir.common"
compileSdk = Sdk.compileSdk
-
- defaultConfig {
- minSdk = Sdk.minSdk
- targetSdk = Sdk.targetSdk
- }
+ defaultConfig { minSdk = Sdk.minSdk }
+ configureJacocoTestOptions()
+ kotlin { jvmToolchain(11) }
compileOptions {
- sourceCompatibility = Java.sourceCompatibility
- targetCompatibility = Java.targetCompatibility
+ sourceCompatibility = javaVersion
+ targetCompatibility = javaVersion
}
- kotlinOptions { jvmTarget = Java.kotlinJvmTarget.toString() }
- configureJacocoTestOptions()
}
configurations { all { exclude(module = "xpp3") } }
diff --git a/common/src/main/AndroidManifest.xml b/common/src/main/AndroidManifest.xml
deleted file mode 100644
index c4111317a9..0000000000
--- a/common/src/main/AndroidManifest.xml
+++ /dev/null
@@ -1,22 +0,0 @@
-
-
-
-
-
diff --git a/common/src/main/java/com/google/android/fhir/MoreTypes.kt b/common/src/main/java/com/google/android/fhir/MoreTypes.kt
index 3adcdc2c66..b4fb029a13 100644
--- a/common/src/main/java/com/google/android/fhir/MoreTypes.kt
+++ b/common/src/main/java/com/google/android/fhir/MoreTypes.kt
@@ -1,5 +1,5 @@
/*
- * Copyright 2021 Google LLC
+ * Copyright 2023 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -65,9 +65,10 @@ operator fun Type.compareTo(value: Type): Int {
return this.dateTimeValue().value.compareTo(value.dateTimeValue().value)
}
this.fhirType().equals("Quantity") -> {
- val quantity = UnitConverter.getCanonicalForm(UcumValue((this as Quantity).code, this.value))
+ val quantity =
+ UnitConverter.getCanonicalFormOrOriginal(UcumValue((this as Quantity).code, this.value))
val anotherQuantity =
- UnitConverter.getCanonicalForm(UcumValue((value as Quantity).code, value.value))
+ UnitConverter.getCanonicalFormOrOriginal(UcumValue((value as Quantity).code, value.value))
if (quantity.code != anotherQuantity.code) {
throw IllegalArgumentException(
"Cannot compare different quantity codes: ${quantity.code} and ${anotherQuantity.code}"
@@ -79,7 +80,6 @@ operator fun Type.compareTo(value: Type): Int {
throw NotImplementedError()
}
}
- return 0
}
private fun clearTimeFromDateValue(dateValue: Date): Date {
diff --git a/common/src/main/java/com/google/android/fhir/UnitConverter.kt b/common/src/main/java/com/google/android/fhir/UnitConverter.kt
index 9e6c390f9d..37519c4414 100644
--- a/common/src/main/java/com/google/android/fhir/UnitConverter.kt
+++ b/common/src/main/java/com/google/android/fhir/UnitConverter.kt
@@ -1,5 +1,5 @@
/*
- * Copyright 2021 Google LLC
+ * Copyright 2023 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -19,7 +19,6 @@ package com.google.android.fhir
import java.lang.NullPointerException
import java.math.BigDecimal
import java.math.MathContext
-import kotlin.jvm.Throws
import org.fhir.ucum.Decimal
import org.fhir.ucum.Pair
import org.fhir.ucum.UcumEssenceService
@@ -45,9 +44,10 @@ object UnitConverter {
* The canonical form is generated by normalizing [value] to UCUM base units, used to generate
* canonical matches on Quantity Search
*
+ * @throws ConverterException if fails to generate canonical matches
+ *
* For example a value of 1000 mm will return 1 m.
*/
- @Throws(ConverterException::class)
fun getCanonicalForm(value: UcumValue): UcumValue {
try {
val pair =
@@ -63,6 +63,26 @@ object UnitConverter {
throw ConverterException("Missing numerical value in the canonical UCUM value", e)
}
}
+
+ /**
+ * Returns the canonical form of a UCUM Value if it is supported in Ucum library.
+ *
+ * The canonical form is generated by normalizing [value] to UCUM base units, used to generate
+ * canonical matches on Quantity Search, if fails to generate then returns original value
+ *
+ * For example a value of 1000 mm will return 1 m.
+ */
+ fun getCanonicalFormOrOriginal(value: UcumValue): UcumValue {
+ return try {
+ getCanonicalForm(value)
+ } catch (e: ConverterException) {
+ val pair = Pair(Decimal(value.value.toPlainString()), value.code)
+ UcumValue(
+ pair.code,
+ pair.value.asDecimal().toBigDecimal(MathContext(value.value.precision()))
+ )
+ }
+ }
}
class ConverterException(message: String, cause: Throwable) : Exception(message, cause)
diff --git a/common/src/test/java/com/google/android/fhir/UnitConverterTest.kt b/common/src/test/java/com/google/android/fhir/UnitConverterTest.kt
index 18ca724fd0..eccbf78670 100644
--- a/common/src/test/java/com/google/android/fhir/UnitConverterTest.kt
+++ b/common/src/test/java/com/google/android/fhir/UnitConverterTest.kt
@@ -1,5 +1,5 @@
/*
- * Copyright 2021 Google LLC
+ * Copyright 2023 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -48,4 +48,13 @@ class UnitConverterTest {
.hasMessageThat()
.isEqualTo("Missing numerical value in the canonical UCUM value")
}
+
+ @Test
+ fun `should return original code and value if fails to convert Cel to K`() {
+ val canonicalValue =
+ UnitConverter.getCanonicalFormOrOriginal(UcumValue("Cel", BigDecimal.valueOf(37.0)))
+
+ assertThat(canonicalValue.code).isEqualTo("Cel")
+ assertThat(canonicalValue.value.toDouble()).isEqualTo(37.0)
+ }
}
diff --git a/contrib/barcode/build.gradle.kts b/contrib/barcode/build.gradle.kts
index 529275b999..2e4bf64560 100644
--- a/contrib/barcode/build.gradle.kts
+++ b/contrib/barcode/build.gradle.kts
@@ -21,11 +21,10 @@ publishArtifact(Releases.Contrib.Barcode)
createJacocoTestReportTask()
android {
+ namespace = "com.google.android.fhir.datacapture.contrib.views.barcode"
compileSdk = Sdk.compileSdk
-
defaultConfig {
minSdk = Sdk.minSdk
- targetSdk = Sdk.targetSdk
testInstrumentationRunner = Dependencies.androidJunitRunner
// Need to specify this to prevent junit runner from going deep into our dependencies
testInstrumentationRunnerArguments["package"] = "com.google.android.fhir.datacapture"
@@ -34,19 +33,12 @@ android {
buildFeatures { viewBinding = true }
buildTypes {
- getByName("release") {
+ release {
isMinifyEnabled = false
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"))
}
}
- compileOptions {
- sourceCompatibility = Java.sourceCompatibility
- targetCompatibility = Java.targetCompatibility
- }
-
- kotlinOptions { jvmTarget = Java.kotlinJvmTarget.toString() }
-
- packagingOptions {
+ packaging {
resources.excludes.addAll(
listOf(
"META-INF/INDEX.LIST",
@@ -60,6 +52,11 @@ android {
configureJacocoTestOptions()
testOptions { animationsDisabled = true }
+ kotlin { jvmToolchain(11) }
+ compileOptions {
+ sourceCompatibility = javaVersion
+ targetCompatibility = javaVersion
+ }
}
configurations { all { exclude(module = "xpp3") } }
diff --git a/contrib/barcode/src/androidTest/java/com/google/android/fhir/datacapture/contrib/views/barcode/BarCodeReaderViewHolderFactoryInstrumentedTest.kt b/contrib/barcode/src/androidTest/java/com/google/android/fhir/datacapture/contrib/views/barcode/BarCodeReaderViewHolderFactoryInstrumentedTest.kt
index 8b701bdcac..c8d0ffd15f 100644
--- a/contrib/barcode/src/androidTest/java/com/google/android/fhir/datacapture/contrib/views/barcode/BarCodeReaderViewHolderFactoryInstrumentedTest.kt
+++ b/contrib/barcode/src/androidTest/java/com/google/android/fhir/datacapture/contrib/views/barcode/BarCodeReaderViewHolderFactoryInstrumentedTest.kt
@@ -1,5 +1,5 @@
/*
- * Copyright 2022 Google LLC
+ * Copyright 2023 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -46,7 +46,7 @@ class BarCodeReaderViewHolderFactoryInstrumentedTest {
context =
ContextThemeWrapper(
InstrumentationRegistry.getInstrumentation().targetContext,
- R.style.Theme_MaterialComponents
+ com.google.android.material.R.style.Theme_MaterialComponents
)
parent = FrameLayout(context)
viewHolder = BarCodeReaderViewHolderFactory.create(parent)
diff --git a/contrib/barcode/src/main/AndroidManifest.xml b/contrib/barcode/src/main/AndroidManifest.xml
deleted file mode 100644
index e112314e09..0000000000
--- a/contrib/barcode/src/main/AndroidManifest.xml
+++ /dev/null
@@ -1,22 +0,0 @@
-
-
-
-
-
diff --git a/contrib/barcode/src/main/java/com/google/android/fhir/datacapture/contrib/views/barcode/mlkit/md/LiveBarcodeScanningFragment.kt b/contrib/barcode/src/main/java/com/google/android/fhir/datacapture/contrib/views/barcode/mlkit/md/LiveBarcodeScanningFragment.kt
index 0a98415679..3ad7f794c1 100644
--- a/contrib/barcode/src/main/java/com/google/android/fhir/datacapture/contrib/views/barcode/mlkit/md/LiveBarcodeScanningFragment.kt
+++ b/contrib/barcode/src/main/java/com/google/android/fhir/datacapture/contrib/views/barcode/mlkit/md/LiveBarcodeScanningFragment.kt
@@ -1,5 +1,5 @@
/*
- * Copyright 2021 Google LLC
+ * Copyright 2023 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -72,7 +72,10 @@ class LiveBarcodeScanningFragment : DialogFragment(), OnClickListener {
promptChip = binding.bottomPromptChip
promptChipAnimator =
- (AnimatorInflater.loadAnimator(context, R.animator.bottom_prompt_chip_enter) as AnimatorSet)
+ (AnimatorInflater.loadAnimator(
+ context,
+ com.google.android.fhir.datacapture.R.animator.bottom_prompt_chip_enter
+ ) as AnimatorSet)
.apply { setTarget(promptChip) }
binding.topActionBarInLiveCamera.closeButton.setOnClickListener(this)
@@ -155,64 +158,67 @@ class LiveBarcodeScanningFragment : DialogFragment(), OnClickListener {
// Observes the workflow state changes, if happens, update the overlay view indicators and
// camera preview state.
- workflowModel!!.workflowState.observe(
- viewLifecycleOwner,
- Observer { workflowState ->
- if (workflowState == null || Objects.equal(currentWorkflowState, workflowState)) {
- return@Observer
- }
-
- currentWorkflowState = workflowState
- Timber.d("Current workflow state: ${currentWorkflowState!!.name}")
-
- val wasPromptChipGone = promptChip?.visibility == View.GONE
-
- when (workflowState) {
- WorkflowState.DETECTING -> {
- promptChip?.visibility = View.VISIBLE
- promptChip?.setText(R.string.prompt_point_at_a_barcode)
- startCameraPreview()
+ workflowModel!!
+ .workflowState.observe(
+ viewLifecycleOwner,
+ Observer { workflowState ->
+ if (workflowState == null || Objects.equal(currentWorkflowState, workflowState)) {
+ return@Observer
}
- WorkflowState.CONFIRMING -> {
- promptChip?.visibility = View.VISIBLE
- promptChip?.setText(R.string.prompt_move_camera_closer)
- startCameraPreview()
- }
- WorkflowState.SEARCHING -> {
- promptChip?.visibility = View.VISIBLE
- promptChip?.setText(R.string.prompt_searching)
- stopCameraPreview()
- }
- WorkflowState.DETECTED, WorkflowState.SEARCHED -> {
- promptChip?.visibility = View.GONE
- stopCameraPreview()
+
+ currentWorkflowState = workflowState
+ Timber.d("Current workflow state: ${currentWorkflowState!!.name}")
+
+ val wasPromptChipGone = promptChip?.visibility == View.GONE
+
+ when (workflowState) {
+ WorkflowState.DETECTING -> {
+ promptChip?.visibility = View.VISIBLE
+ promptChip?.setText(R.string.prompt_point_at_a_barcode)
+ startCameraPreview()
+ }
+ WorkflowState.CONFIRMING -> {
+ promptChip?.visibility = View.VISIBLE
+ promptChip?.setText(R.string.prompt_move_camera_closer)
+ startCameraPreview()
+ }
+ WorkflowState.SEARCHING -> {
+ promptChip?.visibility = View.VISIBLE
+ promptChip?.setText(R.string.prompt_searching)
+ stopCameraPreview()
+ }
+ WorkflowState.DETECTED,
+ WorkflowState.SEARCHED -> {
+ promptChip?.visibility = View.GONE
+ stopCameraPreview()
+ }
+ else -> promptChip?.visibility = View.GONE
}
- else -> promptChip?.visibility = View.GONE
- }
- val shouldPlayPromptChipEnteringAnimation =
- wasPromptChipGone && promptChip?.visibility == View.VISIBLE
- promptChipAnimator?.let {
- if (shouldPlayPromptChipEnteringAnimation && !it.isRunning) it.start()
+ val shouldPlayPromptChipEnteringAnimation =
+ wasPromptChipGone && promptChip?.visibility == View.VISIBLE
+ promptChipAnimator?.let {
+ if (shouldPlayPromptChipEnteringAnimation && !it.isRunning) it.start()
+ }
}
- }
- )
-
- workflowModel?.detectedBarcode?.observe(
- viewLifecycleOwner,
- { barcode ->
- if (barcode != null) {
-
- setFragmentResult(
- RESULT_REQUEST_KEY,
- bundleOf(
- RESULT_REQUEST_KEY to barcode.rawValue,
+ )
+
+ workflowModel
+ ?.detectedBarcode?.observe(
+ viewLifecycleOwner,
+ { barcode ->
+ if (barcode != null) {
+
+ setFragmentResult(
+ RESULT_REQUEST_KEY,
+ bundleOf(
+ RESULT_REQUEST_KEY to barcode.rawValue,
+ )
)
- )
- dismiss()
+ dismiss()
+ }
}
- }
- )
+ )
}
companion object {
diff --git a/contrib/barcode/src/test/java/com/google/android/fhir/datacapture/contrib/views/barcode/mlkit/md/LiveBarcodeScanningFragmentTest.kt b/contrib/barcode/src/test/java/com/google/android/fhir/datacapture/contrib/views/barcode/mlkit/md/LiveBarcodeScanningFragmentTest.kt
index a156dc6f0b..01f28ef1b4 100644
--- a/contrib/barcode/src/test/java/com/google/android/fhir/datacapture/contrib/views/barcode/mlkit/md/LiveBarcodeScanningFragmentTest.kt
+++ b/contrib/barcode/src/test/java/com/google/android/fhir/datacapture/contrib/views/barcode/mlkit/md/LiveBarcodeScanningFragmentTest.kt
@@ -1,5 +1,5 @@
/*
- * Copyright 2021 Google LLC
+ * Copyright 2023 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -51,7 +51,9 @@ class LiveBarcodeScanningFragmentTest {
fun setUp() {
mockUtils()
cameraSource = mock()
- launchFragment(themeResId = R.style.Theme_Questionnaire)
+ launchFragment(
+ themeResId = com.google.android.fhir.datacapture.R.style.Theme_Questionnaire
+ )
.onFragment {
liveBarcodeScanningFragment = spy(it)
fragmentActivity = mock()
diff --git a/datacapture/build.gradle.kts b/datacapture/build.gradle.kts
index 70f13c67c8..27825d8f3a 100644
--- a/datacapture/build.gradle.kts
+++ b/datacapture/build.gradle.kts
@@ -22,11 +22,10 @@ publishArtifact(Releases.DataCapture)
createJacocoTestReportTask()
android {
+ namespace = "com.google.android.fhir.datacapture"
compileSdk = Sdk.compileSdk
-
defaultConfig {
minSdk = Sdk.minSdk
- targetSdk = Sdk.targetSdk
testInstrumentationRunner = Dependencies.androidJunitRunner
// Need to specify this to prevent junit runner from going deep into our dependencies
testInstrumentationRunnerArguments["package"] = "com.google.android.fhir.datacapture"
@@ -35,7 +34,7 @@ android {
buildFeatures { viewBinding = true }
buildTypes {
- getByName("release") {
+ release {
isMinifyEnabled = false
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"))
}
@@ -44,26 +43,25 @@ android {
// Flag to enable support for the new language APIs
// See https://developer.android.com/studio/write/java8-support
isCoreLibraryDesugaringEnabled = true
-
- sourceCompatibility = Java.sourceCompatibility
- targetCompatibility = Java.targetCompatibility
+ sourceCompatibility = javaVersion
+ targetCompatibility = javaVersion
}
- packagingOptions {
+ packaging {
resources.excludes.addAll(
listOf("META-INF/ASL2.0", "META-INF/ASL-2.0.txt", "META-INF/LGPL-3.0.txt")
)
}
- kotlinOptions { jvmTarget = Java.kotlinJvmTarget.toString() }
configureJacocoTestOptions()
sourceSets { getByName("androidTest").apply { resources.setSrcDirs(listOf("sampledata")) } }
testOptions { animationsDisabled = true }
+ kotlin { jvmToolchain(11) }
}
-afterEvaluate { configureFirebaseTestLab() }
+afterEvaluate { configureFirebaseTestLabForLibraries() }
configurations { all { exclude(module = "xpp3") } }
@@ -99,7 +97,6 @@ dependencies {
implementation(Dependencies.Kotlin.stdlib)
implementation(Dependencies.Lifecycle.viewModelKtx)
implementation(Dependencies.material)
- implementation(Dependencies.lifecycleExtensions)
implementation(Dependencies.timber)
testImplementation(Dependencies.AndroidxTest.core)
@@ -111,7 +108,6 @@ dependencies {
testImplementation(Dependencies.mockitoKotlin)
testImplementation(Dependencies.robolectric)
testImplementation(Dependencies.truth)
- testImplementation(project(":testing"))
}
tasks.dokkaHtml.configure {
diff --git a/datacapture/src/androidTest/AndroidManifest.xml b/datacapture/src/androidTest/AndroidManifest.xml
index 9d354ab04b..25c2d451d6 100644
--- a/datacapture/src/androidTest/AndroidManifest.xml
+++ b/datacapture/src/androidTest/AndroidManifest.xml
@@ -16,7 +16,7 @@
-->
{
+ return any(View::class.java)
+ }
+
+ override fun getDescription(): String {
+ return "wait until displayed"
+ }
+
+ override fun perform(uiController: UiController, view: View) {
+ uiController.loopMainThreadForAtLeast(1000)
+ }
+ }
+}
diff --git a/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/utilities/TextInputLayoutAction.kt b/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/test/utilities/TextInputLayoutAction.kt
similarity index 84%
rename from datacapture/src/androidTest/java/com/google/android/fhir/datacapture/utilities/TextInputLayoutAction.kt
rename to datacapture/src/androidTest/java/com/google/android/fhir/datacapture/test/utilities/TextInputLayoutAction.kt
index c8ef713faf..1491277597 100644
--- a/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/utilities/TextInputLayoutAction.kt
+++ b/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/test/utilities/TextInputLayoutAction.kt
@@ -1,5 +1,5 @@
/*
- * Copyright 2021 Google LLC
+ * Copyright 2023 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -14,13 +14,12 @@
* limitations under the License.
*/
-package com.google.android.fhir.datacapture.utilities
+package com.google.android.fhir.datacapture.test.utilities
import android.view.View
import androidx.test.espresso.UiController
import androidx.test.espresso.ViewAction
import androidx.test.espresso.matcher.ViewMatchers
-import com.google.android.fhir.datacapture.R
import com.google.android.material.internal.CheckableImageButton
import com.google.android.material.textfield.TextInputLayout
@@ -38,7 +37,10 @@ fun clickIcon(isEndIcon: Boolean): ViewAction {
override fun perform(uiController: UiController?, view: View?) {
val item = view as TextInputLayout
val iconView: CheckableImageButton =
- item.findViewById(if (isEndIcon) R.id.text_input_end_icon else R.id.text_input_start_icon)
+ item.findViewById(
+ if (isEndIcon) com.google.android.material.R.id.text_input_end_icon
+ else com.google.android.material.R.id.text_input_start_icon
+ )
iconView.performClick()
uiController!!.loopMainThreadForAtLeast(1000)
}
diff --git a/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/views/AttachmentViewHolderFactoryEspressoTest.kt b/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/test/views/AttachmentViewHolderFactoryEspressoTest.kt
similarity index 98%
rename from datacapture/src/androidTest/java/com/google/android/fhir/datacapture/views/AttachmentViewHolderFactoryEspressoTest.kt
rename to datacapture/src/androidTest/java/com/google/android/fhir/datacapture/test/views/AttachmentViewHolderFactoryEspressoTest.kt
index 47e5f03b1b..7b61b0ddee 100644
--- a/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/views/AttachmentViewHolderFactoryEspressoTest.kt
+++ b/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/test/views/AttachmentViewHolderFactoryEspressoTest.kt
@@ -1,5 +1,5 @@
/*
- * Copyright 2022 Google LLC
+ * Copyright 2023 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -14,7 +14,7 @@
* limitations under the License.
*/
-package com.google.android.fhir.datacapture.views
+package com.google.android.fhir.datacapture.test.views
import android.util.Base64
import android.view.View
@@ -26,8 +26,9 @@ import androidx.test.ext.junit.rules.ActivityScenarioRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.google.android.fhir.datacapture.R
-import com.google.android.fhir.datacapture.TestActivity
+import com.google.android.fhir.datacapture.test.TestActivity
import com.google.android.fhir.datacapture.validation.NotValidated
+import com.google.android.fhir.datacapture.views.QuestionnaireViewItem
import com.google.android.fhir.datacapture.views.factories.AttachmentViewHolderFactory
import com.google.android.fhir.datacapture.views.factories.QuestionnaireItemViewHolder
import com.google.common.truth.Truth.assertThat
diff --git a/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/views/DateTimePickerViewHolderFactoryEspressoTest.kt b/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/test/views/DateTimePickerViewHolderFactoryEspressoTest.kt
similarity index 89%
rename from datacapture/src/androidTest/java/com/google/android/fhir/datacapture/views/DateTimePickerViewHolderFactoryEspressoTest.kt
rename to datacapture/src/androidTest/java/com/google/android/fhir/datacapture/test/views/DateTimePickerViewHolderFactoryEspressoTest.kt
index 570308552b..d4a19386c5 100644
--- a/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/views/DateTimePickerViewHolderFactoryEspressoTest.kt
+++ b/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/test/views/DateTimePickerViewHolderFactoryEspressoTest.kt
@@ -1,5 +1,5 @@
/*
- * Copyright 2022 Google LLC
+ * Copyright 2023 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -14,7 +14,7 @@
* limitations under the License.
*/
-package com.google.android.fhir.datacapture.views
+package com.google.android.fhir.datacapture.test.views
import android.view.View
import android.widget.FrameLayout
@@ -29,9 +29,10 @@ import androidx.test.ext.junit.rules.ActivityScenarioRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.google.android.fhir.datacapture.R
-import com.google.android.fhir.datacapture.TestActivity
-import com.google.android.fhir.datacapture.utilities.clickIcon
+import com.google.android.fhir.datacapture.test.TestActivity
+import com.google.android.fhir.datacapture.test.utilities.clickIcon
import com.google.android.fhir.datacapture.validation.NotValidated
+import com.google.android.fhir.datacapture.views.QuestionnaireViewItem
import com.google.android.fhir.datacapture.views.factories.DateTimePickerViewHolderFactory
import com.google.android.fhir.datacapture.views.factories.QuestionnaireItemViewHolder
import org.hamcrest.CoreMatchers.allOf
@@ -77,7 +78,7 @@ class DateTimePickerViewHolderFactoryEspressoTest {
.perform(ViewActions.click())
onView(withId(R.id.time_input_edit_text)).perform(ViewActions.click())
// R.id.material_textinput_timepicker is the id for the text input in the time picker.
- onView(allOf(withId(R.id.material_textinput_timepicker)))
+ onView(allOf(withId(com.google.android.material.R.id.material_textinput_timepicker)))
.inRoot(isDialog())
.check(matches(isDisplayed()))
}
@@ -100,7 +101,9 @@ class DateTimePickerViewHolderFactoryEspressoTest {
.perform(ViewActions.click())
onView(withId(R.id.time_input_layout)).perform(clickIcon(true))
// R.id.material_clock_face is the id for the clock input in the time picker.
- onView(allOf(withId(R.id.material_clock_face))).inRoot(isDialog()).check(matches(isDisplayed()))
+ onView(allOf(withId(com.google.android.material.R.id.material_clock_face)))
+ .inRoot(isDialog())
+ .check(matches(isDisplayed()))
}
/** Method to run code snippet on UI/main thread */
diff --git a/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/views/DropDownViewHolderFactoryEspressoTest.kt b/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/test/views/DropDownViewHolderFactoryEspressoTest.kt
similarity index 88%
rename from datacapture/src/androidTest/java/com/google/android/fhir/datacapture/views/DropDownViewHolderFactoryEspressoTest.kt
rename to datacapture/src/androidTest/java/com/google/android/fhir/datacapture/test/views/DropDownViewHolderFactoryEspressoTest.kt
index 5cec7c96e0..daf2eaee74 100644
--- a/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/views/DropDownViewHolderFactoryEspressoTest.kt
+++ b/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/test/views/DropDownViewHolderFactoryEspressoTest.kt
@@ -1,5 +1,5 @@
/*
- * Copyright 2022 Google LLC
+ * Copyright 2023 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -14,11 +14,13 @@
* limitations under the License.
*/
-package com.google.android.fhir.datacapture.views
+package com.google.android.fhir.datacapture.test.views
import android.view.View
+import android.widget.AutoCompleteTextView
import android.widget.FrameLayout
import android.widget.TextView
+import androidx.test.espresso.Espresso.onData
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.action.ViewActions.click
import androidx.test.espresso.action.ViewActions.typeText
@@ -30,19 +32,24 @@ import androidx.test.espresso.matcher.ViewMatchers.withText
import androidx.test.ext.junit.rules.ActivityScenarioRule
import androidx.test.platform.app.InstrumentationRegistry
import com.google.android.fhir.datacapture.R
-import com.google.android.fhir.datacapture.TestActivity
import com.google.android.fhir.datacapture.extensions.EXTENSION_ITEM_ANSWER_MEDIA
-import com.google.android.fhir.datacapture.utilities.showDropDown
+import com.google.android.fhir.datacapture.test.TestActivity
+import com.google.android.fhir.datacapture.test.utilities.delayMainThread
import com.google.android.fhir.datacapture.validation.NotValidated
+import com.google.android.fhir.datacapture.views.QuestionnaireViewItem
+import com.google.android.fhir.datacapture.views.factories.DropDownAnswerOption
import com.google.android.fhir.datacapture.views.factories.DropDownViewHolderFactory
import com.google.android.fhir.datacapture.views.factories.QuestionnaireItemViewHolder
import com.google.android.material.textfield.MaterialAutoCompleteTextView
import com.google.common.truth.Truth.assertThat
+import org.hamcrest.Matchers.instanceOf
+import org.hamcrest.Matchers.`is`
import org.hl7.fhir.r4.model.Attachment
import org.hl7.fhir.r4.model.Coding
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.Reference
import org.hl7.fhir.r4.model.StringType
import org.junit.Before
import org.junit.Rule
@@ -84,9 +91,12 @@ class DropDownViewHolderFactoryEspressoTest {
validationResult = NotValidated,
answersChangedCallback = { _, _, _, _ -> },
)
- runOnUI { viewHolder.bind(questionnaireViewItem) }
+ runOnUI {
+ viewHolder.bind(questionnaireViewItem)
+ viewHolder.itemView.findViewById(R.id.auto_complete).showDropDown()
+ }
- onView(withId(R.id.auto_complete)).perform(showDropDown())
+ onView(withId(R.id.auto_complete)).perform(delayMainThread())
onView(withText("-")).inRoot(isPlatformPopup()).check(matches(isDisplayed())).perform(click())
assertThat(viewHolder.itemView.findViewById(R.id.auto_complete).text.toString())
.isEqualTo("-")
@@ -103,9 +113,12 @@ class DropDownViewHolderFactoryEspressoTest {
validationResult = NotValidated,
answersChangedCallback = { _, _, answers, _ -> answerHolder = answers },
)
- runOnUI { viewHolder.bind(questionnaireViewItem) }
+ runOnUI {
+ viewHolder.bind(questionnaireViewItem)
+ viewHolder.itemView.findViewById(R.id.auto_complete).showDropDown()
+ }
- onView(withId(R.id.auto_complete)).perform(showDropDown())
+ onView(withId(R.id.auto_complete)).perform(delayMainThread())
onView(withText("Coding 3"))
.inRoot(isPlatformPopup())
.check(matches(isDisplayed()))
@@ -125,9 +138,12 @@ class DropDownViewHolderFactoryEspressoTest {
validationResult = NotValidated,
answersChangedCallback = { _, _, answers, _ -> answerHolder = answers },
)
- runOnUI { viewHolder.bind(questionnaireViewItem) }
+ runOnUI {
+ viewHolder.bind(questionnaireViewItem)
+ viewHolder.itemView.findViewById(R.id.auto_complete).showDropDown()
+ }
- onView(withId(R.id.auto_complete)).perform(showDropDown())
+ onView(withId(R.id.auto_complete)).perform(delayMainThread())
onView(withText("Coding 3"))
.inRoot(isPlatformPopup())
.check(matches(isDisplayed()))
@@ -163,9 +179,12 @@ class DropDownViewHolderFactoryEspressoTest {
validationResult = NotValidated,
answersChangedCallback = { _, _, answers, _ -> answerHolder = answers },
)
- runOnUI { viewHolder.bind(questionnaireViewItem) }
+ runOnUI {
+ viewHolder.bind(questionnaireViewItem)
+ viewHolder.itemView.findViewById(R.id.auto_complete).showDropDown()
+ }
- onView(withId(R.id.auto_complete)).perform(showDropDown())
+ onView(withId(R.id.auto_complete)).perform(delayMainThread())
onView(withText("Coding 3"))
.inRoot(isPlatformPopup())
.check(matches(isDisplayed()))
@@ -190,9 +209,12 @@ class DropDownViewHolderFactoryEspressoTest {
validationResult = NotValidated,
answersChangedCallback = { _, _, answers, _ -> answerHolder = answers },
)
- runOnUI { viewHolder.bind(questionnaireViewItem) }
+ runOnUI {
+ viewHolder.bind(questionnaireViewItem)
+ viewHolder.itemView.findViewById(R.id.auto_complete).showDropDown()
+ }
- onView(withId(R.id.auto_complete)).perform(showDropDown())
+ onView(withId(R.id.auto_complete)).perform(delayMainThread())
onView(withText("Coding 1"))
.inRoot(isPlatformPopup())
.check(matches(isDisplayed()))
@@ -211,9 +233,12 @@ class DropDownViewHolderFactoryEspressoTest {
validationResult = NotValidated,
answersChangedCallback = { _, _, _, _ -> },
)
- runOnUI { viewHolder.bind(questionnaireViewItem) }
+ runOnUI {
+ viewHolder.bind(questionnaireViewItem)
+ viewHolder.itemView.findViewById(R.id.auto_complete).showDropDown()
+ }
- onView(withId(R.id.auto_complete)).perform(showDropDown())
+ onView(withId(R.id.auto_complete)).perform(delayMainThread())
assertThat(
viewHolder.itemView
.findViewById(R.id.auto_complete)
@@ -230,9 +255,12 @@ class DropDownViewHolderFactoryEspressoTest {
validationResult = NotValidated,
answersChangedCallback = { _, _, _, _ -> },
)
- runOnUI { viewHolder.bind(questionnaireViewItem) }
+ runOnUI {
+ viewHolder.bind(questionnaireViewItem)
+ viewHolder.itemView.findViewById(R.id.auto_complete).showDropDown()
+ }
- onView(withId(R.id.auto_complete)).perform(showDropDown())
+ onView(withId(R.id.auto_complete)).perform(delayMainThread())
onView(withId(R.id.auto_complete)).perform(typeText("Coding"))
assertThat(
viewHolder.itemView
@@ -251,9 +279,12 @@ class DropDownViewHolderFactoryEspressoTest {
validationResult = NotValidated,
answersChangedCallback = { _, _, _, _ -> },
)
- runOnUI { viewHolder.bind(questionnaireViewItem) }
+ runOnUI {
+ viewHolder.bind(questionnaireViewItem)
+ viewHolder.itemView.findViewById(R.id.auto_complete).showDropDown()
+ }
- onView(withId(R.id.auto_complete)).perform(showDropDown())
+ onView(withId(R.id.auto_complete)).perform(delayMainThread())
onView(withId(R.id.auto_complete)).perform(typeText("Division"))
assertThat(
viewHolder.itemView
@@ -263,6 +294,56 @@ class DropDownViewHolderFactoryEspressoTest {
.isEqualTo(0)
}
+ @Test
+ fun shouldSetCorrectDropDownValueToAutoCompleteTextViewForDifferentAnswerOptionsWithSimilarDisplayString() {
+ val questionnaireItem =
+ Questionnaire.QuestionnaireItemComponent().apply {
+ addAnswerOption(
+ Questionnaire.QuestionnaireItemAnswerOptionComponent().apply {
+ value =
+ Reference().apply {
+ id = "ref_1"
+ display = "Reference"
+ }
+ }
+ )
+ addAnswerOption(
+ Questionnaire.QuestionnaireItemAnswerOptionComponent().apply {
+ value =
+ Reference().apply {
+ id = "ref_2"
+ display = "Reference"
+ }
+ }
+ )
+ }
+
+ var answerHolder: List? = null
+ val questionnaireViewItem =
+ QuestionnaireViewItem(
+ questionnaireItem,
+ responseValueStringOptions(),
+ validationResult = NotValidated,
+ answersChangedCallback = { _, _, answers, _ -> answerHolder = answers }
+ )
+
+ runOnUI {
+ viewHolder.bind(questionnaireViewItem)
+ viewHolder.itemView.findViewById(R.id.auto_complete).showDropDown()
+ }
+
+ onView(withId(R.id.auto_complete)).perform(delayMainThread())
+ onData(`is`(instanceOf(DropDownAnswerOption::class.java)))
+ .atPosition(2)
+ .inRoot(isPlatformPopup())
+ .perform(click())
+
+ assertThat(viewHolder.itemView.findViewById(R.id.auto_complete).text.toString())
+ .isEqualTo("Reference")
+ assertThat((answerHolder!!.single().value as Reference).display).isEqualTo("Reference")
+ assertThat((answerHolder!!.single().value as Reference).id).isEqualTo("ref_2")
+ }
+
/** Method to run code snippet on UI/main thread */
private fun runOnUI(action: () -> Unit) {
activityScenarioRule.scenario.onActivity { action() }
@@ -294,7 +375,11 @@ class DropDownViewHolderFactoryEspressoTest {
responses.forEach { response ->
addAnswer(
QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply {
- value = Coding().apply { display = response }
+ value =
+ Coding().apply {
+ code = response.replace(" ", "_")
+ display = response
+ }
}
)
}
diff --git a/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/views/MediaViewInstrumentedTest.kt b/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/test/views/MediaViewInstrumentedTest.kt
similarity index 96%
rename from datacapture/src/androidTest/java/com/google/android/fhir/datacapture/views/MediaViewInstrumentedTest.kt
rename to datacapture/src/androidTest/java/com/google/android/fhir/datacapture/test/views/MediaViewInstrumentedTest.kt
index b73897d01b..4df421d4a3 100644
--- a/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/views/MediaViewInstrumentedTest.kt
+++ b/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/test/views/MediaViewInstrumentedTest.kt
@@ -1,5 +1,5 @@
/*
- * Copyright 2022 Google LLC
+ * Copyright 2023 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -14,7 +14,7 @@
* limitations under the License.
*/
-package com.google.android.fhir.datacapture.views
+package com.google.android.fhir.datacapture.test.views
import android.util.Base64
import android.view.View
@@ -24,8 +24,9 @@ import androidx.test.ext.junit.rules.ActivityScenarioRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.google.android.fhir.datacapture.R
-import com.google.android.fhir.datacapture.TestActivity
import com.google.android.fhir.datacapture.extensions.EXTENSION_ITEM_MEDIA
+import com.google.android.fhir.datacapture.test.TestActivity
+import com.google.android.fhir.datacapture.views.MediaView
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.runBlocking
import org.hl7.fhir.r4.model.Attachment
diff --git a/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/views/QuestionnaireItemDialogMultiSelectViewHolderFactoryEspressoTest.kt b/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/test/views/QuestionnaireItemDialogMultiSelectViewHolderFactoryEspressoTest.kt
similarity index 95%
rename from datacapture/src/androidTest/java/com/google/android/fhir/datacapture/views/QuestionnaireItemDialogMultiSelectViewHolderFactoryEspressoTest.kt
rename to datacapture/src/androidTest/java/com/google/android/fhir/datacapture/test/views/QuestionnaireItemDialogMultiSelectViewHolderFactoryEspressoTest.kt
index 60c59cfdab..cb6b22c3c5 100644
--- a/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/views/QuestionnaireItemDialogMultiSelectViewHolderFactoryEspressoTest.kt
+++ b/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/test/views/QuestionnaireItemDialogMultiSelectViewHolderFactoryEspressoTest.kt
@@ -1,5 +1,5 @@
/*
- * Copyright 2022 Google LLC
+ * Copyright 2023 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -14,7 +14,7 @@
* limitations under the License.
*/
-package com.google.android.fhir.datacapture.views
+package com.google.android.fhir.datacapture.test.views
import android.view.View
import android.widget.FrameLayout
@@ -22,7 +22,6 @@ import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.action.ViewActions.click
-import androidx.test.espresso.assertion.ViewAssertions
import androidx.test.espresso.assertion.ViewAssertions.doesNotExist
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.contrib.RecyclerViewActions
@@ -32,17 +31,20 @@ import androidx.test.espresso.matcher.ViewMatchers.withId
import androidx.test.ext.junit.rules.ActivityScenarioRule
import androidx.test.platform.app.InstrumentationRegistry
import com.google.android.fhir.datacapture.R
-import com.google.android.fhir.datacapture.TestActivity
import com.google.android.fhir.datacapture.extensions.DisplayItemControlType
import com.google.android.fhir.datacapture.extensions.EXTENSION_ITEM_CONTROL_SYSTEM
import com.google.android.fhir.datacapture.extensions.EXTENSION_ITEM_CONTROL_URL
import com.google.android.fhir.datacapture.extensions.ItemControlTypes
-import com.google.android.fhir.datacapture.utilities.assertQuestionnaireResponseAtIndex
-import com.google.android.fhir.datacapture.utilities.clickOnText
-import com.google.android.fhir.datacapture.utilities.clickOnTextInDialog
-import com.google.android.fhir.datacapture.utilities.endIconClickInTextInputLayout
+import com.google.android.fhir.datacapture.test.TestActivity
+import com.google.android.fhir.datacapture.test.utilities.assertQuestionnaireResponseAtIndex
+import com.google.android.fhir.datacapture.test.utilities.clickOnText
+import com.google.android.fhir.datacapture.test.utilities.clickOnTextInDialog
+import com.google.android.fhir.datacapture.test.utilities.delayMainThread
+import com.google.android.fhir.datacapture.test.utilities.endIconClickInTextInputLayout
import com.google.android.fhir.datacapture.validation.Invalid
import com.google.android.fhir.datacapture.validation.NotValidated
+import com.google.android.fhir.datacapture.views.QuestionTextConfiguration
+import com.google.android.fhir.datacapture.views.QuestionnaireViewItem
import com.google.android.fhir.datacapture.views.factories.QuestionnaireItemDialogSelectViewHolderFactory
import com.google.android.fhir.datacapture.views.factories.QuestionnaireItemViewHolder
import com.google.android.material.textfield.TextInputLayout
@@ -286,7 +288,8 @@ class QuestionnaireItemDialogMultiSelectViewHolderFactoryEspressoTest {
onView(withId(R.id.recycler_view))
.perform(RecyclerViewActions.scrollToPosition(8))
clickOnTextInDialog("Other")
- onView(withId(R.id.add_another)).check(ViewAssertions.matches(ViewMatchers.isDisplayed()))
+ onView(withId(R.id.add_another)).perform(delayMainThread())
+ onView(withId(R.id.add_another)).check(matches(isDisplayed()))
}
@Test
@@ -352,7 +355,8 @@ class QuestionnaireItemDialogMultiSelectViewHolderFactoryEspressoTest {
.perform(RecyclerViewActions.scrollToPosition(8))
clickOnTextInDialog("Other")
onView(withId(R.id.add_another)).perform(click())
- onView(withId(R.id.add_another)).check(ViewAssertions.matches(ViewMatchers.isDisplayed()))
+ onView(withId(R.id.add_another)).perform(delayMainThread())
+ onView(withId(R.id.add_another)).check(matches(isDisplayed()))
}
@Test
diff --git a/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/views/QuestionnaireItemMultiSelectHolderFactoryInstrumentedTest.kt b/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/test/views/QuestionnaireItemMultiSelectHolderFactoryInstrumentedTest.kt
similarity index 96%
rename from datacapture/src/androidTest/java/com/google/android/fhir/datacapture/views/QuestionnaireItemMultiSelectHolderFactoryInstrumentedTest.kt
rename to datacapture/src/androidTest/java/com/google/android/fhir/datacapture/test/views/QuestionnaireItemMultiSelectHolderFactoryInstrumentedTest.kt
index 8a3a2490c7..99fad29772 100644
--- a/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/views/QuestionnaireItemMultiSelectHolderFactoryInstrumentedTest.kt
+++ b/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/test/views/QuestionnaireItemMultiSelectHolderFactoryInstrumentedTest.kt
@@ -1,5 +1,5 @@
/*
- * Copyright 2022 Google LLC
+ * Copyright 2023 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -14,7 +14,7 @@
* limitations under the License.
*/
-package com.google.android.fhir.datacapture.views
+package com.google.android.fhir.datacapture.test.views
import android.widget.FrameLayout
import android.widget.TextView
@@ -22,9 +22,10 @@ import androidx.test.annotation.UiThreadTest
import androidx.test.ext.junit.rules.activityScenarioRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.google.android.fhir.datacapture.R
-import com.google.android.fhir.datacapture.TestActivity
+import com.google.android.fhir.datacapture.test.TestActivity
import com.google.android.fhir.datacapture.validation.Invalid
import com.google.android.fhir.datacapture.validation.NotValidated
+import com.google.android.fhir.datacapture.views.QuestionnaireViewItem
import com.google.android.fhir.datacapture.views.factories.QuestionnaireItemDialogSelectViewHolderFactory
import com.google.android.fhir.datacapture.views.factories.QuestionnaireItemViewHolder
import com.google.android.material.textfield.TextInputLayout
diff --git a/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/test/views/QuestionnaireItemQuantityViewHolderFactoryEspressoTest.kt b/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/test/views/QuestionnaireItemQuantityViewHolderFactoryEspressoTest.kt
new file mode 100644
index 0000000000..f21d88eff2
--- /dev/null
+++ b/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/test/views/QuestionnaireItemQuantityViewHolderFactoryEspressoTest.kt
@@ -0,0 +1,324 @@
+/*
+ * Copyright 2023 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.android.fhir.datacapture.test.views
+
+import android.view.View
+import android.widget.AutoCompleteTextView
+import android.widget.FrameLayout
+import android.widget.TextView
+import androidx.test.espresso.Espresso.onView
+import androidx.test.espresso.action.ViewActions.click
+import androidx.test.espresso.action.ViewActions.typeText
+import androidx.test.espresso.assertion.ViewAssertions
+import androidx.test.espresso.matcher.RootMatchers
+import androidx.test.espresso.matcher.ViewMatchers
+import androidx.test.espresso.matcher.ViewMatchers.withId
+import androidx.test.ext.junit.rules.ActivityScenarioRule
+import androidx.test.platform.app.InstrumentationRegistry
+import com.google.android.fhir.datacapture.R
+import com.google.android.fhir.datacapture.test.TestActivity
+import com.google.android.fhir.datacapture.test.utilities.delayMainThread
+import com.google.android.fhir.datacapture.validation.NotValidated
+import com.google.android.fhir.datacapture.views.QuestionnaireViewItem
+import com.google.android.fhir.datacapture.views.factories.QuantityViewHolderFactory
+import com.google.android.fhir.datacapture.views.factories.QuestionnaireItemViewHolder
+import com.google.common.truth.Truth.assertThat
+import java.math.BigDecimal
+import org.hl7.fhir.r4.model.Coding
+import org.hl7.fhir.r4.model.Extension
+import org.hl7.fhir.r4.model.Questionnaire
+import org.hl7.fhir.r4.model.QuestionnaireResponse
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+
+class QuestionnaireItemQuantityViewHolderFactoryEspressoTest {
+ @Rule
+ @JvmField
+ var activityScenarioRule: ActivityScenarioRule =
+ ActivityScenarioRule(TestActivity::class.java)
+
+ private lateinit var parent: FrameLayout
+ private lateinit var viewHolder: QuestionnaireItemViewHolder
+
+ @Before
+ fun setup() {
+ activityScenarioRule.scenario.onActivity { activity -> parent = FrameLayout(activity) }
+ viewHolder = QuantityViewHolderFactory.create(parent)
+ setTestLayout(viewHolder.itemView)
+ }
+
+ @Test
+ fun shouldSetDraftWithUnit() {
+ var answerHolder: List? = null
+ var draftHolder: Any? = null
+
+ val questionnaireViewItem =
+ QuestionnaireViewItem(
+ Questionnaire.QuestionnaireItemComponent().apply {
+ required = true
+ addExtension(
+ Extension().apply {
+ url = "http://hl7.org/fhir/StructureDefinition/questionnaire-unitOption"
+ setValue(
+ Coding().apply {
+ code = "cm"
+ system = "http://unitofmeasure.com"
+ display = "centimeter"
+ }
+ )
+ }
+ )
+ addExtension(
+ Extension().apply {
+ url = "http://hl7.org/fhir/StructureDefinition/questionnaire-unitOption"
+ setValue(
+ Coding().apply {
+ code = "[in_i]"
+ system = "http://unitofmeasure.com"
+ display = "inch"
+ }
+ )
+ }
+ )
+ },
+ QuestionnaireResponse.QuestionnaireResponseItemComponent(),
+ validationResult = NotValidated,
+ answersChangedCallback = { _, _, answers, draft ->
+ answerHolder = answers
+ draftHolder = draft
+ },
+ )
+
+ runOnUI {
+ viewHolder.bind(questionnaireViewItem)
+ viewHolder.itemView.findViewById(R.id.unit_auto_complete).showDropDown()
+ }
+
+ onView(withId(R.id.unit_auto_complete)).perform(delayMainThread())
+ onView(ViewMatchers.withText("centimeter"))
+ .inRoot(RootMatchers.isPlatformPopup())
+ .check(ViewAssertions.matches(ViewMatchers.isDisplayed()))
+ .perform(click())
+
+ assertThat(viewHolder.itemView.findViewById(R.id.unit_auto_complete).text.toString())
+ .isEqualTo("centimeter")
+
+ with(draftHolder as Coding) {
+ assertThat(system).isEqualTo("http://unitofmeasure.com")
+ assertThat(code).isEqualTo("cm")
+ assertThat(display).isEqualTo("centimeter")
+ }
+ assertThat(answerHolder).isEmpty()
+ }
+
+ @Test
+ fun shouldSetDraftWithDecimalValue() {
+ var answerHolder: List? = null
+ var draftHolder: Any? = null
+
+ val questionnaireViewItem =
+ QuestionnaireViewItem(
+ Questionnaire.QuestionnaireItemComponent().apply {
+ required = true
+ addExtension(
+ Extension().apply {
+ url = "http://hl7.org/fhir/StructureDefinition/questionnaire-unitOption"
+ setValue(
+ Coding().apply {
+ code = "cm"
+ system = "http://unitofmeasure.com"
+ display = "centimeter"
+ }
+ )
+ }
+ )
+ addExtension(
+ Extension().apply {
+ url = "http://hl7.org/fhir/StructureDefinition/questionnaire-unitOption"
+ setValue(
+ Coding().apply {
+ code = "[in_i]"
+ system = "http://unitofmeasure.com"
+ display = "inch"
+ }
+ )
+ }
+ )
+ },
+ QuestionnaireResponse.QuestionnaireResponseItemComponent(),
+ validationResult = NotValidated,
+ answersChangedCallback = { _, _, answers, draft ->
+ answerHolder = answers
+ draftHolder = draft
+ },
+ )
+
+ runOnUI { viewHolder.bind(questionnaireViewItem) }
+
+ onView(withId(R.id.text_input_edit_text)).perform(click())
+ onView(withId(R.id.text_input_edit_text)).perform(typeText("22"))
+
+ assertThat(
+ viewHolder.itemView.findViewById(R.id.text_input_edit_text).text.toString()
+ )
+ .isEqualTo("22")
+
+ assertThat(draftHolder as BigDecimal).isEqualTo(BigDecimal(22))
+ assertThat(answerHolder).isEmpty()
+ }
+
+ @Test
+ fun draftWithUnit_shouldCompleteQuantity() {
+ var answerHolder: List? = null
+ var draftHolder: Any? = null
+
+ val questionnaireViewItem =
+ QuestionnaireViewItem(
+ Questionnaire.QuestionnaireItemComponent().apply {
+ required = true
+ addExtension(
+ Extension().apply {
+ url = "http://hl7.org/fhir/StructureDefinition/questionnaire-unitOption"
+ setValue(
+ Coding().apply {
+ code = "cm"
+ system = "http://unitofmeasure.com"
+ display = "centimeter"
+ }
+ )
+ }
+ )
+ addExtension(
+ Extension().apply {
+ url = "http://hl7.org/fhir/StructureDefinition/questionnaire-unitOption"
+ setValue(
+ Coding().apply {
+ code = "[in_i]"
+ system = "http://unitofmeasure.com"
+ display = "inch"
+ }
+ )
+ }
+ )
+ },
+ QuestionnaireResponse.QuestionnaireResponseItemComponent(),
+ validationResult = NotValidated,
+ answersChangedCallback = { _, _, answers, draft ->
+ answerHolder = answers
+ draftHolder = draft
+ },
+ draftAnswer = Coding("http://unitofmeasure.com", "cm", "centimeter"),
+ )
+
+ runOnUI {
+ viewHolder.bind(questionnaireViewItem)
+ viewHolder.itemView.findViewById(R.id.unit_auto_complete).showDropDown()
+ }
+
+ onView(withId(R.id.text_input_edit_text)).perform(click())
+ onView(withId(R.id.text_input_edit_text)).perform(typeText("22"))
+ assertThat(
+ viewHolder.itemView.findViewById(R.id.text_input_edit_text).text.toString()
+ )
+ .isEqualTo("22")
+
+ with(answerHolder!!.single().valueQuantity) {
+ assertThat(system).isEqualTo("http://unitofmeasure.com")
+ assertThat(code).isEqualTo("cm")
+ assertThat(unit).isEqualTo("centimeter")
+ assertThat(value).isEqualTo(BigDecimal("22.0"))
+ }
+ assertThat(draftHolder).isNull()
+ }
+
+ @Test
+ fun draftWithDecimalValue_shouldCompleteQuantity() {
+ var answerHolder: List? = null
+ var draftHolder: Any? = null
+
+ val questionnaireViewItem =
+ QuestionnaireViewItem(
+ Questionnaire.QuestionnaireItemComponent().apply {
+ required = true
+ addExtension(
+ Extension().apply {
+ url = "http://hl7.org/fhir/StructureDefinition/questionnaire-unitOption"
+ setValue(
+ Coding().apply {
+ code = "cm"
+ system = "http://unitofmeasure.com"
+ display = "centimeter"
+ }
+ )
+ }
+ )
+ addExtension(
+ Extension().apply {
+ url = "http://hl7.org/fhir/StructureDefinition/questionnaire-unitOption"
+ setValue(
+ Coding().apply {
+ code = "[in_i]"
+ system = "http://unitofmeasure.com"
+ display = "inch"
+ }
+ )
+ }
+ )
+ },
+ QuestionnaireResponse.QuestionnaireResponseItemComponent(),
+ validationResult = NotValidated,
+ answersChangedCallback = { _, _, answers, draft ->
+ answerHolder = answers
+ draftHolder = draft
+ },
+ draftAnswer = BigDecimal(22),
+ )
+
+ runOnUI {
+ viewHolder.bind(questionnaireViewItem)
+ viewHolder.itemView.findViewById(R.id.unit_auto_complete).showDropDown()
+ }
+
+ onView(withId(R.id.unit_auto_complete)).perform(delayMainThread())
+ onView(ViewMatchers.withText("centimeter"))
+ .inRoot(RootMatchers.isPlatformPopup())
+ .check(ViewAssertions.matches(ViewMatchers.isDisplayed()))
+ .perform(click())
+ assertThat(viewHolder.itemView.findViewById(R.id.unit_auto_complete).text.toString())
+ .isEqualTo("centimeter")
+
+ with(answerHolder!!.single().valueQuantity) {
+ assertThat(system).isEqualTo("http://unitofmeasure.com")
+ assertThat(code).isEqualTo("cm")
+ assertThat(unit).isEqualTo("centimeter")
+ assertThat(value).isEqualTo(BigDecimal("22.0"))
+ }
+ assertThat(draftHolder).isNull()
+ }
+
+ /** Method to run code snippet on UI/main thread */
+ private fun runOnUI(action: () -> Unit) {
+ activityScenarioRule.scenario.onActivity { action() }
+ }
+
+ /** Method to set content view for test activity */
+ private fun setTestLayout(view: View) {
+ activityScenarioRule.scenario.onActivity { activity -> activity.setContentView(view) }
+ InstrumentationRegistry.getInstrumentation().waitForIdleSync()
+ }
+}
diff --git a/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/utilities/AutoCompleteTextViewAction.kt b/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/utilities/AutoCompleteTextViewAction.kt
deleted file mode 100644
index 082d7b43bc..0000000000
--- a/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/utilities/AutoCompleteTextViewAction.kt
+++ /dev/null
@@ -1,45 +0,0 @@
-/*
- * Copyright 2021 Google LLC
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package com.google.android.fhir.datacapture.utilities
-
-import android.view.View
-import android.widget.AutoCompleteTextView
-import androidx.test.espresso.UiController
-import androidx.test.espresso.ViewAction
-import androidx.test.espresso.matcher.ViewMatchers.isAssignableFrom
-import androidx.test.espresso.matcher.ViewMatchers.isEnabled
-import org.hamcrest.Matcher
-import org.hamcrest.Matchers.allOf
-
-/** Show Drop Down view for AutoCompleteTextView widget. */
-fun showDropDown(): ViewAction {
- return object : ViewAction {
- override fun getConstraints(): Matcher =
- allOf(isEnabled(), isAssignableFrom(AutoCompleteTextView::class.java))
-
- override fun getDescription(): String {
- return "show DropDown"
- }
-
- override fun perform(uiController: UiController, view: View?) {
- val autoCompleteTextView = view as AutoCompleteTextView
- autoCompleteTextView.showDropDown()
- // Avoid test flakiness with a delay. See https://github.com/google/android-fhir/issues/1323.
- uiController.loopMainThreadForAtLeast(1000)
- }
- }
-}
diff --git a/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/views/QuestionnaireItemEditTextQuantityViewHolderFactoryEspressoTest.kt b/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/views/QuestionnaireItemEditTextQuantityViewHolderFactoryEspressoTest.kt
deleted file mode 100644
index da7a5ecd4f..0000000000
--- a/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/views/QuestionnaireItemEditTextQuantityViewHolderFactoryEspressoTest.kt
+++ /dev/null
@@ -1,128 +0,0 @@
-/*
- * Copyright 2022 Google LLC
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package com.google.android.fhir.datacapture.views
-
-import android.view.View
-import android.widget.FrameLayout
-import android.widget.TextView
-import androidx.test.espresso.Espresso.onView
-import androidx.test.espresso.action.ViewActions.click
-import androidx.test.espresso.action.ViewActions.typeText
-import androidx.test.espresso.matcher.ViewMatchers.withId
-import androidx.test.ext.junit.rules.ActivityScenarioRule
-import androidx.test.platform.app.InstrumentationRegistry
-import com.google.android.fhir.datacapture.R
-import com.google.android.fhir.datacapture.TestActivity
-import com.google.android.fhir.datacapture.validation.NotValidated
-import com.google.android.fhir.datacapture.views.factories.EditTextQuantityViewHolderFactory
-import com.google.android.fhir.datacapture.views.factories.QuestionnaireItemViewHolder
-import com.google.common.truth.Truth.assertThat
-import java.math.BigDecimal
-import org.hl7.fhir.r4.model.Quantity
-import org.hl7.fhir.r4.model.Questionnaire
-import org.hl7.fhir.r4.model.QuestionnaireResponse
-import org.junit.Before
-import org.junit.Rule
-import org.junit.Test
-
-class QuestionnaireItemEditTextQuantityViewHolderFactoryEspressoTest {
- @Rule
- @JvmField
- var activityScenarioRule: ActivityScenarioRule =
- ActivityScenarioRule(TestActivity::class.java)
-
- private lateinit var parent: FrameLayout
- private lateinit var viewHolder: QuestionnaireItemViewHolder
-
- @Before
- fun setup() {
- activityScenarioRule.scenario.onActivity { activity -> parent = FrameLayout(activity) }
- viewHolder = EditTextQuantityViewHolderFactory.create(parent)
- setTestLayout(viewHolder.itemView)
- }
-
- @Test
- fun getValue_WithInitial_shouldReturn_Quantity_With_UnitAndSystem() {
- var answerHolder: List? = null
- val questionnaireViewItem =
- QuestionnaireViewItem(
- Questionnaire.QuestionnaireItemComponent().apply {
- required = true
- addInitial(
- Questionnaire.QuestionnaireItemInitialComponent(
- Quantity().apply {
- code = "months"
- system = "http://unitofmeasure.com"
- }
- )
- )
- },
- QuestionnaireResponse.QuestionnaireResponseItemComponent(),
- validationResult = NotValidated,
- answersChangedCallback = { _, _, answers, _ -> answerHolder = answers },
- )
- runOnUI { viewHolder.bind(questionnaireViewItem) }
-
- onView(withId(R.id.text_input_edit_text)).perform(click())
- onView(withId(R.id.text_input_edit_text)).perform(typeText("22"))
- assertThat(
- viewHolder.itemView.findViewById(R.id.text_input_edit_text).text.toString()
- )
- .isEqualTo("22")
-
- val responseValue = answerHolder!!.first().valueQuantity
- assertThat(responseValue.code).isEqualTo("months")
- assertThat(responseValue.system).isEqualTo("http://unitofmeasure.com")
- assertThat(responseValue.value).isEqualTo(BigDecimal(22))
- }
-
- @Test
- fun getValue_WithoutInitial_shouldReturn_Quantity_Without_UnitAndSystem() {
- var answerHolder: List? = null
- val questionnaireViewItem =
- QuestionnaireViewItem(
- Questionnaire.QuestionnaireItemComponent().apply { required = true },
- QuestionnaireResponse.QuestionnaireResponseItemComponent(),
- validationResult = NotValidated,
- answersChangedCallback = { _, _, answers, _ -> answerHolder = answers },
- )
- runOnUI { viewHolder.bind(questionnaireViewItem) }
-
- onView(withId(R.id.text_input_edit_text)).perform(click())
- onView(withId(R.id.text_input_edit_text)).perform(typeText("22"))
- assertThat(
- viewHolder.itemView.findViewById(R.id.text_input_edit_text).text.toString()
- )
- .isEqualTo("22")
-
- val responseValue = answerHolder!!.first().valueQuantity
- assertThat(responseValue.code).isNull()
- assertThat(responseValue.system).isNull()
- assertThat(responseValue.value).isEqualTo(BigDecimal(22))
- }
-
- /** Method to run code snippet on UI/main thread */
- private fun runOnUI(action: () -> Unit) {
- activityScenarioRule.scenario.onActivity { action() }
- }
-
- /** Method to set content view for test activity */
- private fun setTestLayout(view: View) {
- activityScenarioRule.scenario.onActivity { activity -> activity.setContentView(view) }
- InstrumentationRegistry.getInstrumentation().waitForIdleSync()
- }
-}
diff --git a/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/views/factories/AutoCompleteViewHolderFactoryEspressoTest.kt b/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/views/factories/AutoCompleteViewHolderFactoryEspressoTest.kt
new file mode 100644
index 0000000000..8d09d87083
--- /dev/null
+++ b/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/views/factories/AutoCompleteViewHolderFactoryEspressoTest.kt
@@ -0,0 +1,168 @@
+/*
+ * Copyright 2023 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.android.fhir.datacapture.views.factories
+
+import android.view.View
+import android.widget.AutoCompleteTextView
+import android.widget.FrameLayout
+import android.widget.TextView
+import androidx.test.espresso.Espresso.onView
+import androidx.test.espresso.action.ViewActions
+import androidx.test.espresso.assertion.ViewAssertions
+import androidx.test.espresso.matcher.RootMatchers
+import androidx.test.espresso.matcher.ViewMatchers
+import androidx.test.ext.junit.rules.ActivityScenarioRule
+import androidx.test.platform.app.InstrumentationRegistry
+import com.google.android.fhir.datacapture.R
+import com.google.android.fhir.datacapture.test.TestActivity
+import com.google.android.fhir.datacapture.test.utilities.delayMainThread
+import com.google.android.fhir.datacapture.validation.NotValidated
+import com.google.android.fhir.datacapture.views.QuestionnaireViewItem
+import com.google.android.material.chip.ChipGroup
+import com.google.android.material.textfield.MaterialAutoCompleteTextView
+import com.google.common.truth.Truth.assertThat
+import org.hl7.fhir.r4.model.Coding
+import org.hl7.fhir.r4.model.Questionnaire
+import org.hl7.fhir.r4.model.QuestionnaireResponse
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+
+class AutoCompleteViewHolderFactoryEspressoTest {
+ @Rule
+ @JvmField
+ var activityScenarioRule: ActivityScenarioRule =
+ ActivityScenarioRule(TestActivity::class.java)
+
+ private lateinit var parent: FrameLayout
+ private lateinit var viewHolder: QuestionnaireItemViewHolder
+
+ @Before
+ fun setup() {
+ activityScenarioRule.scenario.onActivity { activity -> parent = FrameLayout(activity) }
+ viewHolder = AutoCompleteViewHolderFactory.create(parent)
+ setTestLayout(viewHolder.itemView)
+ }
+
+ @Test
+ fun shouldReturnFilteredDropDownMenuItems() {
+ val questionnaireViewItem =
+ QuestionnaireViewItem(
+ answerOptions(false, "Coding 1", "Coding 2", "Coding 3", "Coding 4", "Coding 5"),
+ responseOptions(),
+ validationResult = NotValidated,
+ answersChangedCallback = { _, _, _, _ -> },
+ )
+ runOnUI { viewHolder.bind(questionnaireViewItem) }
+
+ onView(ViewMatchers.withId(R.id.autoCompleteTextView)).perform(ViewActions.typeText("Coding 1"))
+ assertThat(
+ viewHolder.itemView
+ .findViewById(R.id.autoCompleteTextView)
+ .adapter.count
+ )
+ .isEqualTo(1)
+ }
+
+ @Test
+ fun shouldAddDropDownValueSelectedForMultipleAnswersAutoCompleteTextView() {
+ var answerHolder: List? = null
+ val questionnaireViewItem =
+ QuestionnaireViewItem(
+ answerOptions(true, "Coding 1", "Coding 2", "Coding 3", "Coding 4", "Coding 5"),
+ responseOptions("Coding 1", "Coding 5"),
+ validationResult = NotValidated,
+ answersChangedCallback = { _, _, answers, _ -> answerHolder = answers },
+ )
+ runOnUI { viewHolder.bind(questionnaireViewItem) }
+
+ onView(ViewMatchers.withId(R.id.autoCompleteTextView)).perform(ViewActions.typeText("Coding 3"))
+ runOnUI {
+ viewHolder.itemView
+ .findViewById(R.id.autoCompleteTextView)
+ .showDropDown()
+ }
+ onView(ViewMatchers.withId(R.id.autoCompleteTextView)).perform(delayMainThread())
+ onView(ViewMatchers.withText("Coding 3"))
+ .inRoot(RootMatchers.isPlatformPopup())
+ .check(ViewAssertions.matches(ViewMatchers.isDisplayed()))
+ .perform(ViewActions.click())
+ assertThat(
+ viewHolder.itemView.findViewById(R.id.autoCompleteTextView).text.toString()
+ )
+ .isEmpty()
+ assertThat(answerHolder!!.map { it.valueCoding.display })
+ .containsExactly("Coding 1", "Coding 5", "Coding 3")
+ }
+
+ @Test
+ fun shouldSetCorrectNumberOfChipsForSelectedAnswers() {
+ val questionnaireViewItem =
+ QuestionnaireViewItem(
+ answerOptions(true, "Coding 1", "Coding 2", "Coding 3", "Coding 4", "Coding 5"),
+ responseOptions("Coding 1", "Coding 5"),
+ validationResult = NotValidated,
+ answersChangedCallback = { _, _, _, _ -> },
+ )
+ runOnUI { viewHolder.bind(questionnaireViewItem) }
+
+ assertThat(viewHolder.itemView.findViewById(R.id.chipContainer).childCount)
+ .isEqualTo(2)
+ }
+
+ /** Method to run code snippet on UI/main thread */
+ private fun runOnUI(action: () -> Unit) {
+ activityScenarioRule.scenario.onActivity { action() }
+ }
+
+ /** Method to set content view for test activity */
+ private fun setTestLayout(view: View) {
+ activityScenarioRule.scenario.onActivity { activity -> activity.setContentView(view) }
+ InstrumentationRegistry.getInstrumentation().waitForIdleSync()
+ }
+
+ private fun answerOptions(repeats: Boolean, vararg options: String) =
+ Questionnaire.QuestionnaireItemComponent().apply {
+ this.repeats = repeats
+ options.forEach { option ->
+ addAnswerOption(
+ Questionnaire.QuestionnaireItemAnswerOptionComponent().apply {
+ value =
+ Coding().apply {
+ code = option.replace(" ", "_")
+ display = option
+ }
+ }
+ )
+ }
+ }
+
+ private fun responseOptions(vararg options: String) =
+ QuestionnaireResponse.QuestionnaireResponseItemComponent().apply {
+ options.forEach { option ->
+ addAnswer(
+ QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply {
+ value =
+ Coding().apply {
+ code = option.replace(" ", "_")
+ display = option
+ }
+ }
+ )
+ }
+ }
+}
diff --git a/datacapture/src/main/AndroidManifest.xml b/datacapture/src/main/AndroidManifest.xml
deleted file mode 100644
index ac577a551f..0000000000
--- a/datacapture/src/main/AndroidManifest.xml
+++ /dev/null
@@ -1,22 +0,0 @@
-
-
-
-
-
diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireEditAdapter.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireEditAdapter.kt
index 540c4e65b9..58abf7a41a 100644
--- a/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireEditAdapter.kt
+++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireEditAdapter.kt
@@ -1,5 +1,5 @@
/*
- * Copyright 2022 Google LLC
+ * Copyright 2022-2023 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -33,9 +33,9 @@ import com.google.android.fhir.datacapture.views.factories.DropDownViewHolderFac
import com.google.android.fhir.datacapture.views.factories.EditTextDecimalViewHolderFactory
import com.google.android.fhir.datacapture.views.factories.EditTextIntegerViewHolderFactory
import com.google.android.fhir.datacapture.views.factories.EditTextMultiLineViewHolderFactory
-import com.google.android.fhir.datacapture.views.factories.EditTextQuantityViewHolderFactory
import com.google.android.fhir.datacapture.views.factories.EditTextSingleLineViewHolderFactory
import com.google.android.fhir.datacapture.views.factories.GroupViewHolderFactory
+import com.google.android.fhir.datacapture.views.factories.QuantityViewHolderFactory
import com.google.android.fhir.datacapture.views.factories.QuestionnaireItemDialogSelectViewHolderFactory
import com.google.android.fhir.datacapture.views.factories.QuestionnaireItemViewHolder
import com.google.android.fhir.datacapture.views.factories.RadioGroupViewHolderFactory
@@ -86,7 +86,7 @@ internal class QuestionnaireEditAdapter(
QuestionnaireViewHolderType.RADIO_GROUP -> RadioGroupViewHolderFactory
QuestionnaireViewHolderType.DROP_DOWN -> DropDownViewHolderFactory
QuestionnaireViewHolderType.DISPLAY -> DisplayViewHolderFactory
- QuestionnaireViewHolderType.QUANTITY -> EditTextQuantityViewHolderFactory
+ QuestionnaireViewHolderType.QUANTITY -> QuantityViewHolderFactory
QuestionnaireViewHolderType.CHECK_BOX_GROUP -> CheckBoxGroupViewHolderFactory
QuestionnaireViewHolderType.AUTO_COMPLETE -> AutoCompleteViewHolderFactory
QuestionnaireViewHolderType.DIALOG_SELECT -> QuestionnaireItemDialogSelectViewHolderFactory
@@ -169,7 +169,7 @@ internal class QuestionnaireEditAdapter(
}
}
- if (questionnaireViewItem.answerOption.isNotEmpty()) {
+ if (questionnaireViewItem.enabledAnswerOptions.isNotEmpty()) {
return getChoiceViewHolderType(questionnaireViewItem).value
}
@@ -200,7 +200,7 @@ internal class QuestionnaireEditAdapter(
return questionnaireItem.itemControl?.viewHolderType
// Otherwise, choose a sensible UI element automatically
?: run {
- val numOptions = questionnaireViewItem.answerOption.size
+ val numOptions = questionnaireViewItem.enabledAnswerOptions.size
when {
// Always use a dialog for questions with a large number of options
numOptions >= MINIMUM_NUMBER_OF_ANSWER_OPTIONS_FOR_DIALOG ->
diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireFragment.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireFragment.kt
index 76cc8bae8a..66d910042b 100644
--- a/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireFragment.kt
+++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireFragment.kt
@@ -1,5 +1,5 @@
/*
- * Copyright 2022 Google LLC
+ * Copyright 2023 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -216,6 +216,16 @@ class QuestionnaireFragment : Fragment() {
)
}
}
+ is DisplayMode.InitMode -> {
+ questionnaireReviewRecyclerView.visibility = View.GONE
+ questionnaireEditRecyclerView.visibility = View.GONE
+ paginationPreviousButton.visibility = View.GONE
+ paginationNextButton.visibility = View.GONE
+ questionnaireProgressIndicator.visibility = View.GONE
+ submitButton.visibility = View.GONE
+ reviewModeButton.visibility = View.GONE
+ reviewModeEditButton.visibility = View.GONE
+ }
}
}
}
diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireValidationErrorMessageDialogFragment.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireValidationErrorMessageDialogFragment.kt
index 3de39507dd..9f79ef3fa9 100644
--- a/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireValidationErrorMessageDialogFragment.kt
+++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireValidationErrorMessageDialogFragment.kt
@@ -1,5 +1,5 @@
/*
- * Copyright 2022 Google LLC
+ * Copyright 2023 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -82,7 +82,7 @@ internal class QuestionnaireValidationErrorMessageDialogFragment(
.apply {
findViewById(R.id.body).apply {
val viewModel: QuestionnaireValidationErrorViewModel by
- activityViewModels(factoryProducer)
+ activityViewModels(factoryProducer = factoryProducer)
text =
viewModel.getItemsTextWithValidationErrors().joinToString(separator = "\n") {
context.getString(R.string.questionnaire_validation_error_item_text_with_bullet, it)
diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireViewHolderType.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireViewHolderType.kt
index 6f62a0b366..f31474062f 100644
--- a/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireViewHolderType.kt
+++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireViewHolderType.kt
@@ -1,5 +1,5 @@
/*
- * Copyright 2022 Google LLC
+ * Copyright 2023 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -26,7 +26,7 @@ package com.google.android.fhir.datacapture
* https://www.hl7.org/fhir/valueset-item-type.html and
* http://hl7.org/fhir/R4/valueset-questionnaire-item-control.html.
*/
-internal enum class QuestionnaireViewHolderType(val value: Int) {
+enum class QuestionnaireViewHolderType(val value: Int) {
GROUP(0),
BOOLEAN_TYPE_PICKER(1),
DATE_PICKER(2),
diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireViewModel.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireViewModel.kt
index 91dc1868a5..aa0361e303 100644
--- a/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireViewModel.kt
+++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireViewModel.kt
@@ -1,5 +1,5 @@
/*
- * Copyright 2022 Google LLC
+ * Copyright 2023 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -26,21 +26,19 @@ import ca.uhn.fhir.context.FhirContext
import ca.uhn.fhir.context.FhirVersionEnum
import ca.uhn.fhir.parser.IParser
import com.google.android.fhir.datacapture.enablement.EnablementEvaluator
+import com.google.android.fhir.datacapture.expressions.EnabledAnswerOptionsEvaluator
import com.google.android.fhir.datacapture.extensions.EntryMode
import com.google.android.fhir.datacapture.extensions.addNestedItemsToAnswer
import com.google.android.fhir.datacapture.extensions.allItems
-import com.google.android.fhir.datacapture.extensions.answerExpression
import com.google.android.fhir.datacapture.extensions.cqfExpression
import com.google.android.fhir.datacapture.extensions.createQuestionnaireResponseItem
import com.google.android.fhir.datacapture.extensions.entryMode
-import com.google.android.fhir.datacapture.extensions.extractAnswerOptions
import com.google.android.fhir.datacapture.extensions.flattened
import com.google.android.fhir.datacapture.extensions.hasDifferentAnswerSet
import com.google.android.fhir.datacapture.extensions.isDisplayItem
import com.google.android.fhir.datacapture.extensions.isFhirPath
import com.google.android.fhir.datacapture.extensions.isHidden
import com.google.android.fhir.datacapture.extensions.isPaginated
-import com.google.android.fhir.datacapture.extensions.isXFhirQuery
import com.google.android.fhir.datacapture.extensions.localizedTextSpanned
import com.google.android.fhir.datacapture.extensions.packRepeatedGroups
import com.google.android.fhir.datacapture.extensions.questionnaireLaunchContexts
@@ -48,11 +46,9 @@ import com.google.android.fhir.datacapture.extensions.shouldHaveNestedItemsUnder
import com.google.android.fhir.datacapture.extensions.unpackRepeatedGroups
import com.google.android.fhir.datacapture.extensions.validateLaunchContextExtensions
import com.google.android.fhir.datacapture.extensions.zipByLinkId
-import com.google.android.fhir.datacapture.fhirpath.ExpressionEvaluator
import com.google.android.fhir.datacapture.fhirpath.ExpressionEvaluator.detectExpressionCyclicDependency
import com.google.android.fhir.datacapture.fhirpath.ExpressionEvaluator.evaluateCalculatedExpressions
import com.google.android.fhir.datacapture.fhirpath.ExpressionEvaluator.evaluateExpression
-import com.google.android.fhir.datacapture.fhirpath.fhirPathEngine
import com.google.android.fhir.datacapture.validation.Invalid
import com.google.android.fhir.datacapture.validation.NotValidated
import com.google.android.fhir.datacapture.validation.QuestionnaireResponseItemValidator
@@ -66,20 +62,19 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.flow.withIndex
import org.hl7.fhir.r4.model.Base
-import org.hl7.fhir.r4.model.Coding
import org.hl7.fhir.r4.model.Element
-import org.hl7.fhir.r4.model.Expression
import org.hl7.fhir.r4.model.Questionnaire
import org.hl7.fhir.r4.model.Questionnaire.QuestionnaireItemComponent
import org.hl7.fhir.r4.model.QuestionnaireResponse
import org.hl7.fhir.r4.model.QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent
import org.hl7.fhir.r4.model.QuestionnaireResponse.QuestionnaireResponseItemComponent
import org.hl7.fhir.r4.model.Resource
-import org.hl7.fhir.r4.model.ResourceType
-import org.hl7.fhir.r4.model.ValueSet
import timber.log.Timber
internal class QuestionnaireViewModel(application: Application, state: SavedStateHandle) :
@@ -89,6 +84,9 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat
private val xFhirQueryResolver: XFhirQueryResolver? by lazy {
DataCapture.getConfiguration(application).xFhirQueryResolver
}
+ private val externalValueSetResolver: ExternalAnswerValueSetResolver? by lazy {
+ DataCapture.getConfiguration(application).valueSetResolverExternal
+ }
/** The current questionnaire as questions are being answered. */
internal val questionnaire: Questionnaire
@@ -134,6 +132,7 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat
questionnaireResponse =
parser.parseResource(application.contentResolver.openInputStream(uri))
as QuestionnaireResponse
+ addMissingResponseItems(questionnaire.item, questionnaireResponse.item)
checkQuestionnaireResponse(questionnaire, questionnaireResponse)
}
state.contains(QuestionnaireFragment.EXTRA_QUESTIONNAIRE_RESPONSE_JSON_STRING) -> {
@@ -141,6 +140,7 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat
state[QuestionnaireFragment.EXTRA_QUESTIONNAIRE_RESPONSE_JSON_STRING]!!
questionnaireResponse =
parser.parseResource(questionnaireResponseJson) as QuestionnaireResponse
+ addMissingResponseItems(questionnaire.item, questionnaireResponse.item)
checkQuestionnaireResponse(questionnaire, questionnaireResponse)
}
else -> {
@@ -335,17 +335,49 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat
modificationCount.update { it + 1 }
}
- private val answerValueSetMap =
- mutableMapOf>()
+ private val answerOptionsEvaluator: EnabledAnswerOptionsEvaluator =
+ EnabledAnswerOptionsEvaluator(
+ questionnaire,
+ questionnaireLaunchContextMap,
+ xFhirQueryResolver,
+ externalValueSetResolver
+ )
/**
- * The answer expression referencing an x-fhir-query has its evaluated data cached to avoid
- * reloading resources unnecessarily. The value is updated each time an item with answer
- * expression is evaluating the latest answer options.
+ * Adds empty [QuestionnaireResponseItemComponent]s to `responseItems` so that each
+ * [QuestionnaireItemComponent] in `questionnaireItems` has at least one corresponding
+ * [QuestionnaireResponseItemComponent]. This is because user-provided [QuestionnaireResponse]
+ * might not contain answers to unanswered or disabled questions. Note : this only applies to
+ * [QuestionnaireItemComponent]s nested under a group.
*/
- private val answerExpressionMap =
- mutableMapOf>()
-
+ private fun addMissingResponseItems(
+ questionnaireItems: List,
+ responseItems: MutableList,
+ ) {
+ // To associate the linkId to QuestionnaireResponseItemComponent, do not use associateBy().
+ // Instead, use groupBy().
+ // This is because a questionnaire response may have multiple
+ // QuestionnaireResponseItemComponents with the same linkId.
+ val responseItemMap = responseItems.groupBy { it.linkId }
+
+ // Clear the response item list, and then add the missing and existing response items back to
+ // the list
+ responseItems.clear()
+
+ questionnaireItems.forEach {
+ if (responseItemMap[it.linkId].isNullOrEmpty()) {
+ responseItems.add(it.createQuestionnaireResponseItem())
+ } else {
+ if (it.type == Questionnaire.QuestionnaireItemType.GROUP && !it.repeats) {
+ addMissingResponseItems(
+ questionnaireItems = it.item,
+ responseItems = responseItemMap[it.linkId]!!.single().item,
+ )
+ }
+ responseItems.addAll(responseItemMap[it.linkId]!!)
+ }
+ }
+ }
/**
* Returns current [QuestionnaireResponse] captured by the UI which includes answers of enabled
* questions.
@@ -445,20 +477,23 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat
combine(modificationCount, currentPageIndexFlow, isInReviewModeFlow) { _, _, _ ->
getQuestionnaireState()
}
+ .withIndex()
+ .onEach {
+ if (it.index == 0) {
+ detectExpressionCyclicDependency(questionnaire.item)
+ questionnaire.item.flattened().forEach { qItem ->
+ updateDependentQuestionnaireResponseItems(
+ qItem,
+ questionnaireResponse.allItems.find { qrItem -> qrItem.linkId == qItem.linkId }
+ )
+ }
+ }
+ }
+ .map { it.value }
.stateIn(
viewModelScope,
SharingStarted.Lazily,
- initialValue =
- getQuestionnaireState()
- .also { detectExpressionCyclicDependency(questionnaire.item) }
- .also {
- questionnaire.item.flattened().forEach { qItem ->
- updateDependentQuestionnaireResponseItems(
- qItem,
- questionnaireResponse.allItems.find { it.linkId == qItem.linkId }
- )
- }
- }
+ initialValue = QuestionnaireState(items = emptyList(), displayMode = DisplayMode.InitMode)
)
private fun updateDependentQuestionnaireResponseItems(
@@ -493,65 +528,6 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat
}
}
- internal suspend fun resolveAnswerValueSet(
- uri: String,
- ): List {
- // If cache hit, return it
- if (answerValueSetMap.contains(uri)) {
- return answerValueSetMap[uri]!!
- }
-
- val options =
- if (uri.startsWith("#")) {
- questionnaire.contained
- .firstOrNull { resource ->
- resource.id.equals(uri) &&
- resource.resourceType == ResourceType.ValueSet &&
- (resource as ValueSet).hasExpansion()
- }
- ?.let { resource ->
- val valueSet = resource as ValueSet
- valueSet.expansion.contains
- .filterNot { it.abstract || it.inactive }
- .map { component ->
- Questionnaire.QuestionnaireItemAnswerOptionComponent(
- Coding(component.system, component.code, component.display)
- )
- }
- }
- } else {
- // Ask the client to provide the answers from an external expanded Valueset.
- DataCapture.getConfiguration(getApplication())
- .valueSetResolverExternal
- ?.resolve(uri)
- ?.map { coding -> Questionnaire.QuestionnaireItemAnswerOptionComponent(coding.copy()) }
- }
- ?: emptyList()
- // save it so that we avoid have cache misses.
- answerValueSetMap[uri] = options
- return options
- }
-
- // TODO persist previous answers in case options are changing and new list does not have selected
- // answer and FHIRPath in x-fhir-query
- // https://build.fhir.org/ig/HL7/sdc/expressions.html#x-fhir-query-enhancements
- internal suspend fun resolveAnswerExpression(
- item: QuestionnaireItemComponent,
- ): List {
- // Check cache first for database queries
- val answerExpression = item.answerExpression ?: return emptyList()
- if (answerExpression.isXFhirQuery && answerExpressionMap.contains(answerExpression.expression)
- ) {
- return answerExpressionMap[answerExpression.expression]!!
- }
-
- val options = loadAnswerExpressionOptions(item, answerExpression)
-
- if (answerExpression.isXFhirQuery) answerExpressionMap[answerExpression.expression] = options
-
- return options
- }
-
private fun resolveCqfExpression(
questionnaireItem: QuestionnaireItemComponent,
questionnaireResponseItem: QuestionnaireResponseItemComponent,
@@ -572,31 +548,16 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat
)
}
- private suspend fun loadAnswerExpressionOptions(
- item: QuestionnaireItemComponent,
- expression: Expression,
- ): List {
- val data =
- if (expression.isXFhirQuery) {
- checkNotNull(xFhirQueryResolver) {
- "XFhirQueryResolver cannot be null. Please provide the XFhirQueryResolver via DataCaptureConfig."
- }
-
- val xFhirExpressionString =
- ExpressionEvaluator.createXFhirQueryFromExpression(
- expression,
- questionnaireLaunchContextMap
- )
- xFhirQueryResolver!!.resolve(xFhirExpressionString)
- } else if (expression.isFhirPath) {
- fhirPathEngine.evaluate(questionnaireResponse, expression.expression)
- } else {
- throw UnsupportedOperationException(
- "${expression.language} not supported for answer-expression yet"
- )
+ private fun removeDisabledAnswers(
+ questionnaireItem: QuestionnaireItemComponent,
+ questionnaireResponseItem: QuestionnaireResponseItemComponent,
+ disabledAnswers: List
+ ) {
+ val validAnswers =
+ questionnaireResponseItem.answer.filterNot { ans ->
+ disabledAnswers.any { ans.value.equalsDeep(it.value) }
}
-
- return item.extractAnswerOptions(data)
+ answersChangedCallback(questionnaireItem, questionnaireResponseItem, validAnswers, null)
}
/**
@@ -607,7 +568,7 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat
*
* The traverse is carried out in the two lists in tandem.
*/
- private fun getQuestionnaireState(): QuestionnaireState {
+ private suspend fun getQuestionnaireState(): QuestionnaireState {
val questionnaireItemList = questionnaire.item
val questionnaireResponseItemList = questionnaireResponse.item
@@ -670,7 +631,7 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat
* Returns the list of [QuestionnaireViewItem]s generated for the questionnaire items and
* questionnaire response items.
*/
- private fun getQuestionnaireAdapterItems(
+ private suspend fun getQuestionnaireAdapterItems(
questionnaireItemList: List,
questionnaireResponseItemList: List,
): List {
@@ -685,7 +646,7 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat
* Returns the list of [QuestionnaireViewItem]s generated for the questionnaire item and
* questionnaire response item.
*/
- private fun getQuestionnaireAdapterItems(
+ private suspend fun getQuestionnaireAdapterItems(
questionnaireItem: QuestionnaireItemComponent,
questionnaireResponseItem: QuestionnaireResponseItemComponent,
): List {
@@ -723,6 +684,20 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat
.firstOrNull()
?.let { text = it.primitiveValue() }
}
+ val (enabledQuestionnaireAnswerOptions, disabledQuestionnaireResponseAnswers) =
+ answerOptionsEvaluator.evaluate(
+ questionnaireItem,
+ questionnaireResponseItem,
+ questionnaireResponse,
+ questionnaireItemParentMap
+ )
+ if (disabledQuestionnaireResponseAnswers.isNotEmpty()) {
+ removeDisabledAnswers(
+ questionnaireItem,
+ questionnaireResponseItem,
+ disabledQuestionnaireResponseAnswers
+ )
+ }
val items = buildList {
// Add an item for the question itself
@@ -733,8 +708,7 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat
questionnaireResponseItem,
validationResult = validationResult,
answersChangedCallback = answersChangedCallback,
- resolveAnswerValueSet = { resolveAnswerValueSet(it) },
- resolveAnswerExpression = { resolveAnswerExpression(it) },
+ enabledAnswerOptions = enabledQuestionnaireAnswerOptions,
draftAnswer = draftAnswerMap[questionnaireResponseItem],
enabledDisplayItems =
questionnaireItem.item.filter {
@@ -904,6 +878,9 @@ internal data class QuestionnaireState(
internal sealed class DisplayMode {
class EditMode(val pagination: QuestionnairePagination) : DisplayMode()
data class ReviewMode(val showEditButton: Boolean, val showSubmitButton: Boolean) : DisplayMode()
+
+ // Sentinel displayMode that's used in setting the initial default QuestionnaireState
+ object InitMode : DisplayMode()
}
/**
diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/expressions/EnabledAnswerOptionsEvaluator.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/expressions/EnabledAnswerOptionsEvaluator.kt
new file mode 100644
index 0000000000..f0bb0a0188
--- /dev/null
+++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/expressions/EnabledAnswerOptionsEvaluator.kt
@@ -0,0 +1,256 @@
+/*
+ * Copyright 2022-2023 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.android.fhir.datacapture.expressions
+
+import com.google.android.fhir.datacapture.ExternalAnswerValueSetResolver
+import com.google.android.fhir.datacapture.XFhirQueryResolver
+import com.google.android.fhir.datacapture.extensions.answerExpression
+import com.google.android.fhir.datacapture.extensions.answerOptionsToggleExpressions
+import com.google.android.fhir.datacapture.extensions.extractAnswerOptions
+import com.google.android.fhir.datacapture.extensions.isFhirPath
+import com.google.android.fhir.datacapture.extensions.isXFhirQuery
+import com.google.android.fhir.datacapture.fhirpath.ExpressionEvaluator
+import com.google.android.fhir.datacapture.fhirpath.fhirPathEngine
+import org.hl7.fhir.r4.model.Coding
+import org.hl7.fhir.r4.model.Questionnaire
+import org.hl7.fhir.r4.model.Questionnaire.QuestionnaireItemComponent
+import org.hl7.fhir.r4.model.QuestionnaireResponse
+import org.hl7.fhir.r4.model.Resource
+import org.hl7.fhir.r4.model.ResourceType
+import org.hl7.fhir.r4.model.ValueSet
+
+internal class EnabledAnswerOptionsEvaluator(
+ private val questionnaire: Questionnaire,
+ private val questionnaireLaunchContextMap: Map?,
+ private val xFhirQueryResolver: XFhirQueryResolver?,
+ private val externalValueSetResolver: ExternalAnswerValueSetResolver?
+) {
+
+ private val answerValueSetMap =
+ mutableMapOf>()
+
+ /**
+ * The answer expression referencing an x-fhir-query has its evaluated data cached to avoid
+ * reloading resources unnecessarily. The value is updated each time an item with answer
+ * expression is evaluating the latest answer options.
+ */
+ private val answerExpressionMap =
+ mutableMapOf>()
+
+ /**
+ * Returns a [Pair] of the enabled/allowed [Questionnaire.QuestionnaireItemAnswerOptionComponent]
+ * options and the disabled/disallowed
+ * [QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent] answers, based on the
+ * evaluation of [Questionnaire.QuestionnaireItemComponent.answerOptionsToggleExpressions]
+ * expressions
+ */
+ internal suspend fun evaluate(
+ questionnaireItem: QuestionnaireItemComponent,
+ questionnaireResponseItem: QuestionnaireResponse.QuestionnaireResponseItemComponent,
+ questionnaireResponse: QuestionnaireResponse,
+ questionnaireItemParentMap: Map
+ ): Pair<
+ List,
+ List
+ > {
+
+ val resolvedAnswerOptions =
+ answerOptions(questionnaireItem, questionnaireResponse, questionnaireItemParentMap)
+
+ if (questionnaireItem.answerOptionsToggleExpressions.isEmpty())
+ return Pair(resolvedAnswerOptions, emptyList())
+
+ val enabledQuestionnaireAnswerOptions =
+ evaluateAnswerOptionsToggleExpressions(
+ questionnaireItem,
+ questionnaireResponseItem,
+ questionnaireResponse,
+ resolvedAnswerOptions,
+ questionnaireItemParentMap
+ )
+ val disabledAnswers =
+ questionnaireResponseItem.answer
+ .takeIf { it.isNotEmpty() }
+ ?.filterNot { ans ->
+ enabledQuestionnaireAnswerOptions.any { ans.value.equalsDeep(it.value) }
+ }
+ ?: emptyList()
+ return Pair(enabledQuestionnaireAnswerOptions, disabledAnswers)
+ }
+
+ /**
+ * In a `choice` or `open-choice` type question, the answer options are defined in one of the
+ * three elements in the questionnaire:
+ *
+ * - `Questionnaire.item.answerOption`: a list of permitted answers to the question
+ * - `Questionnaire.item.answerValueSet`: a reference to a value set containing a list of
+ * permitted answers to the question
+ * - `Extension answer-expression`: an expression based extension which defines the x-fhir-query
+ * or fhirpath to evaluate permitted answer options
+ *
+ * Returns the answer options defined in one of the sources above. If the answer options are
+ * defined in `Questionnaire.item.answerValueSet`, the answer value set will be expanded.
+ */
+ private suspend fun answerOptions(
+ questionnaireItem: QuestionnaireItemComponent,
+ questionnaireResponse: QuestionnaireResponse,
+ questionnaireItemParentMap: Map
+ ): List =
+ when {
+ questionnaireItem.answerOption.isNotEmpty() -> questionnaireItem.answerOption
+ !questionnaireItem.answerValueSet.isNullOrEmpty() ->
+ resolveAnswerValueSet(questionnaireItem.answerValueSet)
+ questionnaireItem.answerExpression != null ->
+ resolveAnswerExpression(
+ questionnaireItem,
+ questionnaireResponse,
+ questionnaireItemParentMap
+ )
+ else -> emptyList()
+ }
+
+ private suspend fun resolveAnswerValueSet(
+ uri: String
+ ): List {
+ // If cache hit, return it
+ if (answerValueSetMap.contains(uri)) {
+ return answerValueSetMap[uri]!!
+ }
+
+ val options =
+ if (uri.startsWith("#")) {
+ questionnaire.contained
+ .firstOrNull { resource ->
+ resource.id.equals(uri) &&
+ resource.resourceType == ResourceType.ValueSet &&
+ (resource as ValueSet).hasExpansion()
+ }
+ ?.let { resource ->
+ val valueSet = resource as ValueSet
+ valueSet.expansion.contains
+ .filterNot { it.abstract || it.inactive }
+ .map { component ->
+ Questionnaire.QuestionnaireItemAnswerOptionComponent(
+ Coding(component.system, component.code, component.display)
+ )
+ }
+ }
+ } else {
+ // Ask the client to provide the answers from an external expanded Valueset.
+ externalValueSetResolver?.resolve(uri)?.map { coding ->
+ Questionnaire.QuestionnaireItemAnswerOptionComponent(coding.copy())
+ }
+ }
+ ?: emptyList()
+ // save it so that we avoid have cache misses.
+ answerValueSetMap[uri] = options
+ return options
+ }
+
+ // TODO persist previous answers in case options are changing and new list does not have selected
+ // answer and FHIRPath in x-fhir-query
+ // https://build.fhir.org/ig/HL7/sdc/expressions.html#x-fhir-query-enhancements
+ private suspend fun resolveAnswerExpression(
+ item: QuestionnaireItemComponent,
+ questionnaireResponse: QuestionnaireResponse,
+ questionnaireItemParentMap: Map
+ ): List {
+ // Check cache first for database queries
+ val answerExpression = item.answerExpression ?: return emptyList()
+
+ return when {
+ answerExpression.isXFhirQuery -> {
+ xFhirQueryResolver?.let { xFhirQueryResolver ->
+ val xFhirExpressionString =
+ ExpressionEvaluator.createXFhirQueryFromExpression(
+ questionnaire,
+ questionnaireResponse,
+ item,
+ questionnaireItemParentMap,
+ answerExpression,
+ questionnaireLaunchContextMap
+ )
+ if (answerExpressionMap.containsKey(xFhirExpressionString)) {
+ answerExpressionMap[xFhirExpressionString]
+ }
+
+ val data = xFhirQueryResolver.resolve(xFhirExpressionString)
+ val options = item.extractAnswerOptions(data)
+
+ answerExpressionMap[xFhirExpressionString] = options
+ options
+ }
+ ?: error(
+ "XFhirQueryResolver cannot be null. Please provide the XFhirQueryResolver via DataCaptureConfig."
+ )
+ }
+ answerExpression.isFhirPath -> {
+ val data = fhirPathEngine.evaluate(questionnaireResponse, answerExpression.expression)
+ item.extractAnswerOptions(data)
+ }
+ else ->
+ throw UnsupportedOperationException(
+ "${answerExpression.language} not supported for answer-expression yet"
+ )
+ }
+ }
+
+ private fun evaluateAnswerOptionsToggleExpressions(
+ item: QuestionnaireItemComponent,
+ questionnaireResponseItem: QuestionnaireResponse.QuestionnaireResponseItemComponent,
+ questionnaireResponse: QuestionnaireResponse,
+ answerOptions: List,
+ questionnaireItemParentMap: Map
+ ): List {
+ val results =
+ item.answerOptionsToggleExpressions
+ .map {
+ val (expression, toggleOptions) = it
+ val evaluationResult =
+ if (expression.isFhirPath)
+ fhirPathEngine.convertToBoolean(
+ ExpressionEvaluator.evaluateExpression(
+ questionnaire,
+ questionnaireResponse,
+ item,
+ questionnaireResponseItem,
+ expression,
+ questionnaireItemParentMap
+ )
+ )
+ else
+ throw UnsupportedOperationException(
+ "${expression.language} not supported yet for answer-options-toggle-expression"
+ )
+ evaluationResult to toggleOptions
+ }
+ .partition { it.first }
+ val (allowed, disallowed) = results
+ val allowedOptions = allowed.flatMap { it.second }
+
+ val disallowedOptions =
+ disallowed.flatMap {
+ it.second.filterNot { option ->
+ allowedOptions.any { type -> com.google.android.fhir.equals(type, option) }
+ }
+ }
+
+ return answerOptions.filterNot { answerOption ->
+ disallowedOptions.any { com.google.android.fhir.equals(answerOption.value, it) }
+ }
+ }
+}
diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/extensions/MoreHeaderViews.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/extensions/MoreHeaderViews.kt
index 8d098ec7ba..9902123f3d 100644
--- a/datacapture/src/main/java/com/google/android/fhir/datacapture/extensions/MoreHeaderViews.kt
+++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/extensions/MoreHeaderViews.kt
@@ -1,5 +1,5 @@
/*
- * Copyright 2022 Google LLC
+ * Copyright 2023 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -28,7 +28,8 @@ import com.google.android.fhir.datacapture.views.QuestionnaireViewItem
import com.google.android.material.card.MaterialCardView
import org.hl7.fhir.r4.model.Questionnaire
-internal fun TextView.updateTextAndVisibility(localizedText: Spanned? = null) {
+/** Displays `localizedText` if it is not null or empty, or hides the [TextView]. */
+fun TextView.updateTextAndVisibility(localizedText: Spanned? = null) {
text = localizedText
visibility =
if (localizedText.isNullOrEmpty()) {
@@ -39,7 +40,7 @@ internal fun TextView.updateTextAndVisibility(localizedText: Spanned? = null) {
}
/** Returns [VISIBLE] if any of the [view] is visible, [GONE] otherwise. */
-internal fun getHeaderViewVisibility(vararg view: TextView): Int {
+fun getHeaderViewVisibility(vararg view: TextView): Int {
if (view.any { it.visibility == VISIBLE }) {
return VISIBLE
}
@@ -51,12 +52,13 @@ internal fun getHeaderViewVisibility(vararg view: TextView): Int {
* visibility and click listener for the [helpButton] to allow users to access the help information
* and toggles the visibility for view [helpCardView].
*/
-internal fun initHelpViews(
+fun initHelpViews(
helpButton: Button,
helpCardView: MaterialCardView,
helpTextView: TextView,
questionnaireItem: Questionnaire.QuestionnaireItemComponent
) {
+ helpCardView.visibility = GONE
helpButton.visibility =
if (questionnaireItem.hasHelpButton) {
VISIBLE
@@ -77,7 +79,7 @@ internal fun initHelpViews(
* Appends ' *' to [Questionnaire.QuestionnaireItemComponent.localizedTextSpanned] text if
* [Questionnaire.QuestionnaireItemComponent.required] is true.
*/
-internal fun appendAsteriskToQuestionText(
+fun appendAsteriskToQuestionText(
context: Context,
questionnaireViewItem: QuestionnaireViewItem
): Spanned {
diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/extensions/MoreItemViews.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/extensions/MoreItemViews.kt
index 4db4940aa1..0843ec3d70 100644
--- a/datacapture/src/main/java/com/google/android/fhir/datacapture/extensions/MoreItemViews.kt
+++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/extensions/MoreItemViews.kt
@@ -1,5 +1,5 @@
/*
- * Copyright 2022 Google LLC
+ * Copyright 2023 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -30,10 +30,7 @@ import org.hl7.fhir.r4.model.Questionnaire
* [Questionnaire.QuestionnaireItemComponent.required] is true, or [R.string.optional_text] if
* [QuestionnaireViewItem.showOptionalText] is true.
*/
-internal fun getRequiredOrOptionalText(
- questionnaireViewItem: QuestionnaireViewItem,
- context: Context
-) =
+fun getRequiredOrOptionalText(questionnaireViewItem: QuestionnaireViewItem, context: Context) =
when {
(questionnaireViewItem.questionnaireItem.required &&
questionnaireViewItem.questionViewTextConfiguration.showRequiredText) -> {
@@ -53,7 +50,7 @@ internal fun getRequiredOrOptionalText(
* true, the error message starts with `Required` text and the rest of the error message is placed
* on the next line.
*/
-internal fun getValidationErrorMessage(
+fun getValidationErrorMessage(
context: Context,
questionnaireViewItem: QuestionnaireViewItem,
validationResult: ValidationResult
diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/extensions/MoreQuestionnaireItemComponents.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/extensions/MoreQuestionnaireItemComponents.kt
index 5681c2d112..f2675fe71d 100644
--- a/datacapture/src/main/java/com/google/android/fhir/datacapture/extensions/MoreQuestionnaireItemComponents.kt
+++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/extensions/MoreQuestionnaireItemComponents.kt
@@ -1,5 +1,5 @@
/*
- * Copyright 2022 Google LLC
+ * Copyright 2023 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -19,6 +19,7 @@ package com.google.android.fhir.datacapture.extensions
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
+import android.text.SpannableStringBuilder
import android.text.Spanned
import androidx.core.text.HtmlCompat
import ca.uhn.fhir.util.UrlUtil
@@ -34,6 +35,7 @@ import org.hl7.fhir.r4.model.Base
import org.hl7.fhir.r4.model.BooleanType
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.DecimalType
import org.hl7.fhir.r4.model.Expression
import org.hl7.fhir.r4.model.IntegerType
@@ -45,8 +47,12 @@ import org.hl7.fhir.r4.model.StringType
import org.hl7.fhir.r4.utils.ToolingExtensions
import timber.log.Timber
-/** UI controls relevant to capturing question data. */
-internal enum class ItemControlTypes(
+/**
+ * Item control types supported by the SDC library with `extensionCode` from the value set
+ * http://hl7.org/fhir/R4/valueset-questionnaire-item-control.html and `viewHolderType` as the
+ * [QuestionnaireViewHolderType] to be used to render the question.
+ */
+enum class ItemControlTypes(
val extensionCode: String,
val viewHolderType: QuestionnaireViewHolderType,
) {
@@ -108,6 +114,57 @@ internal const val EXTENSION_CQF_CALCULATED_VALUE_URL: String =
internal const val EXTENSION_SLIDER_STEP_VALUE_URL =
"http://hl7.org/fhir/StructureDefinition/questionnaire-sliderStepValue"
+/**
+ * Extension for questionnaire items of integer and decimal types including a single unit to be
+ * displayed.
+ *
+ * See https://hl7.org/fhir/extensions/StructureDefinition-questionnaire-unit.html.
+ */
+internal const val EXTENSION_QUESTIONNAIRE_UNIT_URL =
+ "http://hl7.org/fhir/StructureDefinition/questionnaire-unit"
+
+/**
+ * Extension for questionnaire items of quantity type including unit options to choose from.
+ *
+ * See https://hl7.org/fhir/extensions/StructureDefinition-questionnaire-unitOption.html.
+ */
+internal const val EXTENSION_QUESTIONNAIRE_UNIT_OPTION_URL =
+ "http://hl7.org/fhir/StructureDefinition/questionnaire-unitOption"
+
+/**
+ * Extension for questionnaire items of quantity type including a value set of unit options to
+ * choose from.
+ */
+internal const val EXTENSION_QUESTIONNAIRE_UNIT_VALUE_SET_URL =
+ "http://hl7.org/fhir/StructureDefinition/questionnaire-unitValueSet"
+
+internal const val EXTENSION_ANSWER_OPTION_TOGGLE_EXPRESSION_URL =
+ "http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-answerOptionsToggleExpression"
+
+internal const val EXTENSION_ANSWER_OPTION_TOGGLE_EXPRESSION_OPTION = "option"
+
+internal const val EXTENSION_ANSWER_OPTION_TOGGLE_EXPRESSION = "expression"
+
+internal val Questionnaire.QuestionnaireItemComponent.answerOptionsToggleExpressions
+ get() =
+ this.extension
+ .filter { it.url == EXTENSION_ANSWER_OPTION_TOGGLE_EXPRESSION_URL }
+ .map { rootExtension ->
+ val options =
+ rootExtension.extension
+ .filter { it.url == EXTENSION_ANSWER_OPTION_TOGGLE_EXPRESSION_OPTION }
+ .map { it.value }
+ if (options.isEmpty())
+ throw IllegalArgumentException(
+ "Questionnaire item $linkId with extension '$EXTENSION_ANSWER_EXPRESSION_URL' requires at least one option. See http://hl7.org/fhir/uv/sdc/STU3/StructureDefinition-sdc-questionnaire-answerOptionsToggleExpression.html."
+ )
+ val expression =
+ rootExtension.extension
+ .single { it.url == EXTENSION_ANSWER_OPTION_TOGGLE_EXPRESSION }
+ .let { it.castToExpression(it.value) }
+ expression to options
+ }
+
internal val Questionnaire.QuestionnaireItemComponent.variableExpressions: List
get() =
this.extension.filter { it.url == EXTENSION_VARIABLE_URL }.map { it.castToExpression(it.value) }
@@ -153,8 +210,13 @@ internal fun Questionnaire.QuestionnaireItemComponent.isReferencedBy(
.contains(Regex(".*linkId='${this.linkId}'.*"))
}
-// Item control code, or null
-internal val Questionnaire.QuestionnaireItemComponent.itemControl: ItemControlTypes?
+/**
+ * The [ItemControlTypes] of the questionnaire item if it is specified by the item control
+ * extension, or `null`.
+ *
+ * See http://hl7.org/fhir/R4/extension-questionnaire-itemcontrol.html.
+ */
+val Questionnaire.QuestionnaireItemComponent.itemControl: ItemControlTypes?
get() {
val codeableConcept =
this.extension
@@ -173,7 +235,12 @@ internal val Questionnaire.QuestionnaireItemComponent.itemControl: ItemControlTy
return ItemControlTypes.values().firstOrNull { it.extensionCode == code }
}
-internal enum class ChoiceOrientationTypes(val extensionCode: String) {
+/**
+ * The desired orientation for the list of choices.
+ *
+ * See http://hl7.org/fhir/R4/extension-questionnaire-choiceorientation.html.
+ */
+enum class ChoiceOrientationTypes(val extensionCode: String) {
HORIZONTAL("horizontal"),
VERTICAL("vertical")
}
@@ -182,7 +249,7 @@ internal const val EXTENSION_CHOICE_ORIENTATION_URL =
"http://hl7.org/fhir/StructureDefinition/questionnaire-choiceOrientation"
/** Desired orientation to render a list of choices. */
-internal val Questionnaire.QuestionnaireItemComponent.choiceOrientation: ChoiceOrientationTypes?
+val Questionnaire.QuestionnaireItemComponent.choiceOrientation: ChoiceOrientationTypes?
get() {
val code =
(this.extension.firstOrNull { it.url == EXTENSION_CHOICE_ORIENTATION_URL }?.value
@@ -194,7 +261,7 @@ internal val Questionnaire.QuestionnaireItemComponent.choiceOrientation: ChoiceO
internal const val EXTENSION_MIME_TYPE = "http://hl7.org/fhir/StructureDefinition/mimeType"
/** Identifies the kinds of attachment allowed to be sent for an element. */
-internal val Questionnaire.QuestionnaireItemComponent.mimeTypes: List
+val Questionnaire.QuestionnaireItemComponent.mimeTypes: List
get() {
return extension
.filter { it.url == EXTENSION_MIME_TYPE }
@@ -203,7 +270,7 @@ internal val Questionnaire.QuestionnaireItemComponent.mimeTypes: List
}
/** Currently supported mime types. */
-internal enum class MimeType(val value: String) {
+enum class MimeType(val value: String) {
AUDIO("audio"),
DOCUMENT("application"),
IMAGE("image"),
@@ -214,12 +281,12 @@ internal enum class MimeType(val value: String) {
private fun getMimeType(mimeType: String): String = mimeType.substringBefore("/")
/** Returns true if at least one mime type matches the given type. */
-internal fun Questionnaire.QuestionnaireItemComponent.hasMimeType(type: String): Boolean {
+fun Questionnaire.QuestionnaireItemComponent.hasMimeType(type: String): Boolean {
return mimeTypes.any { it.substringBefore("/") == type }
}
/** Returns true if all mime types match the given type. */
-internal fun Questionnaire.QuestionnaireItemComponent.hasMimeTypeOnly(type: String): Boolean {
+fun Questionnaire.QuestionnaireItemComponent.hasMimeTypeOnly(type: String): Boolean {
return mimeTypes.all { it.substringBefore("/") == type }
}
@@ -257,7 +324,7 @@ internal fun Questionnaire.QuestionnaireItemComponent.isGivenSizeOverLimit(
internal enum class DisplayItemControlType(val extensionCode: String) {
FLYOVER("flyover"),
PAGE("page"),
- HELP("help")
+ HELP("help"),
}
/** Item control to show instruction text */
@@ -271,13 +338,13 @@ internal val Questionnaire.QuestionnaireItemComponent.displayItemControl: Displa
}
/** Whether any one of the nested display item has [DisplayItemControlType.HELP] control. */
-internal val Questionnaire.QuestionnaireItemComponent.hasHelpButton: Boolean
+val Questionnaire.QuestionnaireItemComponent.hasHelpButton: Boolean
get() {
return item.any { it.isHelpCode }
}
/** Converts Text with HTML Tag to formatted text. */
-private fun String.toSpanned(): Spanned {
+internal fun String.toSpanned(): Spanned {
return HtmlCompat.fromHtml(this, HtmlCompat.FROM_HTML_MODE_COMPACT)
}
@@ -299,17 +366,24 @@ val Questionnaire.QuestionnaireItemComponent.localizedPrefixSpanned: Spanned?
* A nested questionnaire item of type display with displayCategory extension with [INSTRUCTIONS]
* code is used as the instructions of the parent question.
*/
-internal val Questionnaire.QuestionnaireItemComponent.localizedInstructionsSpanned: Spanned?
- get() = item.localizedInstructionsSpanned
+val Questionnaire.QuestionnaireItemComponent.localizedInstructionsSpanned: Spanned?
+ get() = item.getLocalizedInstructionsSpanned()
-/** [localizedInstructionsSpanned] over list of [Questionnaire.QuestionnaireItemComponent] */
-internal val List.localizedInstructionsSpanned: Spanned?
- get() {
- return this.firstOrNull { questionnaireItem ->
+/**
+ * Returns a Spanned object that contains the localized instructions for all of the items in this
+ * list that are of type `Questionnaire.QuestionnaireItemType.DISPLAY` and have the
+ * `isInstructionsCode` flag set. The instructions are separated by newlines.
+ */
+fun List.getLocalizedInstructionsSpanned(
+ separator: String = "\n"
+) =
+ SpannableStringBuilder().apply {
+ this@getLocalizedInstructionsSpanned.filter { questionnaireItem ->
questionnaireItem.type == Questionnaire.QuestionnaireItemType.DISPLAY &&
questionnaireItem.isInstructionsCode
}
- ?.localizedTextSpanned
+ .map { it.localizedTextSpanned }
+ .joinTo(this, separator)
}
/**
@@ -320,7 +394,7 @@ internal val Questionnaire.QuestionnaireItemComponent.localizedFlyoverSpanned: S
get() = item.localizedFlyoverSpanned
/** [localizedFlyoverSpanned] over list of [Questionnaire.QuestionnaireItemComponent] */
-internal val List.localizedFlyoverSpanned: Spanned?
+val List.localizedFlyoverSpanned: Spanned?
get() =
this.firstOrNull { questionnaireItem ->
questionnaireItem.type == Questionnaire.QuestionnaireItemType.DISPLAY &&
@@ -332,11 +406,11 @@ internal val List.localizedFlyoverSpan
* A nested questionnaire item of type display with displayCategory extension with [INSTRUCTIONS]
* code is used as the instructions of the parent question.
*/
-internal val Questionnaire.QuestionnaireItemComponent.localizedHelpSpanned: Spanned?
+val Questionnaire.QuestionnaireItemComponent.localizedHelpSpanned: Spanned?
get() = item.localizedHelpSpanned
/** [localizedHelpSpanned] over list of [Questionnaire.QuestionnaireItemComponent] */
-internal val List.localizedHelpSpanned: Spanned?
+val List.localizedHelpSpanned: Spanned?
get() {
return this.firstOrNull { questionnaireItem -> questionnaireItem.isHelpCode }
?.localizedTextSpanned
@@ -425,7 +499,7 @@ internal val Questionnaire.QuestionnaireItemComponent.isDisplayItem: Boolean
(isInstructionsCode || isFlyoverCode || isHelpCode))
/** Slider step extension value. */
-internal val Questionnaire.QuestionnaireItemComponent.sliderStepValue: Int?
+val Questionnaire.QuestionnaireItemComponent.sliderStepValue: Int?
get() {
val extension =
this.extension.singleOrNull { it.url == EXTENSION_SLIDER_STEP_VALUE_URL } ?: return null
@@ -436,6 +510,34 @@ internal val Questionnaire.QuestionnaireItemComponent.sliderStepValue: Int?
return null
}
+/**
+ * The unit for the numerical question.
+ *
+ * See http://hl7.org/fhir/R4/extension-questionnaire-unit.html.
+ */
+internal val Questionnaire.QuestionnaireItemComponent.unit: Coding?
+ get() {
+ val extension =
+ this.extension.singleOrNull { it.url == EXTENSION_QUESTIONNAIRE_UNIT_URL } ?: return null
+ val value = extension.value
+ if (value is Coding) {
+ return value
+ }
+ return null
+ }
+
+/**
+ * The unit options for the quantity question.
+ *
+ * See http://hl7.org/fhir/R4/extension-questionnaire-unitoption.html.
+ */
+internal val Questionnaire.QuestionnaireItemComponent.unitOption: List
+ get() {
+ return this.extension
+ .filter { it.url == EXTENSION_QUESTIONNAIRE_UNIT_OPTION_URL }
+ .map { it.value as Coding }
+ }
+
/**
* Returns a list of values built from the elements of `this` and the
* `questionnaireResponseItemList` with the same linkId using the provided `transform` function
@@ -527,8 +629,9 @@ val Questionnaire.QuestionnaireItemComponent.enableWhenExpression: Expression?
* value.
*/
private fun Questionnaire.QuestionnaireItemComponent.createQuestionnaireResponseItemAnswers():
- MutableList? {
- // https://build.fhir.org/ig/HL7/sdc/behavior.html#initial
+ MutableList<
+ QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent
+ >? { // https://build.fhir.org/ig/HL7/sdc/behavior.html#initial
// quantity given as initial without value is for unit reference purpose only. Answer conversion
// not needed
if (initial.isEmpty() ||
@@ -655,8 +758,16 @@ internal fun Questionnaire.QuestionnaireItemComponent.extractAnswerOptions(
* flat list of all items into list embedded at any level
*/
fun List.flattened():
- List {
- return this + this.flatMap { it.item.flattened() }
+ List =
+ mutableListOf().also { flattenInto(it) }
+
+private fun List.flattenInto(
+ output: MutableList
+) {
+ forEach {
+ output.add(it)
+ it.item.flattenInto(output)
+ }
}
val Resource.logicalId: String
diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/extensions/MoreQuestionnaireResponseItemComponents.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/extensions/MoreQuestionnaireResponseItemComponents.kt
index 1778d9c521..977403bb59 100644
--- a/datacapture/src/main/java/com/google/android/fhir/datacapture/extensions/MoreQuestionnaireResponseItemComponents.kt
+++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/extensions/MoreQuestionnaireResponseItemComponents.kt
@@ -1,5 +1,5 @@
/*
- * Copyright 2022 Google LLC
+ * Copyright 2023 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -24,11 +24,18 @@ import org.hl7.fhir.r4.model.QuestionnaireResponse
*/
val QuestionnaireResponse.QuestionnaireResponseItemComponent.descendant:
List
- get() {
- return listOf(this) +
- this.item.flatMap { it.descendant } +
- this.answer.flatMap { answer -> answer.item.flatMap { it.descendant } }
- }
+ get() =
+ mutableListOf().also {
+ appendDescendantTo(it)
+ }
+
+private fun QuestionnaireResponse.QuestionnaireResponseItemComponent.appendDescendantTo(
+ output: MutableList
+) {
+ output.add(this)
+ item.forEach { it.appendDescendantTo(output) }
+ answer.forEach { answer -> answer.item.forEach { it.appendDescendantTo(output) } }
+}
/**
* Add nested items under the provided `questionnaireItem` to each answer in the questionnaire
diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/extensions/MoreQuestionnaires.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/extensions/MoreQuestionnaires.kt
index 1cfb691129..8ba5377701 100644
--- a/datacapture/src/main/java/com/google/android/fhir/datacapture/extensions/MoreQuestionnaires.kt
+++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/extensions/MoreQuestionnaires.kt
@@ -1,5 +1,5 @@
/*
- * Copyright 2022 Google LLC
+ * Copyright 2023 Google LLC
*
* 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/datacapture/src/main/java/com/google/android/fhir/datacapture/extensions/MoreTypes.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/extensions/MoreTypes.kt
index 9ffc4eb54f..116d72135c 100644
--- a/datacapture/src/main/java/com/google/android/fhir/datacapture/extensions/MoreTypes.kt
+++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/extensions/MoreTypes.kt
@@ -1,5 +1,5 @@
/*
- * Copyright 2022 Google LLC
+ * Copyright 2023 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -28,17 +28,14 @@ import org.hl7.fhir.r4.model.CodeType
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.Expression
import org.hl7.fhir.r4.model.IdType
-import org.hl7.fhir.r4.model.IntegerType
import org.hl7.fhir.r4.model.PrimitiveType
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.StringType
-import org.hl7.fhir.r4.model.TimeType
import org.hl7.fhir.r4.model.Type
import org.hl7.fhir.r4.model.UriType
@@ -59,39 +56,45 @@ fun Type.asStringValue(): String {
* [Questionnaire.QuestionnaireItemAnswerOptionComponent].
*/
fun Type.displayString(context: Context): String =
- when (this) {
- is Attachment -> this.url ?: context.getString(R.string.not_answered)
+ getDisplayString(this, context) ?: context.getString(R.string.not_answered)
+
+/** Returns value as string depending on the [Type] of element. */
+fun Type.getValueAsString(context: Context): String =
+ getValueString(this) ?: context.getString(R.string.not_answered)
+
+/*
+ * Returns the unique identifier of a [Type]. Used to differentiate between item answer options that
+ * may have similar display strings
+ */
+fun Type.identifierString(context: Context): String =
+ id ?: (this as? Coding)?.code ?: (this as? Reference)?.reference ?: displayString(context)
+
+private fun getDisplayString(type: Type, context: Context): String? =
+ when (type) {
+ is Coding -> type.displayElement.getLocalizedText() ?: type.display ?: type.code
+ is StringType -> type.getLocalizedText() ?: type.asStringValue()
+ is DateType -> type.localDate?.format()
+ is DateTimeType -> "${type.localDate.format()} ${type.localTime.toLocalizedString(context)}"
+ is Reference -> type.display ?: type.reference
+ is Attachment -> type.url
is BooleanType -> {
- when (this.value) {
+ when (type.value) {
true -> context.getString(R.string.yes)
false -> context.getString(R.string.no)
- null -> context.getString(R.string.not_answered)
+ null -> null
}
}
- is Coding -> {
- val display = this.displayElement.getLocalizedText() ?: this.display
- if (display.isNullOrEmpty()) {
- this.code ?: context.getString(R.string.not_answered)
- } else display
- }
- is DateType -> this.localDate?.format() ?: context.getString(R.string.not_answered)
- is DateTimeType -> "${this.localDate.format()} ${this.localTime.toLocalizedString(context)}"
- is DecimalType,
- is IntegerType -> (this as PrimitiveType<*>).valueAsString
- ?: context.getString(R.string.not_answered)
- is Quantity -> this.value.toString()
- is Reference -> {
- val display = this.display
- if (display.isNullOrEmpty()) {
- this.reference ?: context.getString(R.string.not_answered)
- } else display
- }
- is StringType -> this.getLocalizedText()
- ?: this.valueAsString ?: context.getString(R.string.not_answered)
- is TimeType,
- is UriType -> (this as PrimitiveType<*>).valueAsString
- ?: context.getString(R.string.not_answered)
- else -> context.getString(R.string.not_answered)
+ is Quantity -> type.value.toString()
+ else -> (type as? PrimitiveType<*>)?.valueAsString
+ }
+
+private fun getValueString(type: Type): String? =
+ when (type) {
+ is DateType,
+ is DateTimeType,
+ is StringType -> type.asStringValue()
+ is Quantity -> type.value.toString()
+ else -> (type as? PrimitiveType<*>)?.valueAsString
}
/** Converts StringType to toUriType. */
diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/fhirpath/ExpressionEvaluator.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/fhirpath/ExpressionEvaluator.kt
index fec1b86273..ed0c1c84a8 100644
--- a/datacapture/src/main/java/com/google/android/fhir/datacapture/fhirpath/ExpressionEvaluator.kt
+++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/fhirpath/ExpressionEvaluator.kt
@@ -1,5 +1,5 @@
/*
- * Copyright 2022 Google LLC
+ * Copyright 2023 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -279,19 +279,36 @@ object ExpressionEvaluator {
}
/**
- * Creates an x-fhir-query string for evaluation
- *
- * @param expression x-fhir-query expression
- * @param launchContextMap if passed, the launch context to evaluate the expression against
+ * Creates an x-fhir-query string for evaluation. For this, it evaluates both variables and
+ * fhir-paths in the expression.
*/
internal fun createXFhirQueryFromExpression(
+ questionnaire: Questionnaire,
+ questionnaireResponse: QuestionnaireResponse,
+ questionnaireItem: QuestionnaireItemComponent,
+ questionnaireItemParentMap: Map,
expression: Expression,
launchContextMap: Map?
): String {
- if (launchContextMap == null) {
- return expression.expression
- }
- return evaluateXFhirEnhancement(expression, launchContextMap).fold(expression.expression) {
+ // get all dependent variables and their evaluated values
+ val variablesEvaluatedPairs =
+ mutableMapOf()
+ .apply {
+ extractDependentVariables(
+ expression,
+ questionnaire,
+ questionnaireResponse,
+ questionnaireItemParentMap,
+ questionnaireItem,
+ this
+ )
+ }
+ .filterKeys { expression.expression.contains("{{%$it}}") }
+ .map { Pair("{{%${it.key}}}", it.value!!.primitiveValue()) }
+
+ val fhirPathsEvaluatedPairs =
+ launchContextMap?.let { evaluateXFhirEnhancement(expression, it) } ?: emptySequence()
+ return (fhirPathsEvaluatedPairs + variablesEvaluatedPairs).fold(expression.expression) {
acc: String,
pair: Pair ->
acc.replace(pair.first, pair.second)
diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/fhirpath/FHIRPathEngineHostServices.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/fhirpath/FHIRPathEngineHostServices.kt
index d2c7d5d4fd..9fbe1c3329 100644
--- a/datacapture/src/main/java/com/google/android/fhir/datacapture/fhirpath/FHIRPathEngineHostServices.kt
+++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/fhirpath/FHIRPathEngineHostServices.kt
@@ -1,5 +1,5 @@
/*
- * Copyright 2022 Google LLC
+ * Copyright 2023 Google LLC
*
* 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/datacapture/src/main/java/com/google/android/fhir/datacapture/mapping/ResourceMapper.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/mapping/ResourceMapper.kt
index 929988033b..8fee63df06 100644
--- a/datacapture/src/main/java/com/google/android/fhir/datacapture/mapping/ResourceMapper.kt
+++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/mapping/ResourceMapper.kt
@@ -1,5 +1,5 @@
/*
- * Copyright 2022 Google LLC
+ * Copyright 2022-2023 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -18,6 +18,7 @@ package com.google.android.fhir.datacapture.mapping
import com.google.android.fhir.datacapture.DataCapture
import com.google.android.fhir.datacapture.extensions.createQuestionnaireResponseItem
+import com.google.android.fhir.datacapture.extensions.logicalId
import com.google.android.fhir.datacapture.extensions.targetStructureMap
import com.google.android.fhir.datacapture.extensions.toCodeType
import com.google.android.fhir.datacapture.extensions.toCoding
@@ -28,6 +29,7 @@ import java.lang.reflect.Field
import java.lang.reflect.Method
import java.lang.reflect.ParameterizedType
import java.util.Locale
+import org.hl7.fhir.exceptions.FHIRException
import org.hl7.fhir.r4.context.IWorkerContext
import org.hl7.fhir.r4.model.Base
import org.hl7.fhir.r4.model.Bundle
@@ -45,6 +47,7 @@ import org.hl7.fhir.r4.model.IntegerType
import org.hl7.fhir.r4.model.Parameters
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.Resource
import org.hl7.fhir.r4.model.StringType
import org.hl7.fhir.r4.model.StructureDefinition
@@ -252,10 +255,9 @@ object ResourceMapper {
?.let {
// Set initial value for the questionnaire item. Questionnaire items should not have both
// initial value and initial expression.
+ val value = it.asExpectedType(questionnaireItem.type)
questionnaireItem.initial =
- mutableListOf(
- Questionnaire.QuestionnaireItemInitialComponent().setValue(it.asExpectedType())
- )
+ mutableListOf(Questionnaire.QuestionnaireItemInitialComponent().setValue(value))
}
populateInitialValues(questionnaireItem.item, *resources)
@@ -758,14 +760,36 @@ private fun Questionnaire.createResource(): Resource? =
* objects and throws exception otherwise. This extension function takes care of the conversion
* based on the input and expected [Type].
*/
-private fun Base.asExpectedType(): Type {
- return when (this) {
- is Enumeration<*> -> toCoding()
- is IdType -> StringType(idPart)
+private fun Base.asExpectedType(
+ questionnaireItemType: Questionnaire.QuestionnaireItemType? = null
+): Type {
+ return when {
+ questionnaireItemType == Questionnaire.QuestionnaireItemType.REFERENCE ->
+ asExpectedReferenceType()
+ this is Enumeration<*> -> toCoding()
+ this is IdType -> StringType(idPart)
else -> this as Type
}
}
+private fun Base.asExpectedReferenceType(): Type {
+ return when {
+ this.isResource -> {
+ this@asExpectedReferenceType as Resource
+ Reference().apply {
+ reference =
+ "${this@asExpectedReferenceType.resourceType}/${this@asExpectedReferenceType.logicalId}"
+ }
+ }
+ this is IdType ->
+ Reference().apply {
+ reference =
+ "${this@asExpectedReferenceType.resourceType}/${this@asExpectedReferenceType.idPart}"
+ }
+ else -> throw FHIRException("Expression supplied does not evaluate to IdType.")
+ }
+}
+
/**
* Returns a newly created [Resource] from the item extraction context extension if one and only one
* such extension exists in the questionnaire item, or null otherwise.
diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/MaxDecimalPlacesValidator.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/MaxDecimalPlacesValidator.kt
index 1a1894fad7..81f7f18d08 100644
--- a/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/MaxDecimalPlacesValidator.kt
+++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/MaxDecimalPlacesValidator.kt
@@ -1,5 +1,5 @@
/*
- * Copyright 2022 Google LLC
+ * Copyright 2023 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -40,7 +40,7 @@ internal object MaxDecimalPlacesValidator :
maxDecimalPlaces != null &&
answer.valueDecimalType.valueAsString.substringAfter(".").length > maxDecimalPlaces
},
- { extension: Extension, context: Context ->
+ messageGenerator = { extension: Extension, context: Context ->
context.getString(R.string.max_decimal_validation_error_msg, extension.value.primitiveValue())
}
)
diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/MaxValueValidator.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/MaxValueValidator.kt
index e93aac27b7..af969dde76 100644
--- a/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/MaxValueValidator.kt
+++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/MaxValueValidator.kt
@@ -1,5 +1,5 @@
/*
- * Copyright 2022 Google LLC
+ * Copyright 2023 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -19,6 +19,7 @@ package com.google.android.fhir.datacapture.validation
import android.content.Context
import com.google.android.fhir.compareTo
import com.google.android.fhir.datacapture.R
+import com.google.android.fhir.datacapture.extensions.getValueAsString
import com.google.android.fhir.datacapture.extensions.valueOrCalculateValue
import org.hl7.fhir.r4.model.Extension
import org.hl7.fhir.r4.model.Questionnaire
@@ -36,10 +37,10 @@ internal object MaxValueValidator :
answer: QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent ->
answer.value > extension.value?.valueOrCalculateValue()!!
},
- { extension: Extension, context: Context ->
+ messageGenerator = { extension: Extension, context: Context ->
context.getString(
R.string.max_value_validation_error_msg,
- extension.value?.valueOrCalculateValue()?.primitiveValue()
+ extension.value?.valueOrCalculateValue()?.getValueAsString(context)
)
}
) {
diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/MinLengthValidator.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/MinLengthValidator.kt
index 3b0f317c4f..2ca8681d2b 100644
--- a/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/MinLengthValidator.kt
+++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/MinLengthValidator.kt
@@ -1,5 +1,5 @@
/*
- * Copyright 2022 Google LLC
+ * Copyright 2023 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -17,6 +17,7 @@
package com.google.android.fhir.datacapture.validation
import android.content.Context
+import com.google.android.fhir.datacapture.R
import org.hl7.fhir.r4.model.Extension
import org.hl7.fhir.r4.model.IntegerType
import org.hl7.fhir.r4.model.PrimitiveType
@@ -41,9 +42,8 @@ internal object MinLengthValidator :
(answer.value as PrimitiveType<*>).asStringValue().length <
(extension.value as IntegerType).value
},
- messageGenerator = { extension: Extension, _: Context ->
- ("The minimum number of characters that are permitted in the answer is: " +
- extension.value.primitiveValue())
+ messageGenerator = { extension: Extension, context: Context ->
+ context.getString(R.string.min_length_validation_error_msg, extension.value.primitiveValue())
}
)
diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/MinValueValidator.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/MinValueValidator.kt
index fb477f6879..9d225519ab 100644
--- a/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/MinValueValidator.kt
+++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/MinValueValidator.kt
@@ -1,5 +1,5 @@
/*
- * Copyright 2022 Google LLC
+ * Copyright 2023 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -19,6 +19,7 @@ package com.google.android.fhir.datacapture.validation
import android.content.Context
import com.google.android.fhir.compareTo
import com.google.android.fhir.datacapture.R
+import com.google.android.fhir.datacapture.extensions.getValueAsString
import com.google.android.fhir.datacapture.extensions.valueOrCalculateValue
import org.hl7.fhir.r4.model.Extension
import org.hl7.fhir.r4.model.Questionnaire
@@ -35,10 +36,10 @@ internal object MinValueValidator :
answer: QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent ->
answer.value < extension.value?.valueOrCalculateValue()!!
},
- { extension: Extension, context: Context ->
+ messageGenerator = { extension: Extension, context: Context ->
context.getString(
R.string.min_value_validation_error_msg,
- extension.value?.valueOrCalculateValue()?.primitiveValue()
+ extension.value?.valueOrCalculateValue()?.getValueAsString(context)
)
}
) {
diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/RegexValidator.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/RegexValidator.kt
index 59504dbf32..9507b385db 100644
--- a/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/RegexValidator.kt
+++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/RegexValidator.kt
@@ -1,5 +1,5 @@
/*
- * Copyright 2022 Google LLC
+ * Copyright 2023 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -17,6 +17,7 @@
package com.google.android.fhir.datacapture.validation
import android.content.Context
+import com.google.android.fhir.datacapture.R
import com.google.android.fhir.datacapture.extensions.asStringValue
import java.util.regex.Pattern
import java.util.regex.PatternSyntaxException
@@ -49,8 +50,8 @@ internal object RegexValidator :
false
}
},
- { extension: Extension, _: Context ->
- "The answer doesn't match regular expression: " + extension.value.primitiveValue()
+ messageGenerator = { extension: Extension, context: Context ->
+ context.getString(R.string.regex_validation_error_msg, extension.value.primitiveValue())
}
)
diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/GroupHeaderView.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/GroupHeaderView.kt
index e974850799..baa60a9a6c 100644
--- a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/GroupHeaderView.kt
+++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/GroupHeaderView.kt
@@ -1,5 +1,5 @@
/*
- * Copyright 2022 Google LLC
+ * Copyright 2023 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -21,15 +21,19 @@ import android.util.AttributeSet
import android.view.LayoutInflater
import android.widget.LinearLayout
import android.widget.TextView
+import com.google.android.fhir.datacapture.QuestionnaireViewHolderType
import com.google.android.fhir.datacapture.R
import com.google.android.fhir.datacapture.extensions.getHeaderViewVisibility
+import com.google.android.fhir.datacapture.extensions.getLocalizedInstructionsSpanned
import com.google.android.fhir.datacapture.extensions.initHelpViews
-import com.google.android.fhir.datacapture.extensions.localizedInstructionsSpanned
import com.google.android.fhir.datacapture.extensions.localizedPrefixSpanned
import com.google.android.fhir.datacapture.extensions.updateTextAndVisibility
-internal class GroupHeaderView(context: Context, attrs: AttributeSet?) :
- LinearLayout(context, attrs) {
+/**
+ * Generic view for the prefix, question, and hint as the header of a group using a view holder of
+ * type [QuestionnaireViewHolderType.GROUP].
+ */
+class GroupHeaderView(context: Context, attrs: AttributeSet?) : LinearLayout(context, attrs) {
init {
LayoutInflater.from(context).inflate(R.layout.group_type_header_view, this, true)
@@ -50,7 +54,7 @@ internal class GroupHeaderView(context: Context, attrs: AttributeSet?) :
// CQF expression takes precedence over static question text
question.updateTextAndVisibility(questionnaireViewItem.questionText)
hint.updateTextAndVisibility(
- questionnaireViewItem.enabledDisplayItems.localizedInstructionsSpanned
+ questionnaireViewItem.enabledDisplayItems.getLocalizedInstructionsSpanned()
)
visibility = getHeaderViewVisibility(prefix, question, hint)
}
diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/HeaderView.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/HeaderView.kt
index d62d0bca67..1990c2dc98 100644
--- a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/HeaderView.kt
+++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/HeaderView.kt
@@ -1,5 +1,5 @@
/*
- * Copyright 2022 Google LLC
+ * Copyright 2023 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -24,14 +24,14 @@ import android.widget.TextView
import com.google.android.fhir.datacapture.R
import com.google.android.fhir.datacapture.extensions.appendAsteriskToQuestionText
import com.google.android.fhir.datacapture.extensions.getHeaderViewVisibility
+import com.google.android.fhir.datacapture.extensions.getLocalizedInstructionsSpanned
import com.google.android.fhir.datacapture.extensions.initHelpViews
-import com.google.android.fhir.datacapture.extensions.localizedInstructionsSpanned
import com.google.android.fhir.datacapture.extensions.localizedPrefixSpanned
import com.google.android.fhir.datacapture.extensions.updateTextAndVisibility
import org.hl7.fhir.r4.model.Questionnaire
/** View for the prefix, question, and hint of a questionnaire item. */
-internal class HeaderView(context: Context, attrs: AttributeSet?) : LinearLayout(context, attrs) {
+class HeaderView(context: Context, attrs: AttributeSet?) : LinearLayout(context, attrs) {
init {
LayoutInflater.from(context).inflate(R.layout.header_view, this, true)
@@ -56,7 +56,7 @@ internal class HeaderView(context: Context, attrs: AttributeSet?) : LinearLayout
appendAsteriskToQuestionText(question.context, questionnaireViewItem)
)
hint.updateTextAndVisibility(
- questionnaireViewItem.enabledDisplayItems.localizedInstructionsSpanned
+ questionnaireViewItem.enabledDisplayItems.getLocalizedInstructionsSpanned()
)
// Make the entire view GONE if there is nothing to show. This is to avoid an empty row in the
// questionnaire.
diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/QuestionnaireViewItem.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/QuestionnaireViewItem.kt
index 8254e71302..2ad1e13c61 100644
--- a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/QuestionnaireViewItem.kt
+++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/QuestionnaireViewItem.kt
@@ -1,5 +1,5 @@
/*
- * Copyright 2022 Google LLC
+ * Copyright 2023 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -18,25 +18,22 @@ package com.google.android.fhir.datacapture.views
import android.content.Context
import android.text.Spanned
-import androidx.core.text.toSpanned
import androidx.recyclerview.widget.RecyclerView
import com.google.android.fhir.datacapture.R
-import com.google.android.fhir.datacapture.extensions.answerExpression
import com.google.android.fhir.datacapture.extensions.displayString
import com.google.android.fhir.datacapture.extensions.localizedTextSpanned
+import com.google.android.fhir.datacapture.extensions.toSpanned
import com.google.android.fhir.datacapture.validation.NotValidated
import com.google.android.fhir.datacapture.validation.Valid
import com.google.android.fhir.datacapture.validation.ValidationResult
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.runBlocking
import org.hl7.fhir.r4.model.Questionnaire
import org.hl7.fhir.r4.model.QuestionnaireResponse
/**
* Data item for [QuestionnaireItemViewHolder] in [RecyclerView].
*
- * The view should use [questionnaireItem], [answers], [answerOption], [validationResult] and
- * [enabledDisplayItems] to render the data item in the UI. The view SHOULD NOT mutate the data
+ * The view should use [questionnaireItem], [answers], [enabledAnswerOptions], [validationResult]
+ * and [enabledDisplayItems] to render the data item in the UI. The view SHOULD NOT mutate the data
* using these properties.
*
* The view should use the following answer APIs to update the answer(s):
@@ -75,21 +72,11 @@ data class QuestionnaireViewItem(
List,
Any?
) -> Unit,
- private val resolveAnswerValueSet:
- suspend (String) -> List =
- {
- emptyList()
- },
- private val resolveAnswerExpression:
- suspend (Questionnaire.QuestionnaireItemComponent) -> List<
- Questionnaire.QuestionnaireItemAnswerOptionComponent> =
- {
- emptyList()
- },
- internal val draftAnswer: Any? = null,
- internal val enabledDisplayItems: List = emptyList(),
- internal val questionViewTextConfiguration: QuestionTextConfiguration =
- QuestionTextConfiguration(),
+ val enabledAnswerOptions: List =
+ questionnaireItem.answerOption.ifEmpty { emptyList() },
+ val draftAnswer: Any? = null,
+ val enabledDisplayItems: List = emptyList(),
+ val questionViewTextConfiguration: QuestionTextConfiguration = QuestionTextConfiguration(),
) {
/**
@@ -129,7 +116,7 @@ data class QuestionnaireViewItem(
}
/** Adds an answer to the existing answers and removes the draft answer. */
- internal fun addAnswer(
+ fun addAnswer(
questionnaireResponseItemAnswerComponent:
QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent
) {
@@ -145,8 +132,8 @@ data class QuestionnaireViewItem(
}
/** Removes an answer from the existing answers, as well as any draft answer. */
- internal fun removeAnswer(
- questionnaireResponseItemAnswerComponent:
+ fun removeAnswer(
+ vararg questionnaireResponseItemAnswerComponent:
QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent
) {
check(questionnaireItem.repeats) {
@@ -155,8 +142,8 @@ data class QuestionnaireViewItem(
answersChangedCallback(
questionnaireItem,
questionnaireResponseItem,
- answers.toMutableList().apply {
- removeIf { it.value.equalsDeep(questionnaireResponseItemAnswerComponent.value) }
+ answers.filterNot { ans ->
+ questionnaireResponseItemAnswerComponent.any { ans.value.equalsDeep(it.value) }
},
null
)
@@ -170,7 +157,11 @@ data class QuestionnaireViewItem(
answersChangedCallback(questionnaireItem, questionnaireResponseItem, listOf(), draftAnswer)
}
- internal fun answerString(context: Context): String {
+ /**
+ * Returns a given answer (The respondent's answer(s) to the question) along with [displayString]
+ * if question is answered else 'Not Answered'
+ */
+ fun answerString(context: Context): String {
if (!questionnaireResponseItem.hasAnswer()) return context.getString(R.string.not_answered)
return questionnaireResponseItem.answer.joinToString { it.value.displayString(context) }
}
@@ -181,38 +172,12 @@ data class QuestionnaireViewItem(
return answers.any { it.value.equalsDeep(answerOption.value) }
}
- /**
- * In a `choice` or `open-choice` type question, the answer options are defined in one of the
- * three elements in the questionnaire:
- *
- * - `Questionnaire.item.answerOption`: a list of permitted answers to the question
- * - `Questionnaire.item.answerValueSet`: a reference to a value set containing a list of
- * permitted answers to the question
- * - `Extension answer-expression`: an expression based extension which defines the x-fhir-query
- * or fhirpath to evaluate permitted answer options
- *
- * This property returns the answer options defined in one of the sources above. If the answer
- * options are defined in `Questionnaire.item.answerValueSet`, the answer value set will be
- * expanded.
- */
- internal val answerOption: List
- get() =
- runBlocking(Dispatchers.IO) {
- when {
- questionnaireItem.answerOption.isNotEmpty() -> questionnaireItem.answerOption
- !questionnaireItem.answerValueSet.isNullOrEmpty() ->
- resolveAnswerValueSet(questionnaireItem.answerValueSet)
- questionnaireItem.answerExpression != null -> resolveAnswerExpression(questionnaireItem)
- else -> emptyList()
- }
- }
-
/**
* Fetches the question title that should be displayed to user. The title is first fetched from
* [Questionnaire.QuestionnaireResponseItemComponent] (derived from cqf-expression), otherwise it
* is derived from [localizedTextSpanned] of [QuestionnaireResponse.QuestionnaireItemComponent]
*/
- internal val questionText: Spanned? by lazy {
+ val questionText: Spanned? by lazy {
questionnaireResponseItem.text?.toSpanned() ?: questionnaireItem.localizedTextSpanned
}
diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/AutoCompleteViewHolderFactory.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/AutoCompleteViewHolderFactory.kt
index 6f2c59efc6..d71bc7b8b5 100644
--- a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/AutoCompleteViewHolderFactory.kt
+++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/AutoCompleteViewHolderFactory.kt
@@ -1,5 +1,5 @@
/*
- * Copyright 2022 Google LLC
+ * Copyright 2022-2023 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -25,6 +25,7 @@ import androidx.core.view.get
import androidx.core.view.isEmpty
import com.google.android.fhir.datacapture.R
import com.google.android.fhir.datacapture.extensions.displayString
+import com.google.android.fhir.datacapture.extensions.identifierString
import com.google.android.fhir.datacapture.validation.Invalid
import com.google.android.fhir.datacapture.validation.NotValidated
import com.google.android.fhir.datacapture.validation.Valid
@@ -62,10 +63,12 @@ internal object AutoCompleteViewHolderFactory :
val answer =
QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply {
value =
- questionnaireViewItem.answerOption
+ questionnaireViewItem.enabledAnswerOptions
.first {
- it.value.displayString(header.context) ==
- autoCompleteTextView.adapter.getItem(position) as String
+ it.value.identifierString(header.context) ==
+ (autoCompleteTextView.adapter.getItem(position)
+ as AutoCompleteViewAnswerOption)
+ .answerId
}
.valueCoding
}
@@ -78,9 +81,20 @@ internal object AutoCompleteViewHolderFactory :
override fun bind(questionnaireViewItem: QuestionnaireViewItem) {
header.bind(questionnaireViewItem)
header.showRequiredOrOptionalTextInHeaderView(questionnaireViewItem)
- val answerOptionString =
- questionnaireViewItem.answerOption.map { it.value.displayString(header.context) }
- val adapter = ArrayAdapter(header.context, R.layout.drop_down_list_item, answerOptionString)
+ val answerOptionValues =
+ questionnaireViewItem.enabledAnswerOptions.map {
+ AutoCompleteViewAnswerOption(
+ answerId = it.value.identifierString(header.context),
+ answerDisplay = it.value.displayString(header.context)
+ )
+ }
+ val adapter =
+ ArrayAdapter(
+ header.context,
+ R.layout.drop_down_list_item,
+ R.id.answer_option_textview,
+ answerOptionValues
+ )
autoCompleteTextView.setAdapter(adapter)
// Remove chips if any from the last bindView call on this VH.
chipContainer.removeAllViews()
@@ -204,3 +218,13 @@ internal object AutoCompleteViewHolderFactory :
}
}
}
+
+/**
+ * An answer option that would show up as a dropdown item in an [AutoCompleteViewHolderFactory]
+ * textview
+ */
+internal data class AutoCompleteViewAnswerOption(val answerId: String, val answerDisplay: String) {
+ override fun toString(): String {
+ return this.answerDisplay
+ }
+}
diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/CheckBoxGroupViewHolderFactory.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/CheckBoxGroupViewHolderFactory.kt
index 0dc39742d3..5a6be75daa 100644
--- a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/CheckBoxGroupViewHolderFactory.kt
+++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/CheckBoxGroupViewHolderFactory.kt
@@ -1,5 +1,5 @@
/*
- * Copyright 2022 Google LLC
+ * Copyright 2022-2023 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -71,7 +71,7 @@ internal object CheckBoxGroupViewHolderFactory :
flow.setWrapMode(Flow.WRAP_NONE)
}
}
- questionnaireViewItem.answerOption
+ questionnaireViewItem.enabledAnswerOptions
.map { answerOption -> View.generateViewId() to answerOption }
.onEach { populateViewWithAnswerOption(it.first, it.second, choiceOrientation) }
.map { it.first }
@@ -127,24 +127,24 @@ internal object CheckBoxGroupViewHolderFactory :
// if this answer option has optionExclusive extension, then deselect other
// answer options.
val optionExclusiveIndex = checkboxGroup.indexOfChild(it) - 1
- for (i in 0 until questionnaireViewItem.answerOption.size) {
+ for (i in 0 until questionnaireViewItem.enabledAnswerOptions.size) {
if (optionExclusiveIndex == i) {
continue
}
(checkboxGroup.getChildAt(i + 1) as CheckBox).isChecked = false
newAnswers.removeIf {
- it.value.equalsDeep(questionnaireViewItem.answerOption[i].value)
+ it.value.equalsDeep(questionnaireViewItem.enabledAnswerOptions[i].value)
}
}
} else {
// deselect optionExclusive answer option.
- for (i in 0 until questionnaireViewItem.answerOption.size) {
- if (!questionnaireViewItem.answerOption[i].optionExclusive) {
+ for (i in 0 until questionnaireViewItem.enabledAnswerOptions.size) {
+ if (!questionnaireViewItem.enabledAnswerOptions[i].optionExclusive) {
continue
}
(checkboxGroup.getChildAt(i + 1) as CheckBox).isChecked = false
newAnswers.removeIf {
- it.value.equalsDeep(questionnaireViewItem.answerOption[i].value)
+ it.value.equalsDeep(questionnaireViewItem.enabledAnswerOptions[i].value)
}
}
}
diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/DialogSelectViewHolderFactory.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/DialogSelectViewHolderFactory.kt
index 74b97f1db9..ded388f279 100644
--- a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/DialogSelectViewHolderFactory.kt
+++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/DialogSelectViewHolderFactory.kt
@@ -1,5 +1,5 @@
/*
- * Copyright 2022 Google LLC
+ * Copyright 2023 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -168,7 +168,7 @@ internal class QuestionnaireItemDialogSelectViewModel : ViewModel() {
}
private fun selectedOptionsFlow(linkId: String) =
- linkIdsToSelectedOptionsFlow.getOrPut(linkId) { MutableSharedFlow(replay = 1) }
+ linkIdsToSelectedOptionsFlow.getOrPut(linkId) { MutableSharedFlow(replay = 0) }
}
data class SelectedOptions(
@@ -190,7 +190,7 @@ data class OptionSelectOption(
private fun QuestionnaireViewItem.extractInitialOptions(context: Context): SelectedOptions {
val options =
- answerOption.map { answerOption ->
+ enabledAnswerOptions.map { answerOption ->
OptionSelectOption(
item = answerOption,
selected = isAnswerOptionSelected(answerOption),
diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/DropDownViewHolderFactory.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/DropDownViewHolderFactory.kt
index f3a4fb32df..12167eb50c 100644
--- a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/DropDownViewHolderFactory.kt
+++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/DropDownViewHolderFactory.kt
@@ -1,5 +1,5 @@
/*
- * Copyright 2022 Google LLC
+ * Copyright 2022-2023 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -28,6 +28,7 @@ import com.google.android.fhir.datacapture.R
import com.google.android.fhir.datacapture.extensions.displayString
import com.google.android.fhir.datacapture.extensions.getRequiredOrOptionalText
import com.google.android.fhir.datacapture.extensions.getValidationErrorMessage
+import com.google.android.fhir.datacapture.extensions.identifierString
import com.google.android.fhir.datacapture.extensions.itemAnswerOptionImage
import com.google.android.fhir.datacapture.extensions.localizedFlyoverSpanned
import com.google.android.fhir.datacapture.validation.ValidationResult
@@ -58,28 +59,34 @@ internal object DropDownViewHolderFactory :
override fun bind(questionnaireViewItem: QuestionnaireViewItem) {
cleanupOldState()
header.bind(questionnaireViewItem)
- textInputLayout.hint = questionnaireViewItem.enabledDisplayItems.localizedFlyoverSpanned
with(textInputLayout) {
hint = questionnaireViewItem.enabledDisplayItems.localizedFlyoverSpanned
helperText = getRequiredOrOptionalText(questionnaireViewItem, context)
}
val answerOptionList =
- this.questionnaireViewItem.answerOption
+ this.questionnaireViewItem.enabledAnswerOptions
.map {
DropDownAnswerOption(
+ it.value.identifierString(context),
it.value.displayString(context),
it.itemAnswerOptionImage(context)
)
}
.toMutableList()
- answerOptionList.add(0, DropDownAnswerOption(context.getString(R.string.hyphen), null))
+ answerOptionList.add(
+ 0,
+ DropDownAnswerOption(
+ context.getString(R.string.hyphen),
+ context.getString(R.string.hyphen),
+ null
+ )
+ )
val adapter =
AnswerOptionDropDownArrayAdapter(context, R.layout.drop_down_list_item, answerOptionList)
- val selectedAnswer =
- questionnaireViewItem.answers.singleOrNull()?.value?.displayString(header.context)
+ val selectedAnswerIdentifier =
+ questionnaireViewItem.answers.singleOrNull()?.value?.identifierString(header.context)
answerOptionList
- .filter { it.answerOptionString == selectedAnswer }
- .singleOrNull()
+ .firstOrNull { it.answerId == selectedAnswerIdentifier }
?.let {
autoCompleteTextView.setText(it.answerOptionString)
autoCompleteTextView.setSelection(it.answerOptionString.length)
@@ -102,8 +109,8 @@ internal object DropDownViewHolderFactory :
null
)
val selectedAnswer =
- questionnaireViewItem.answerOption
- .firstOrNull { it.value.displayString(context) == selectedItem?.answerOptionString }
+ questionnaireViewItem.enabledAnswerOptions
+ .firstOrNull { it.value.identifierString(context) == selectedItem?.answerId }
?.value
if (selectedAnswer == null) {
@@ -167,8 +174,9 @@ internal class AnswerOptionDropDownArrayAdapter(
}
internal data class DropDownAnswerOption(
+ val answerId: String,
val answerOptionString: String,
- val answerOptionImage: Drawable?
+ val answerOptionImage: Drawable? = null
) {
override fun toString(): String {
return this.answerOptionString
diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/EditTextQuantityViewHolderFactory.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/EditTextQuantityViewHolderFactory.kt
deleted file mode 100644
index 9b7853c5aa..0000000000
--- a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/EditTextQuantityViewHolderFactory.kt
+++ /dev/null
@@ -1,103 +0,0 @@
-/*
- * Copyright 2022 Google LLC
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package com.google.android.fhir.datacapture.views.factories
-
-import android.text.Editable
-import android.text.InputType
-import com.google.android.fhir.datacapture.R
-import com.google.android.fhir.datacapture.views.QuestionnaireViewItem
-import com.google.android.material.textfield.TextInputEditText
-import com.google.android.material.textfield.TextInputLayout
-import java.math.BigDecimal
-import org.hl7.fhir.r4.model.Quantity
-import org.hl7.fhir.r4.model.QuestionnaireResponse
-
-/**
- * Inherits from QuestionnaireItemEditTextViewHolderFactory as only the numeric part of the quantity
- * is being handled right now. Will use a separate layout to handle the unit in the quantity.
- */
-internal object EditTextQuantityViewHolderFactory :
- EditTextViewHolderFactory(R.layout.edit_text_single_line_view) {
- override fun getQuestionnaireItemViewHolderDelegate() =
- object : QuestionnaireItemEditTextViewHolderDelegate(QUANTITY_INPUT_TYPE) {
- override fun handleInput(editable: Editable, questionnaireViewItem: QuestionnaireViewItem) {
- val input = getValue(editable.toString())
- if (input != null) {
- questionnaireViewItem.setAnswer(input)
- } else {
- questionnaireViewItem.clearAnswer()
- }
- }
-
- private fun getValue(
- text: String
- ): QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent? {
- // https://build.fhir.org/ig/HL7/sdc/behavior.html#initial
- // read default unit from initial, as ideally quantity must specify a unit
- return text.let {
- if (text.isEmpty()) {
- return null
- }
- try {
- val value = BigDecimal(text)
- val quantity =
- with(questionnaireViewItem.questionnaireItem) {
- if (this.hasInitial() && this.initialFirstRep.valueQuantity.hasCode())
- this.initialFirstRep.valueQuantity.let { initial ->
- Quantity().apply {
- this.value = value
- this.code = initial.code
- this.system = initial.system
- }
- }
- else Quantity().apply { this.value = value }
- }
- QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().setValue(quantity)
- } catch (exception: NumberFormatException) {
- textInputLayout.error =
- textInputLayout.context.getString(R.string.number_format_validation_error_msg)
- null
- }
- }
- }
-
- override fun updateUI(
- questionnaireViewItem: QuestionnaireViewItem,
- textInputEditText: TextInputEditText,
- textInputLayout: TextInputLayout,
- ) {
- val text =
- questionnaireViewItem.answers.singleOrNull()?.valueQuantity?.value?.toString() ?: ""
- if (isTextUpdatesRequired(text, textInputEditText.text.toString())) {
- textInputEditText.setText(text)
- }
- }
-
- fun isTextUpdatesRequired(answerText: String, inputText: String): Boolean {
- if (answerText.isEmpty() && inputText.isEmpty()) {
- return false
- }
- if (answerText.isEmpty() || inputText.isEmpty()) {
- return true
- }
- // Avoid shifting focus by updating text field if the values are the same
- return answerText.toDouble() != inputText.toDouble()
- }
- }
-}
-
-const val QUANTITY_INPUT_TYPE = InputType.TYPE_CLASS_NUMBER or InputType.TYPE_NUMBER_FLAG_SIGNED
diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/EditTextViewHolderFactory.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/EditTextViewHolderFactory.kt
index f6dfbb299b..688916505a 100644
--- a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/EditTextViewHolderFactory.kt
+++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/EditTextViewHolderFactory.kt
@@ -1,5 +1,5 @@
/*
- * Copyright 2022 Google LLC
+ * Copyright 2022-2023 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -21,14 +21,18 @@ import android.text.Editable
import android.text.TextWatcher
import android.view.View
import android.view.View.FOCUS_DOWN
+import android.view.View.GONE
+import android.view.View.VISIBLE
import android.view.inputmethod.EditorInfo
import android.view.inputmethod.InputMethodManager
+import android.widget.TextView
import androidx.annotation.LayoutRes
import androidx.core.widget.doAfterTextChanged
import com.google.android.fhir.datacapture.R
import com.google.android.fhir.datacapture.extensions.getRequiredOrOptionalText
import com.google.android.fhir.datacapture.extensions.getValidationErrorMessage
import com.google.android.fhir.datacapture.extensions.localizedFlyoverSpanned
+import com.google.android.fhir.datacapture.extensions.unit
import com.google.android.fhir.datacapture.validation.ValidationResult
import com.google.android.fhir.datacapture.views.HeaderView
import com.google.android.fhir.datacapture.views.QuestionnaireViewItem
@@ -48,12 +52,15 @@ abstract class QuestionnaireItemEditTextViewHolderDelegate(private val rawInputT
private lateinit var header: HeaderView
protected lateinit var textInputLayout: TextInputLayout
private lateinit var textInputEditText: TextInputEditText
+ private var unitTextView: TextView? = null
private var textWatcher: TextWatcher? = null
override fun init(itemView: View) {
header = itemView.findViewById(R.id.header)
textInputLayout = itemView.findViewById(R.id.text_input_layout)
textInputEditText = itemView.findViewById(R.id.text_input_edit_text)
+ unitTextView = itemView.findViewById(R.id.unit_text_view)
+
textInputEditText.setRawInputType(rawInputType)
// Override `setOnEditorActionListener` to avoid crash with `IllegalStateException` if it's not
// possible to move focus forward.
@@ -89,6 +96,11 @@ abstract class QuestionnaireItemEditTextViewHolderDelegate(private val rawInputT
textInputEditText.removeTextChangedListener(textWatcher)
updateUI(questionnaireViewItem, textInputEditText, textInputLayout)
+ unitTextView?.apply {
+ text = questionnaireViewItem.questionnaireItem.unit?.code
+ visibility = if (text.isNullOrEmpty()) GONE else VISIBLE
+ }
+
textWatcher =
textInputEditText.doAfterTextChanged { editable: Editable? ->
handleInput(editable!!, questionnaireViewItem)
diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/QuantityViewHolderFactory.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/QuantityViewHolderFactory.kt
new file mode 100644
index 0000000000..4273faabcb
--- /dev/null
+++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/QuantityViewHolderFactory.kt
@@ -0,0 +1,218 @@
+/*
+ * Copyright 2022-2023 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.android.fhir.datacapture.views.factories
+
+import android.content.Context
+import android.text.Editable
+import android.text.InputType
+import android.text.TextWatcher
+import android.view.View
+import android.view.inputmethod.EditorInfo
+import android.view.inputmethod.InputMethodManager
+import android.widget.AdapterView
+import androidx.core.widget.doAfterTextChanged
+import com.google.android.fhir.datacapture.R
+import com.google.android.fhir.datacapture.extensions.getRequiredOrOptionalText
+import com.google.android.fhir.datacapture.extensions.localizedFlyoverSpanned
+import com.google.android.fhir.datacapture.extensions.unitOption
+import com.google.android.fhir.datacapture.validation.Invalid
+import com.google.android.fhir.datacapture.validation.NotValidated
+import com.google.android.fhir.datacapture.validation.Valid
+import com.google.android.fhir.datacapture.validation.ValidationResult
+import com.google.android.fhir.datacapture.views.HeaderView
+import com.google.android.fhir.datacapture.views.QuestionnaireViewItem
+import com.google.android.material.textfield.MaterialAutoCompleteTextView
+import com.google.android.material.textfield.TextInputEditText
+import com.google.android.material.textfield.TextInputLayout
+import java.math.BigDecimal
+import org.hl7.fhir.r4.model.Coding
+import org.hl7.fhir.r4.model.Quantity
+import org.hl7.fhir.r4.model.QuestionnaireResponse
+
+internal object QuantityViewHolderFactory :
+ QuestionnaireItemViewHolderFactory(R.layout.quantity_view) {
+ override fun getQuestionnaireItemViewHolderDelegate() =
+ object : QuestionnaireItemViewHolderDelegate {
+ override lateinit var questionnaireViewItem: QuestionnaireViewItem
+
+ private lateinit var header: HeaderView
+ protected lateinit var textInputLayout: TextInputLayout
+ private lateinit var textInputEditText: TextInputEditText
+ private lateinit var unitTextInputLayout: TextInputLayout
+ private lateinit var unitAutoCompleteTextView: MaterialAutoCompleteTextView
+ private var textWatcher: TextWatcher? = null
+ private lateinit var context: Context
+
+ override fun init(itemView: View) {
+ context = itemView.context
+ header = itemView.findViewById(R.id.header)
+ textInputLayout = itemView.findViewById(R.id.text_input_layout)
+ textInputEditText =
+ itemView.findViewById(R.id.text_input_edit_text).apply {
+ setRawInputType(QUANTITY_INPUT_TYPE)
+ // Override `setOnEditorActionListener` to avoid crash with `IllegalStateException` if
+ // it's not possible to move focus forward.
+ // See
+ // https://stackoverflow.com/questions/13614101/fatal-crash-focus-search-returned-a-view-that-wasnt-able-to-take-focus/47991577
+ setOnEditorActionListener { view, actionId, _ ->
+ if (actionId != EditorInfo.IME_ACTION_NEXT) {
+ false
+ }
+ view.focusSearch(View.FOCUS_DOWN)?.requestFocus(View.FOCUS_DOWN) ?: false
+ }
+ setOnFocusChangeListener { view, focused ->
+ if (!focused) {
+ (view.context.applicationContext.getSystemService(Context.INPUT_METHOD_SERVICE)
+ as InputMethodManager)
+ .hideSoftInputFromWindow(view.windowToken, 0)
+
+ // Update answer even if the text box loses focus without any change. This will mark
+ // the
+ // questionnaire response item as being modified in the view model and trigger
+ // validation.
+ handleInput(textInputEditText.editableText, null)
+ }
+ }
+ }
+
+ unitTextInputLayout = itemView.findViewById(R.id.unit_text_input_layout)
+ unitAutoCompleteTextView =
+ itemView.findViewById(R.id.unit_auto_complete).apply {
+ onItemClickListener =
+ AdapterView.OnItemClickListener { _, _, position, _ ->
+ handleInput(
+ null,
+ questionnaireViewItem.questionnaireItem.unitOption[position],
+ )
+ }
+ }
+ }
+
+ override fun bind(questionnaireViewItem: QuestionnaireViewItem) {
+ header.bind(questionnaireViewItem)
+ with(textInputLayout) {
+ hint = questionnaireViewItem.enabledDisplayItems.localizedFlyoverSpanned
+ helperText = getRequiredOrOptionalText(questionnaireViewItem, context)
+ }
+ displayValidationResult(questionnaireViewItem.validationResult)
+
+ textInputEditText.removeTextChangedListener(textWatcher)
+ updateUI()
+ textWatcher =
+ textInputEditText.doAfterTextChanged { editable: Editable? ->
+ handleInput(editable!!, null)
+ }
+ }
+
+ private fun displayValidationResult(validationResult: ValidationResult) {
+ textInputLayout.error =
+ when (validationResult) {
+ is NotValidated,
+ Valid -> null
+ is Invalid -> validationResult.getSingleStringValidationMessage()
+ }
+ }
+
+ override fun setReadOnly(isReadOnly: Boolean) {
+ textInputLayout.isEnabled = !isReadOnly
+ textInputEditText.isEnabled = !isReadOnly
+ unitTextInputLayout.isEnabled = !isReadOnly
+ unitAutoCompleteTextView.isEnabled = !isReadOnly
+ }
+
+ private fun handleInput(editable: Editable?, unitDropDown: Coding?) {
+ var decimal: BigDecimal? = null
+ var unit: Coding? = null
+
+ // Read decimal value and unit from complete answer
+ questionnaireViewItem.answers.singleOrNull()?.let {
+ val quantity = it.value as Quantity
+ decimal = quantity.value
+ unit = Coding(quantity.system, quantity.code, quantity.unit)
+ }
+
+ // Read decimal value and unit from partial answer
+ questionnaireViewItem.draftAnswer?.let {
+ when (it) {
+ is BigDecimal -> decimal = it
+ is Coding -> unit = it
+ }
+ }
+
+ // Update decimal value and unit
+ editable?.toString()?.let { decimal = it.toBigDecimalOrNull() }
+ unitDropDown?.let { unit = it }
+
+ if (decimal == null && unit == null) {
+ questionnaireViewItem.clearAnswer()
+ } else if (decimal == null) {
+ questionnaireViewItem.setDraftAnswer(unit)
+ } else if (unit == null) {
+ questionnaireViewItem.setDraftAnswer(decimal)
+ } else {
+ questionnaireViewItem.setAnswer(
+ QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply {
+ value =
+ Quantity(null, decimal!!.toDouble(), unit!!.system, unit!!.code, unit!!.display)
+ }
+ )
+ }
+ }
+
+ private fun updateUI() {
+ val text =
+ questionnaireViewItem.answers.singleOrNull()?.valueQuantity?.value?.toString()
+ ?: questionnaireViewItem.draftAnswer?.let {
+ if (it is BigDecimal) it.toString() else ""
+ }
+ ?: ""
+ if (isTextUpdatesRequired(text, textInputEditText.text.toString())) {
+ textInputEditText.setText(text)
+ }
+
+ val unit =
+ questionnaireViewItem.answers.singleOrNull()?.valueQuantity?.let {
+ Coding(it.system, it.code, it.unit)
+ }
+ ?: questionnaireViewItem.draftAnswer?.let { if (it is Coding) it else null }
+ unitAutoCompleteTextView.setText(unit?.display ?: "")
+
+ val unitAdapter =
+ AnswerOptionDropDownArrayAdapter(
+ context,
+ R.layout.drop_down_list_item,
+ questionnaireViewItem.questionnaireItem.unitOption.map {
+ DropDownAnswerOption(it.code, it.display)
+ }
+ )
+ unitAutoCompleteTextView.setAdapter(unitAdapter)
+ }
+
+ private fun isTextUpdatesRequired(answerText: String, inputText: String): Boolean {
+ if (answerText.isEmpty() && inputText.isEmpty()) {
+ return false
+ }
+ if (answerText.isEmpty() || inputText.isEmpty()) {
+ return true
+ }
+ // Avoid shifting focus by updating text field if the values are the same
+ return answerText.toDouble() != inputText.toDouble()
+ }
+ }
+}
+
+const val QUANTITY_INPUT_TYPE = InputType.TYPE_CLASS_NUMBER or InputType.TYPE_NUMBER_FLAG_DECIMAL
diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/RadioGroupViewHolderFactory.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/RadioGroupViewHolderFactory.kt
index f5a2c520c6..bd2cac435d 100644
--- a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/RadioGroupViewHolderFactory.kt
+++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/RadioGroupViewHolderFactory.kt
@@ -1,5 +1,5 @@
/*
- * Copyright 2022 Google LLC
+ * Copyright 2022-2023 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -70,7 +70,7 @@ internal object RadioGroupViewHolderFactory :
flow.setWrapMode(Flow.WRAP_NONE)
}
}
- questionnaireViewItem.answerOption
+ questionnaireViewItem.enabledAnswerOptions
.map { answerOption -> View.generateViewId() to answerOption }
.onEach { populateViewWithAnswerOption(it.first, it.second, choiceOrientation) }
.map { it.first }
diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/ReviewViewHolderFactory.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/ReviewViewHolderFactory.kt
index 0eb6194251..0ee5e35b4c 100644
--- a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/ReviewViewHolderFactory.kt
+++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/ReviewViewHolderFactory.kt
@@ -1,5 +1,5 @@
/*
- * Copyright 2022 Google LLC
+ * Copyright 2022-2023 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -23,8 +23,8 @@ import android.widget.TextView
import androidx.constraintlayout.widget.ConstraintLayout
import com.google.android.fhir.datacapture.R
import com.google.android.fhir.datacapture.extensions.getHeaderViewVisibility
+import com.google.android.fhir.datacapture.extensions.getLocalizedInstructionsSpanned
import com.google.android.fhir.datacapture.extensions.localizedFlyoverSpanned
-import com.google.android.fhir.datacapture.extensions.localizedInstructionsSpanned
import com.google.android.fhir.datacapture.extensions.localizedPrefixSpanned
import com.google.android.fhir.datacapture.extensions.localizedTextSpanned
import com.google.android.fhir.datacapture.extensions.updateTextAndVisibility
@@ -69,7 +69,7 @@ internal object ReviewViewHolderFactory : QuestionnaireItemViewHolderFactory(R.l
questionnaireViewItem.questionnaireItem.localizedTextSpanned
)
hint.updateTextAndVisibility(
- questionnaireViewItem.enabledDisplayItems.localizedInstructionsSpanned
+ questionnaireViewItem.enabledDisplayItems.getLocalizedInstructionsSpanned()
)
header.visibility = getHeaderViewVisibility(prefix, question, hint)
diff --git a/datacapture/src/main/res/layout/boolean_choice_view.xml b/datacapture/src/main/res/layout/boolean_choice_view.xml
index ab9af4c05f..6747e0622b 100644
--- a/datacapture/src/main/res/layout/boolean_choice_view.xml
+++ b/datacapture/src/main/res/layout/boolean_choice_view.xml
@@ -28,7 +28,6 @@
android:id="@+id/header"
android:layout_width="match_parent"
android:layout_height="wrap_content"
- android:layout_marginBottom="@dimen/header_margin_bottom"
/>
-
+>
+
+
diff --git a/datacapture/src/main/res/layout/drop_down_view.xml b/datacapture/src/main/res/layout/drop_down_view.xml
index cdb9ce396e..0c99c82b54 100644
--- a/datacapture/src/main/res/layout/drop_down_view.xml
+++ b/datacapture/src/main/res/layout/drop_down_view.xml
@@ -24,7 +24,6 @@
android:id="@+id/header"
android:layout_width="match_parent"
android:layout_height="wrap_content"
- android:layout_marginBottom="@dimen/header_margin_bottom"
/>
-
-
+
+
+
+
+
+
-
+
diff --git a/datacapture/src/main/res/layout/group_header_view.xml b/datacapture/src/main/res/layout/group_header_view.xml
index 1f6bfae306..def51fbde1 100644
--- a/datacapture/src/main/res/layout/group_header_view.xml
+++ b/datacapture/src/main/res/layout/group_header_view.xml
@@ -29,8 +29,9 @@
android:id="@+id/header"
android:layout_width="match_parent"
android:layout_height="wrap_content"
- android:layout_marginVertical="@dimen/item_margin_vertical"
+ android:layout_marginTop="@dimen/item_margin_vertical"
android:layout_marginHorizontal="@dimen/item_margin_horizontal"
+ style="?attr/questionnaireGroupTypeHeaderStyle"
/>
diff --git a/datacapture/src/main/res/layout/option_select_view.xml b/datacapture/src/main/res/layout/option_select_view.xml
index f531b6021e..c541d050c8 100644
--- a/datacapture/src/main/res/layout/option_select_view.xml
+++ b/datacapture/src/main/res/layout/option_select_view.xml
@@ -28,7 +28,6 @@
android:id="@+id/header"
android:layout_width="match_parent"
android:layout_height="wrap_content"
- android:layout_marginBottom="@dimen/header_margin_bottom"
/>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/datacapture/src/main/res/layout/radio_group_view.xml b/datacapture/src/main/res/layout/radio_group_view.xml
index 357c93e0d7..98fe861fae 100644
--- a/datacapture/src/main/res/layout/radio_group_view.xml
+++ b/datacapture/src/main/res/layout/radio_group_view.xml
@@ -28,7 +28,6 @@
android:id="@+id/header"
android:layout_width="match_parent"
android:layout_height="wrap_content"
- android:layout_marginBottom="@dimen/header_margin_bottom"
/>
+
+
+
+
+
+
@@ -35,6 +41,9 @@
+
+
+
diff --git a/datacapture/src/main/res/values/dimens.xml b/datacapture/src/main/res/values/dimens.xml
index 0d5d4c26b9..b32fe068d1 100644
--- a/datacapture/src/main/res/values/dimens.xml
+++ b/datacapture/src/main/res/values/dimens.xml
@@ -27,6 +27,7 @@
4dp
+ 4dp
16dp
4dp
24dp
diff --git a/datacapture/src/main/res/values/strings.xml b/datacapture/src/main/res/values/strings.xml
index a528e5213d..ac4ccf2aae 100644
--- a/datacapture/src/main/res/values/strings.xml
+++ b/datacapture/src/main/res/values/strings.xml
@@ -95,14 +95,24 @@
Minimum value allowed is:%1$s
+ Maximum value allowed is:%1$s
+ The minimum number of characters that are permitted in the answer is: %1$s
The maximum number of decimal places that are permitted in the answer is: %1$s
Maximum value allowed is:%1$s
+ name="regex_validation_error_msg"
+ >The answer doesn\'t match regular expression: %1$s
Only use (.) between two numbers. Other special characters are not supported.
diff --git a/datacapture/src/main/res/values/styles.xml b/datacapture/src/main/res/values/styles.xml
index 10895c07f2..7e83568071 100644
--- a/datacapture/src/main/res/values/styles.xml
+++ b/datacapture/src/main/res/values/styles.xml
@@ -37,14 +37,19 @@
name="Theme.Questionnaire"
parent="Theme.Material3.DayNight.NoActionBar"
>
+ -
+ @style/Questionnaire.GroupTypeHeaderStyle
+
-
@style/TextAppearance.Material3.TitleMedium
+ -
+ @style/Questionnaire.QuestionHeaderStyle
+
- @style/TextAppearance.Material3.TitleMedium
-
- - @style/TextAppearance.Material3.TitleMedium
-
@style/Widget.MaterialComponents.Button.TextButton.Icon
+ -
+ @style/Questionnaire.MediaImageStyle
+
- @style/Questionnaire.BottomNavContainerStyle
@@ -172,6 +180,14 @@
+
+
+
+
+
+