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") +

+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 +

+Questionnaire Rendered in 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. + +Hello World app + +## 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. + +Patient list + +## 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 @@ + + + + + +