diff --git a/CODEOWNERS b/CODEOWNERS
index 512f12d413..f6c08701fd 100644
--- a/CODEOWNERS
+++ b/CODEOWNERS
@@ -1,14 +1,7 @@
# This file lists people who get automatically added as Reviewers for Pull Requests.
-#
-# Note that we intentionally do NOT just include all committers here by using @google/android-fhir,
-# nor were we able to get it working using a group such as @google/android-fhir-reviewers;
-# details about why are described on https://github.com/google/android-fhir/issues/2320
-# and https://github.com/google/android-fhir/pull/2536.
-#
-# See https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners
# These people for *ALL* Pull Requests:
-* @aditya-07 @jingtang10 @MJ1998 @santosh-pingle
+* @google/android-fhir-reviewers
# These for anything documentation related:
docs/* @vorburger
diff --git a/buildSrc/src/main/kotlin/Dependencies.kt b/buildSrc/src/main/kotlin/Dependencies.kt
index 83590d9f68..594599d64e 100644
--- a/buildSrc/src/main/kotlin/Dependencies.kt
+++ b/buildSrc/src/main/kotlin/Dependencies.kt
@@ -87,14 +87,6 @@ object Dependencies {
const val playServicesLocation =
"com.google.android.gms:play-services-location:${Versions.playServicesLocation}"
- const val androidFhirGroup = "com.google.android.fhir"
- 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 apacheCommonsCompress =
"org.apache.commons:commons-compress:${Versions.apacheCommonsCompress}"
@@ -131,9 +123,6 @@ object Dependencies {
const val xmlUnit = "org.xmlunit:xmlunit-core:${Versions.xmlUnit}"
object Versions {
- const val androidFhirCommon = "0.1.0-alpha05"
- const val androidFhirEngine = "0.1.0-beta05"
- const val androidFhirKnowledge = "0.1.0-alpha03"
const val apacheCommonsCompress = "1.21"
const val desugarJdkLibs = "2.0.3"
const val caffeine = "2.9.1"
diff --git a/buildSrc/src/main/kotlin/Releases.kt b/buildSrc/src/main/kotlin/Releases.kt
index 89e5b6c34d..dae9dfcc8b 100644
--- a/buildSrc/src/main/kotlin/Releases.kt
+++ b/buildSrc/src/main/kotlin/Releases.kt
@@ -54,13 +54,13 @@ object Releases {
object DataCapture : LibraryArtifact {
override val artifactId = "data-capture"
- override val version = "1.1.0"
+ override val version = "1.2.0"
override val name = "Android FHIR Structured Data Capture Library"
}
object Workflow : LibraryArtifact {
override val artifactId = "workflow"
- override val version = "0.1.0-alpha04"
+ override val version = "0.1.0-beta01"
override val name = "Android FHIR Workflow Library"
}
@@ -81,7 +81,7 @@ object Releases {
object Knowledge : LibraryArtifact {
override val artifactId = "knowledge"
- override val version = "0.1.0-alpha03"
+ override val version = "0.1.0-beta01"
override val name = "Android FHIR Knowledge Manager Library"
}
diff --git a/catalog/src/main/assets/component_per_question_custom_style.json b/catalog/src/main/assets/component_per_question_custom_style.json
new file mode 100644
index 0000000000..2f2e8f3b86
--- /dev/null
+++ b/catalog/src/main/assets/component_per_question_custom_style.json
@@ -0,0 +1,149 @@
+{
+ "resourceType": "Questionnaire",
+ "item": [
+ {
+ "linkId": "1",
+ "text": "Custom style 1",
+ "type": "display",
+ "extension": [
+ {
+ "url": "https://github.com/google/android-fhir/tree/master/datacapture/android-style",
+ "extension": [
+ {
+ "url": "question_text_view",
+ "valueString": "CustomStyle_1"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "linkId": "2",
+ "text": "Custom style 2",
+ "type": "display",
+ "extension": [
+ {
+ "url": "https://github.com/google/android-fhir/tree/master/datacapture/android-style",
+ "extension": [
+ {
+ "url": "question_text_view",
+ "valueString": "CustomStyle_2"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "linkId": "3",
+ "text": "Custom style 3",
+ "type": "display",
+ "extension": [
+ {
+ "url": "https://github.com/google/android-fhir/tree/master/datacapture/android-style",
+ "extension": [
+ {
+ "url": "question_text_view",
+ "valueString": "CustomStyle_3"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "linkId": "4",
+ "text": "Custom style 4",
+ "type": "display",
+ "extension": [
+ {
+ "url": "https://github.com/google/android-fhir/tree/master/datacapture/android-style",
+ "extension": [
+ {
+ "url": "question_text_view",
+ "valueString": "CustomStyle_4"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "linkId": "5",
+ "text": "Custom style 5",
+ "type": "display",
+ "extension": [
+ {
+ "url": "https://github.com/google/android-fhir/tree/master/datacapture/android-style",
+ "extension": [
+ {
+ "url": "question_text_view",
+ "valueString": "CustomStyle_5"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "linkId": "6",
+ "text": "Custom style 6",
+ "type": "display",
+ "extension": [
+ {
+ "url": "https://github.com/google/android-fhir/tree/master/datacapture/android-style",
+ "extension": [
+ {
+ "url": "question_text_view",
+ "valueString": "CustomStyle_6"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "linkId": "7",
+ "text": "Custom style 7",
+ "type": "display",
+ "extension": [
+ {
+ "url": "https://github.com/google/android-fhir/tree/master/datacapture/android-style",
+ "extension": [
+ {
+ "url": "question_text_view",
+ "valueString": "CustomStyle_7"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "linkId": "8",
+ "text": "Custom style 8",
+ "type": "display",
+ "extension": [
+ {
+ "url": "https://github.com/google/android-fhir/tree/master/datacapture/android-style",
+ "extension": [
+ {
+ "url": "question_text_view",
+ "valueString": "CustomStyle_8"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "linkId": "9",
+ "text": "Custom style 9",
+ "type": "display",
+ "extension": [
+ {
+ "url": "https://github.com/google/android-fhir/tree/master/datacapture/android-style",
+ "extension": [
+ {
+ "url": "question_text_view",
+ "valueString": "CustomStyle_9"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+}
\ No newline at end of file
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 bdafbcd17a..d9e4637ada 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
@@ -152,6 +152,11 @@ class ComponentListViewModel(application: Application, private val state: SavedS
R.string.component_name_location_widget,
"component_location_widget.json",
),
+ QUESTION_ITEM_CUSTOM_STYLE(
+ R.drawable.text_format_48dp,
+ R.string.component_name_per_question_custom_style,
+ "component_per_question_custom_style.json",
+ ),
}
val viewItemList =
@@ -177,6 +182,7 @@ class ComponentListViewModel(application: Application, private val state: SavedS
ViewItem.ComponentItem(Component.ITEM_ANSWER_MEDIA),
ViewItem.ComponentItem(Component.INITIAL_VALUE),
ViewItem.ComponentItem(Component.LOCATION_WIDGET),
+ ViewItem.ComponentItem(Component.QUESTION_ITEM_CUSTOM_STYLE),
)
fun isComponent(context: Context, title: String) =
diff --git a/catalog/src/main/res/drawable/ic_location_on.xml b/catalog/src/main/res/drawable/ic_location_on.xml
index 0f96a89039..9821fffba8 100644
--- a/catalog/src/main/res/drawable/ic_location_on.xml
+++ b/catalog/src/main/res/drawable/ic_location_on.xml
@@ -1,7 +1,7 @@
+
+
+
+
diff --git a/catalog/src/main/res/values-night/colors.xml b/catalog/src/main/res/values-night/colors.xml
new file mode 100644
index 0000000000..d80470737a
--- /dev/null
+++ b/catalog/src/main/res/values-night/colors.xml
@@ -0,0 +1,36 @@
+
+
+
+ #000000
+ #0C0A20
+ #201441
+ #341F63
+ #482A85
+ #5C35A6
+ #7F5FBA
+ #A289CF
+ #C5B3E3
+ #FFFFFF
+ #FFFFFF
+ #FFFFFF
+ #FFFFFF
+ #FFFFFF
+ #FFFFFF
+ #FFFFFF
+ #000000
+ #000000
+
diff --git a/catalog/src/main/res/values/colors.xml b/catalog/src/main/res/values/colors.xml
index 8a1561f3da..f9b63e732a 100644
--- a/catalog/src/main/res/values/colors.xml
+++ b/catalog/src/main/res/values/colors.xml
@@ -102,4 +102,26 @@
#C4C7C5
#8E918F
+
+
+ #7A9FFF
+ #668FFF
+ #5581FF
+ #476FFF
+ #3B5CFF
+ #3249FF
+ #2936FF
+ #2024FF
+ #1816FF
+ #FFFFFF
+ #FFFFFF
+ #FFFFFF
+ #FFFFFF
+ #FFFFFF
+ #FFFFFF
+ #FFFFFF
+ #FFFFFF
+ #FFFFFF
+ #FFFFFF
+
diff --git a/catalog/src/main/res/values/strings.xml b/catalog/src/main/res/values/strings.xml
index 7e09047feb..ae47067784 100644
--- a/catalog/src/main/res/values/strings.xml
+++ b/catalog/src/main/res/values/strings.xml
@@ -37,6 +37,9 @@
Repeated Group
Attachment
Location Widget
+ Per question custom style
Default
Paginated
Review
diff --git a/catalog/src/main/res/values/styles.xml b/catalog/src/main/res/values/styles.xml
index f88c5ea9fa..f2afa052f3 100644
--- a/catalog/src/main/res/values/styles.xml
+++ b/catalog/src/main/res/values/styles.xml
@@ -80,7 +80,10 @@
@@ -98,4 +101,170 @@
- 2
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/codelabs/datacapture/README.md b/codelabs/datacapture/README.md
index 41a8504378..e9a7f9e196 100644
--- a/codelabs/datacapture/README.md
+++ b/codelabs/datacapture/README.md
@@ -76,8 +76,8 @@ of the `app/build.gradle.kts` file of your project:
dependencies {
// ...
- implementation("com.google.android.fhir:data-capture:0.1.0-beta03")
- implementation("androidx.fragment:fragment-ktx:1.4.1")
+ implementation("com.google.android.fhir:data-capture:1.0.0")
+ implementation("androidx.fragment:fragment-ktx:1.5.5")
}
```
@@ -173,6 +173,13 @@ if (savedInstanceState == null) {
add(R.id.fragment_container_view, args = questionnaireParams)
}
}
+// Submit button callback
+supportFragmentManager.setFragmentResultListener(
+ QuestionnaireFragment.SUBMIT_REQUEST_KEY,
+ this,
+) { _, _ ->
+ submitQuestionnaire()
+}
```
Learn more about
@@ -244,12 +251,9 @@ questionnaire is already set up for
Find the `submitQuestionnaire()` method and add the following code:
```kotlin
-lifecycleScope.launch {
- val questionnaire =
- jsonParser.parseResource(questionnaireJsonString) as Questionnaire
- val bundle = ResourceMapper.extract(questionnaire, questionnaireResponse)
- Log.d("extraction result", jsonParser.encodeResourceToString(bundle))
-}
+val questionnaire = jsonParser.parseResource(questionnaireJsonString) as Questionnaire
+val bundle = ResourceMapper.extract(questionnaire, questionnaireResponse)
+Log.d("extraction result", jsonParser.encodeResourceToString(bundle))
```
`ResourceMapper.extract()` requires a HAPI FHIR Questionnaire, which you can
diff --git a/codelabs/datacapture/app/build.gradle.kts b/codelabs/datacapture/app/build.gradle.kts
index f252f95236..4c6cc3949c 100644
--- a/codelabs/datacapture/app/build.gradle.kts
+++ b/codelabs/datacapture/app/build.gradle.kts
@@ -38,15 +38,15 @@ android {
}
dependencies {
- coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.3")
- implementation("androidx.core:core-ktx:1.12.0")
- implementation("androidx.appcompat:appcompat:1.6.1")
- implementation("com.google.android.material:material:1.10.0")
+ coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.2")
+ implementation("androidx.core:core-ktx:1.13.1")
+ implementation("androidx.appcompat:appcompat:1.7.0")
+ implementation("com.google.android.material:material:1.12.0")
implementation("androidx.constraintlayout:constraintlayout:2.1.4")
testImplementation("junit:junit:4.13.2")
- androidTestImplementation("androidx.test.ext:junit:1.1.5")
- androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
+ androidTestImplementation("androidx.test.ext:junit:1.2.1")
+ androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1")
// 3 Add dependencies for Structured Data Capture Library and Fragment KTX
}
diff --git a/codelabs/datacapture/app/src/main/java/com/google/codelab/sdclibrary/MainActivity.kt b/codelabs/datacapture/app/src/main/java/com/google/codelab/sdclibrary/MainActivity.kt
index 74ecbe157b..a7f922c760 100644
--- a/codelabs/datacapture/app/src/main/java/com/google/codelab/sdclibrary/MainActivity.kt
+++ b/codelabs/datacapture/app/src/main/java/com/google/codelab/sdclibrary/MainActivity.kt
@@ -1,5 +1,5 @@
/*
- * Copyright 2022-2023 Google LLC
+ * Copyright 2022-2024 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,8 +17,6 @@
package com.google.codelab.sdclibrary
import android.os.Bundle
-import android.view.Menu
-import android.view.MenuItem
import androidx.appcompat.app.AppCompatActivity
class MainActivity : AppCompatActivity() {
@@ -32,24 +30,12 @@ class MainActivity : AppCompatActivity() {
// 4.2 Replace with code from the codelab to add a questionnaire fragment.
}
- private fun submitQuestionnaire() {
- // 5 Replace with code from the codelab to get a questionnaire response.
+ private fun submitQuestionnaire() =
+ lifecycleScope.launch {
+ // 5 Replace with code from the codelab to get a questionnaire response.
- // 6 Replace with code from the codelab to extract FHIR resources from QuestionnaireResponse.
- }
-
- override fun onCreateOptionsMenu(menu: Menu): Boolean {
- menuInflater.inflate(R.menu.submit_menu, menu)
- return super.onCreateOptionsMenu(menu)
- }
-
- override fun onOptionsItemSelected(item: MenuItem): Boolean {
- if (item.itemId == R.id.submit) {
- submitQuestionnaire()
- return true
+ // 6 Replace with code from the codelab to extract FHIR resources from QuestionnaireResponse.
}
- return super.onOptionsItemSelected(item)
- }
private fun getStringFromAssets(fileName: String): String {
return assets.open(fileName).bufferedReader().use { it.readText() }
diff --git a/codelabs/engine/README.md b/codelabs/engine/README.md
index 9b116e4cbb..67bfd6fabc 100644
--- a/codelabs/engine/README.md
+++ b/codelabs/engine/README.md
@@ -125,7 +125,7 @@ file of your project:
dependencies {
// ...
- implementation("com.google.android.fhir:engine:0.1.0-beta05")
+ implementation("com.google.android.fhir:engine:1.0.0")
}
```
@@ -256,6 +256,8 @@ outlined below will guide you through the process.
override fun getConflictResolver() = AcceptLocalConflictResolver
override fun getFhirEngine() = FhirApplication.fhirEngine(applicationContext)
+
+ override fun getUploadStrategy() = UploadStrategy.AllChangesSquashedBundlePut
}
```
@@ -282,7 +284,7 @@ outlined below will guide you through the process.
```kotlin
when (syncJobStatus) {
- is SyncJobStatus.Finished -> {
+ is CurrentSyncJobStatus.Succeeded -> {
Toast.makeText(requireContext(), "Sync Finished", Toast.LENGTH_SHORT).show()
viewModel.searchPatientsByName("")
}
@@ -434,20 +436,20 @@ the UI to update, incorporate the following conditional code block:
```kotlin
viewModelScope.launch {
- val fhirEngine = FhirApplication.fhirEngine(getApplication())
- if (nameQuery.isNotEmpty()) {
- val searchResult = fhirEngine.search {
- filter(
- Patient.NAME,
- {
- modifier = StringFilterModifier.CONTAINS
- value = nameQuery
- },
- )
+ val fhirEngine = FhirApplication.fhirEngine(getApplication())
+ val searchResult = fhirEngine.search {
+ if (nameQuery.isNotEmpty()) {
+ filter(
+ Patient.NAME,
+ {
+ modifier = StringFilterModifier.CONTAINS
+ value = nameQuery
+ },
+ )
+ }
+ }
+ liveSearchedPatients.value = searchResult.map { it.resource }
}
- liveSearchedPatients.value = searchResult.map { it.resource }
- }
-}
```
Here, if the `nameQuery` is not empty, the search function will filter the
diff --git a/codelabs/engine/app/build.gradle.kts b/codelabs/engine/app/build.gradle.kts
index 75c7ab8f62..23c48e875f 100644
--- a/codelabs/engine/app/build.gradle.kts
+++ b/codelabs/engine/app/build.gradle.kts
@@ -37,18 +37,18 @@ android {
}
dependencies {
- coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.3")
+ coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.2")
- implementation("androidx.core:core-ktx:1.12.0")
- implementation("androidx.appcompat:appcompat:1.6.1")
- implementation("com.google.android.material:material:1.10.0")
+ implementation("androidx.core:core-ktx:1.13.1")
+ implementation("androidx.appcompat:appcompat:1.7.0")
+ implementation("com.google.android.material:material:1.12.0")
implementation("androidx.constraintlayout:constraintlayout:2.1.4")
- implementation("androidx.work:work-runtime-ktx:2.8.1")
+ implementation("androidx.work:work-runtime-ktx:2.9.1")
testImplementation("junit:junit:4.13.2")
- androidTestImplementation("androidx.test.ext:junit:1.1.5")
- androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
+ androidTestImplementation("androidx.test.ext:junit:1.2.1")
+ androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1")
- implementation("com.google.android.fhir:engine:0.1.0-beta05")
- implementation("androidx.fragment:fragment-ktx:1.6.1")
+ implementation("com.google.android.fhir:engine:1.0.0")
+ implementation("androidx.fragment:fragment-ktx:1.8.3")
}
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
index 8c260fabf0..b00b3bdf89 100644
--- 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
@@ -1,5 +1,5 @@
/*
- * Copyright 2023 Google LLC
+ * Copyright 2023-2024 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -35,7 +35,7 @@ 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 com.google.android.fhir.sync.CurrentSyncJobStatus
import kotlinx.coroutines.launch
class PatientListFragment : Fragment() {
@@ -75,7 +75,7 @@ class PatientListFragment : Fragment() {
}
}
- private fun handleSyncJobStatus(syncJobStatus: SyncJobStatus) {
+ private fun handleSyncJobStatus(syncJobStatus: CurrentSyncJobStatus) {
// Add code to display Toast when sync job is complete
}
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
index b25123a148..3c9a099aa8 100644
--- 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
@@ -1,5 +1,5 @@
/*
- * Copyright 2023 Google LLC
+ * Copyright 2023-2024 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,16 +23,16 @@ 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 com.google.android.fhir.sync.CurrentSyncJobStatus
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()
+ private val _pollState = MutableSharedFlow()
- val pollState: Flow
+ val pollState: Flow
get() = _pollState
val liveSearchedPatients = MutableLiveData>()
diff --git a/contrib/barcode/src/main/java/com/google/android/fhir/datacapture/contrib/views/barcode/BarCodeReaderViewHolderFactory.kt b/contrib/barcode/src/main/java/com/google/android/fhir/datacapture/contrib/views/barcode/BarCodeReaderViewHolderFactory.kt
index a37b545489..96a678b080 100644
--- a/contrib/barcode/src/main/java/com/google/android/fhir/datacapture/contrib/views/barcode/BarCodeReaderViewHolderFactory.kt
+++ b/contrib/barcode/src/main/java/com/google/android/fhir/datacapture/contrib/views/barcode/BarCodeReaderViewHolderFactory.kt
@@ -22,7 +22,6 @@ import android.widget.TextView
import androidx.lifecycle.lifecycleScope
import com.google.android.fhir.datacapture.contrib.views.barcode.mlkit.md.LiveBarcodeScanningFragment
import com.google.android.fhir.datacapture.extensions.localizedPrefixSpanned
-import com.google.android.fhir.datacapture.extensions.localizedTextSpanned
import com.google.android.fhir.datacapture.extensions.tryUnwrapContext
import com.google.android.fhir.datacapture.views.QuestionnaireViewItem
import com.google.android.fhir.datacapture.views.factories.QuestionnaireItemViewHolderDelegate
@@ -95,7 +94,7 @@ object BarCodeReaderViewHolderFactory :
} else {
prefixTextView.visibility = View.GONE
}
- textQuestion.text = questionnaireViewItem.questionnaireItem.localizedTextSpanned
+ textQuestion.text = questionnaireViewItem.questionText
setInitial(questionnaireViewItem.answers.singleOrNull(), reScanView)
}
diff --git a/datacapture/build.gradle.kts b/datacapture/build.gradle.kts
index 8726fc504f..34d81ab04c 100644
--- a/datacapture/build.gradle.kts
+++ b/datacapture/build.gradle.kts
@@ -90,9 +90,9 @@ dependencies {
exclude(module = "commons-logging")
exclude(module = "httpclient")
}
- implementation(Dependencies.androidFhirCommon)
implementation(Dependencies.material)
implementation(Dependencies.timber)
+ implementation(libs.android.fhir.common)
implementation(libs.androidx.appcompat)
implementation(libs.androidx.constraintlayout)
implementation(libs.androidx.core)
@@ -106,7 +106,7 @@ dependencies {
testImplementation(Dependencies.mockitoKotlin)
testImplementation(Dependencies.robolectric)
testImplementation(project(":knowledge")) {
- exclude(group = Dependencies.androidFhirGroup, module = Dependencies.androidFhirEngineModule)
+ exclude(group = "com.google.android.fhir", module = "engine")
}
testImplementation(libs.androidx.test.core)
testImplementation(libs.androidx.fragment.testing)
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 310fe641c0..45e07cc557 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
@@ -112,11 +112,23 @@ class QuestionnaireFragment : Fragment() {
} else {
val errorViewModel: QuestionnaireValidationErrorViewModel by activityViewModels()
errorViewModel.setQuestionnaireAndValidation(viewModel.questionnaire, validationMap)
- QuestionnaireValidationErrorMessageDialogFragment()
- .show(
- requireActivity().supportFragmentManager,
- QuestionnaireValidationErrorMessageDialogFragment.TAG,
- )
+ val validationErrorMessageDialog = QuestionnaireValidationErrorMessageDialogFragment()
+ if (requireArguments().containsKey(EXTRA_SHOW_SUBMIT_ANYWAY_BUTTON)) {
+ validationErrorMessageDialog.arguments =
+ Bundle().apply {
+ putBoolean(
+ EXTRA_SHOW_SUBMIT_ANYWAY_BUTTON,
+ requireArguments()
+ .getBoolean(
+ EXTRA_SHOW_SUBMIT_ANYWAY_BUTTON,
+ ),
+ )
+ }
+ }
+ validationErrorMessageDialog.show(
+ requireActivity().supportFragmentManager,
+ QuestionnaireValidationErrorMessageDialogFragment.TAG,
+ )
}
}
}
@@ -161,10 +173,10 @@ class QuestionnaireFragment : Fragment() {
}
// Set bottom navigation
- if (state.bottomNavItems.isNotEmpty()) {
+ if (state.bottomNavItem != null) {
bottomNavContainerFrame.visibility = View.VISIBLE
NavigationViewHolder(bottomNavContainerFrame)
- .bind(state.bottomNavItems.single().questionnaireNavigationUIState)
+ .bind(state.bottomNavItem.questionnaireNavigationUIState)
} else {
bottomNavContainerFrame.visibility = View.GONE
}
@@ -180,10 +192,10 @@ class QuestionnaireFragment : Fragment() {
reviewModeEditButton.visibility = View.GONE
// Set bottom navigation
- if (state.bottomNavItems.isNotEmpty()) {
+ if (state.bottomNavItem != null) {
bottomNavContainerFrame.visibility = View.VISIBLE
NavigationViewHolder(bottomNavContainerFrame)
- .bind(state.bottomNavItems.single().questionnaireNavigationUIState)
+ .bind(state.bottomNavItem.questionnaireNavigationUIState)
} else {
bottomNavContainerFrame.visibility = View.GONE
}
@@ -408,6 +420,11 @@ class QuestionnaireFragment : Fragment() {
args.add(EXTRA_SHOW_NAVIGATION_IN_DEFAULT_LONG_SCROLL to value)
}
+ /** Setter to show/hide the Submit anyway button. This button is visible by default. */
+ fun setShowSubmitAnywayButton(value: Boolean) = apply {
+ args.add(EXTRA_SHOW_SUBMIT_ANYWAY_BUTTON to value)
+ }
+
@VisibleForTesting fun buildArgs() = bundleOf(*args.toTypedArray())
/** @return A [QuestionnaireFragment] with provided [Bundle] arguments. */
@@ -510,6 +527,12 @@ class QuestionnaireFragment : Fragment() {
internal const val EXTRA_SHOW_NAVIGATION_IN_DEFAULT_LONG_SCROLL =
"show-navigation-in-default-long-scroll"
+ /**
+ * A [Boolean] extra to show or hide the Submit anyway button in the questionnaire. Default is
+ * true.
+ */
+ internal const val EXTRA_SHOW_SUBMIT_ANYWAY_BUTTON = "show-submit-anyway-button"
+
fun builder() = Builder()
}
diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireNavigationViewUIState.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireNavigationViewUIState.kt
index bfb7624af8..6f5e3e9222 100644
--- a/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireNavigationViewUIState.kt
+++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireNavigationViewUIState.kt
@@ -16,11 +16,11 @@
package com.google.android.fhir.datacapture
-sealed class QuestionnaireNavigationViewUIState(val isShown: Boolean, val isEnabled: Boolean) {
- data object Hidden : QuestionnaireNavigationViewUIState(isShown = false, isEnabled = false)
+sealed interface QuestionnaireNavigationViewUIState {
+ data object Hidden : QuestionnaireNavigationViewUIState
data class Enabled(val labelText: String? = null, val onClickAction: () -> Unit = {}) :
- QuestionnaireNavigationViewUIState(isShown = true, isEnabled = true)
+ QuestionnaireNavigationViewUIState
}
data class QuestionnaireNavigationUIState(
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 2db2576875..f57b6949b7 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 2023 Google LLC
+ * Copyright 2023-2024 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,17 +51,23 @@ internal class QuestionnaireValidationErrorMessageDialogFragment(
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
isCancelable = false
- return MaterialAlertDialogBuilder(requireContext())
- .setView(onCreateCustomView())
- .setPositiveButton(R.string.questionnaire_validation_error_fix_button_text) { dialog, _ ->
+ val currentDialog =
+ MaterialAlertDialogBuilder(requireContext()).setView(onCreateCustomView()).setPositiveButton(
+ R.string.questionnaire_validation_error_fix_button_text,
+ ) { dialog, _ ->
setFragmentResult(RESULT_CALLBACK, bundleOf(RESULT_KEY to RESULT_VALUE_FIX))
dialog?.dismiss()
}
- .setNegativeButton(R.string.questionnaire_validation_error_submit_button_text) { dialog, _ ->
+ if (arguments == null || requireArguments().getBoolean(EXTRA_SHOW_SUBMIT_ANYWAY_BUTTON, true)) {
+ currentDialog.setNegativeButton(R.string.questionnaire_validation_error_submit_button_text) {
+ dialog,
+ _,
+ ->
setFragmentResult(RESULT_CALLBACK, bundleOf(RESULT_KEY to RESULT_VALUE_SUBMIT))
dialog?.dismiss()
}
- .create()
+ }
+ return currentDialog.create()
}
@VisibleForTesting
@@ -97,6 +103,12 @@ internal class QuestionnaireValidationErrorMessageDialogFragment(
const val RESULT_KEY = "result"
const val RESULT_VALUE_FIX = "result_fix"
const val RESULT_VALUE_SUBMIT = "result_submit"
+
+ /**
+ * A [Boolean] extra to show or hide the Submit anyway button in the questionnaire. Default is
+ * true.
+ */
+ internal const val EXTRA_SHOW_SUBMIT_ANYWAY_BUTTON = "show-submit-anyway-button"
}
}
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 889994520a..40267c3da0 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
@@ -444,10 +444,11 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat
* 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.
+ * might not contain answers to unanswered or disabled questions. This function should only be
+ * used for unpacked questionnaire.
*/
- private fun addMissingResponseItems(
+ @VisibleForTesting
+ internal fun addMissingResponseItems(
questionnaireItems: List,
responseItems: MutableList,
) {
@@ -471,6 +472,14 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat
responseItems = responseItemMap[it.linkId]!!.single().item,
)
}
+ if (it.type == Questionnaire.QuestionnaireItemType.GROUP && it.repeats) {
+ responseItemMap[it.linkId]!!.forEach { rItem ->
+ addMissingResponseItems(
+ questionnaireItems = it.item,
+ responseItems = rItem.item,
+ )
+ }
+ }
responseItems.addAll(responseItemMap[it.linkId]!!)
}
}
@@ -620,7 +629,7 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat
QuestionnaireState(
items = emptyList(),
displayMode = DisplayMode.InitMode,
- bottomNavItems = emptyList(),
+ bottomNavItem = null,
),
)
@@ -770,13 +779,12 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat
QuestionnaireNavigationViewUIState.Hidden
},
)
- val bottomNavigationItems =
- listOf(QuestionnaireAdapterItem.Navigation(bottomNavigationViewState))
+ val bottomNavigation = QuestionnaireAdapterItem.Navigation(bottomNavigationViewState)
return QuestionnaireState(
items =
if (shouldSetNavigationInLongScroll) {
- questionnaireItemViewItems + bottomNavigationItems
+ questionnaireItemViewItems + bottomNavigation
} else {
questionnaireItemViewItems
},
@@ -785,8 +793,7 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat
showEditButton = !isReadOnly,
showNavAsScroll = shouldSetNavigationInLongScroll,
),
- bottomNavItems =
- if (!shouldSetNavigationInLongScroll) bottomNavigationItems else emptyList(),
+ bottomNavItem = if (!shouldSetNavigationInLongScroll) bottomNavigation else null,
)
}
@@ -890,18 +897,17 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat
}
},
)
- val bottomNavigationItems =
- listOf(QuestionnaireAdapterItem.Navigation(bottomNavigationUiViewState))
+ val bottomNavigation = QuestionnaireAdapterItem.Navigation(bottomNavigationUiViewState)
return QuestionnaireState(
items =
if (shouldSetNavigationInLongScroll) {
- questionnaireItemViewItems + bottomNavigationItems
+ questionnaireItemViewItems + bottomNavigation
} else {
questionnaireItemViewItems
},
displayMode = DisplayMode.EditMode(questionnairePagination, shouldSetNavigationInLongScroll),
- bottomNavItems = if (!shouldSetNavigationInLongScroll) bottomNavigationItems else emptyList(),
+ bottomNavItem = if (!shouldSetNavigationInLongScroll) bottomNavigation else null,
)
}
@@ -1193,7 +1199,7 @@ typealias ItemToParentMap = MutableMap,
val displayMode: DisplayMode,
- val bottomNavItems: List,
+ val bottomNavItem: QuestionnaireAdapterItem.Navigation?,
)
internal sealed class DisplayMode {
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 022964c9db..fd4690ea26 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
@@ -104,3 +104,41 @@ fun appendAsteriskToQuestionText(
}
}
}
+
+internal fun applyCustomOrDefaultStyle(
+ questionnaireItem: Questionnaire.QuestionnaireItemComponent,
+ prefixTextView: TextView,
+ questionTextView: TextView,
+ instructionTextView: TextView,
+) {
+ applyCustomOrDefaultStyle(
+ context = prefixTextView.context,
+ view = prefixTextView,
+ customStyleName =
+ questionnaireItem.readCustomStyleExtension(
+ StyleUrl.PREFIX_TEXT_VIEW,
+ ),
+ defaultStyleResId =
+ getStyleResIdFromAttribute(questionTextView.context, R.attr.questionnaireQuestionTextStyle),
+ )
+ applyCustomOrDefaultStyle(
+ context = questionTextView.context,
+ view = questionTextView,
+ customStyleName =
+ questionnaireItem.readCustomStyleExtension(
+ StyleUrl.QUESTION_TEXT_VIEW,
+ ),
+ defaultStyleResId =
+ getStyleResIdFromAttribute(questionTextView.context, R.attr.questionnaireQuestionTextStyle),
+ )
+ applyCustomOrDefaultStyle(
+ context = instructionTextView.context,
+ view = instructionTextView,
+ customStyleName =
+ questionnaireItem.readCustomStyleExtension(
+ StyleUrl.SUBTITLE_TEXT_VIEW,
+ ),
+ defaultStyleResId =
+ getStyleResIdFromAttribute(questionTextView.context, R.attr.questionnaireSubtitleTextStyle),
+ )
+}
diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/extensions/MoreQuestionItemStyle.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/extensions/MoreQuestionItemStyle.kt
new file mode 100644
index 0000000000..3067d969bd
--- /dev/null
+++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/extensions/MoreQuestionItemStyle.kt
@@ -0,0 +1,267 @@
+/*
+ * Copyright 2024 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.extensions
+
+import android.content.Context
+import android.content.res.TypedArray
+import android.view.View
+import android.widget.TextView
+import androidx.core.content.ContextCompat
+import com.google.android.fhir.datacapture.R
+
+/**
+ * Applies either a custom style or a default style to the given view based on the provided custom
+ * style name and default style resource ID.
+ *
+ * If the custom style resource name is valid, it applies the custom style to the view. If the
+ * custom style resource name is not valid or not found, it falls back to applying the default style
+ * defined by the given style resource ID. It sets the view's tag to resourceId to indicate that the
+ * custom style has been applied.
+ *
+ * @param context the context used to access resources.
+ * @param view the view to which the style should be applied.
+ * @param customStyleName the name of the custom style to apply.
+ * @param defaultStyleResId the default style resource ID to use if no custom style is found.
+ */
+internal fun applyCustomOrDefaultStyle(
+ context: Context,
+ view: View,
+ customStyleName: String?,
+ defaultStyleResId: Int,
+) {
+ val customStyleResId = customStyleName?.let { getStyleResIdByName(context, it) } ?: 0
+ when {
+ customStyleResId != 0 -> {
+ view.tag = customStyleResId
+ QuestionItemCustomStyle().applyStyle(context, view, customStyleResId)
+ }
+ defaultStyleResId != 0 -> {
+ applyDefaultStyleIfNotApplied(context, view, defaultStyleResId)
+ }
+ }
+}
+
+/**
+ * Applies the default style to the given view if the default style has not already been applied.
+ *
+ * This function checks the `view`'s tag to determine if a style has been previously applied. If the
+ * tag is an integer, it will apply the default style specified by `defaultStyleResId`. After
+ * applying the style, it resets the view's tag to `null` to indicate that the default style has
+ * been applied.
+ *
+ * @param context The context used to access resources and themes.
+ * @param view The view to which the default style will be applied.
+ * @param defaultStyleResId The resource ID of the default style to apply.
+ */
+private fun applyDefaultStyleIfNotApplied(
+ context: Context,
+ view: View,
+ defaultStyleResId: Int,
+) {
+ (view.tag as? Int)?.let {
+ QuestionItemDefaultStyle().applyStyle(context, view, defaultStyleResId)
+ view.tag = null
+ }
+}
+
+/**
+ * Retrieves the resource ID of a style given its name.
+ *
+ * This function uses the `getIdentifier` method to look up the style resource ID based on the style
+ * name provided. If the style name is not found, it returns 0.
+ *
+ * @param context The context used to access resources.
+ * @param styleName The name of the style whose resource ID is to be retrieved.
+ * @return The resource ID of the style, or 0 if the style name is not found.
+ */
+private fun getStyleResIdByName(context: Context, styleName: String): Int {
+ return context.resources.getIdentifier(styleName, "style", context.packageName)
+}
+
+/**
+ * Retrieves the style resource ID associated with a specific attribute from the current theme.
+ *
+ * This function obtains the style resource ID that is linked to a given attribute in the current
+ * theme. It uses the `obtainStyledAttributes` method to fetch the attributes and extract the
+ * resource ID.
+ *
+ * @param context The context to access the current theme and resources.
+ * @param attr The attribute whose associated style resource ID is to be retrieved.
+ * @return The resource ID of the style associated with the specified attribute, or 0 if not found.
+ */
+internal fun getStyleResIdFromAttribute(context: Context, attr: Int): Int {
+ val typedArray = context.theme.obtainStyledAttributes(intArrayOf(attr))
+ val styleResId = typedArray.getResourceId(0, 0)
+ typedArray.recycle()
+ return styleResId
+}
+
+internal abstract class QuestionItemStyle {
+
+ /**
+ * Applies a style to a view.
+ *
+ * @param context The context used to apply the style.
+ * @param view The view to which the style will be applied.
+ * @param styleResId The resource ID of the style to apply.
+ */
+ abstract fun applyStyle(context: Context, view: View, styleResId: Int)
+
+ /**
+ * Applies the style from a TypedArray to a view.
+ *
+ * @param context The context used to apply the style.
+ * @param view The view to which the style will be applied.
+ * @param typedArray The TypedArray containing the style attributes.
+ */
+ internal fun applyStyle(context: Context, view: View, typedArray: TypedArray) {
+ applyGenericViewStyle(context, view, typedArray)
+ if (view is TextView) {
+ applyTextViewSpecificStyle(view, typedArray)
+ }
+ typedArray.recycle()
+ }
+
+ /**
+ * Abstract function to apply generic view styles from a TypedArray.
+ *
+ * @param context The context used to apply the style.
+ * @param view The view to which the style will be applied.
+ * @param typedArray The TypedArray containing the style attributes.
+ */
+ abstract fun applyGenericViewStyle(context: Context, view: View, typedArray: TypedArray)
+
+ /**
+ * Abstract function to apply TextView-specific styles from a TypedArray.
+ *
+ * @param textView The TextView to which the style will be applied.
+ * @param typedArray The TypedArray containing the style attributes.
+ */
+ abstract fun applyTextViewSpecificStyle(textView: TextView, typedArray: TypedArray)
+
+ /**
+ * Applies the background color from a TypedArray to a view.
+ *
+ * @param context The context used to apply the background color.
+ * @param view The view to which the background color will be applied.
+ * @param typedArray The TypedArray containing the background color attribute.
+ * @param index The index of the background color attribute in the TypedArray.
+ */
+ protected fun applyBackgroundColor(
+ context: Context,
+ view: View,
+ typedArray: TypedArray,
+ index: Int,
+ ) {
+ val backgroundColor =
+ typedArray.getColor(index, ContextCompat.getColor(context, android.R.color.transparent))
+ view.setBackgroundColor(backgroundColor)
+ }
+
+ /**
+ * Applies the text appearance from a TypedArray to a TextView.
+ *
+ * @param textView The TextView to which the text appearance will be applied.
+ * @param typedArray The TypedArray containing the text appearance attribute.
+ * @param index The index of the text appearance attribute in the TypedArray.
+ */
+ protected fun applyTextAppearance(textView: TextView, typedArray: TypedArray, index: Int) {
+ val textAppearance = typedArray.getResourceId(index, -1)
+ if (textAppearance != -1) {
+ textView.setTextAppearance(textAppearance)
+ }
+ }
+}
+
+internal class QuestionItemCustomStyle : QuestionItemStyle() {
+ private enum class CustomStyleViewAttributes(val attrId: Int) {
+ TEXT_APPEARANCE(R.styleable.QuestionnaireCustomStyle_questionnaire_textAppearance),
+ BACKGROUND(R.styleable.QuestionnaireCustomStyle_questionnaire_background),
+ }
+
+ override fun applyStyle(context: Context, view: View, styleResId: Int) {
+ val typedArray =
+ context.obtainStyledAttributes(styleResId, R.styleable.QuestionnaireCustomStyle)
+ applyStyle(context, view, typedArray)
+ }
+
+ override fun applyGenericViewStyle(context: Context, view: View, typedArray: TypedArray) {
+ for (i in 0 until typedArray.indexCount) {
+ when (typedArray.getIndex(i)) {
+ CustomStyleViewAttributes.BACKGROUND.attrId -> {
+ applyBackgroundColor(context, view, typedArray, i)
+ }
+ }
+ }
+ }
+
+ override fun applyTextViewSpecificStyle(
+ textView: TextView,
+ typedArray: TypedArray,
+ ) {
+ for (i in 0 until typedArray.indexCount) {
+ when (typedArray.getIndex(i)) {
+ CustomStyleViewAttributes.TEXT_APPEARANCE.attrId -> {
+ applyTextAppearance(textView, typedArray, i)
+ }
+ }
+ }
+ }
+}
+
+internal class QuestionItemDefaultStyle : QuestionItemStyle() {
+ private enum class DefaultStyleViewAttributes(val attrId: Int) {
+ TEXT_APPEARANCE(android.R.attr.textAppearance),
+ BACKGROUND(android.R.attr.background),
+ // Add other attributes you want to apply
+ }
+
+ override fun applyStyle(context: Context, view: View, styleResId: Int) {
+ val attrs = DefaultStyleViewAttributes.values().map { it.attrId }.toIntArray()
+ val typedArray: TypedArray = context.obtainStyledAttributes(styleResId, attrs)
+ applyStyle(context, view, typedArray)
+ }
+
+ override fun applyGenericViewStyle(context: Context, view: View, typedArray: TypedArray) {
+ for (i in 0 until typedArray.indexCount) {
+ when (DefaultStyleViewAttributes.values()[i]) {
+ DefaultStyleViewAttributes.BACKGROUND -> {
+ applyBackgroundColor(context, view, typedArray, i)
+ }
+ else -> {
+ // Ignore view specific attributes.
+ }
+ }
+ }
+ }
+
+ override fun applyTextViewSpecificStyle(
+ textView: TextView,
+ typedArray: TypedArray,
+ ) {
+ for (i in 0 until typedArray.indexCount) {
+ when (DefaultStyleViewAttributes.values()[i]) {
+ DefaultStyleViewAttributes.TEXT_APPEARANCE -> {
+ applyTextAppearance(textView, typedArray, i)
+ }
+ else -> {
+ // applyGenericViewDefaultStyle for other attributes.
+ }
+ }
+ }
+ }
+}
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 6b663cf1de..800660db9f 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
@@ -61,6 +61,13 @@ internal const val EXTENSION_ITEM_CONTROL_URL_ANDROID_FHIR =
internal const val EXTENSION_ITEM_CONTROL_SYSTEM_ANDROID_FHIR =
"https://github.com/google/android-fhir/questionnaire-item-control"
+internal enum class StyleUrl(val url: String) {
+ BASE("https://github.com/google/android-fhir/tree/master/datacapture/android-style"),
+ PREFIX_TEXT_VIEW("prefix_text_view"),
+ QUESTION_TEXT_VIEW("question_text_view"),
+ SUBTITLE_TEXT_VIEW("subtitle_text_view"),
+}
+
// Below URLs exist and are supported by HL7
internal const val EXTENSION_ANSWER_EXPRESSION_URL: String =
@@ -1041,3 +1048,17 @@ val Resource.logicalId: String
get() {
return this.idElement?.idPart.orEmpty()
}
+
+internal fun QuestionnaireItemComponent.readCustomStyleExtension(styleUrl: StyleUrl): String? {
+ // Find the base extension
+ val baseExtension = extension.find { it.url == StyleUrl.BASE.url }
+ baseExtension?.let { ext ->
+ // Extract nested extension based on the given StyleUrl
+ ext.extension.forEach { nestedExt ->
+ if (nestedExt.url == styleUrl.url) {
+ return nestedExt.value.asStringValue()
+ }
+ }
+ }
+ return null
+}
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 4fff12a122..da85cbac4e 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
@@ -23,6 +23,7 @@ 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.applyCustomOrDefaultStyle
import com.google.android.fhir.datacapture.extensions.getHeaderViewVisibility
import com.google.android.fhir.datacapture.extensions.getLocalizedInstructionsSpanned
import com.google.android.fhir.datacapture.extensions.initHelpViews
@@ -60,5 +61,11 @@ class GroupHeaderView(context: Context, attrs: AttributeSet?) : LinearLayout(con
questionnaireViewItem.enabledDisplayItems.getLocalizedInstructionsSpanned(),
)
visibility = getHeaderViewVisibility(prefix, question, hint)
+ applyCustomOrDefaultStyle(
+ questionnaireViewItem.questionnaireItem,
+ prefixTextView = prefix,
+ questionTextView = question,
+ instructionTextView = 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 c48e546f38..7e5e77231d 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
@@ -23,6 +23,7 @@ import android.widget.LinearLayout
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.applyCustomOrDefaultStyle
import com.google.android.fhir.datacapture.extensions.getHeaderViewVisibility
import com.google.android.fhir.datacapture.extensions.getLocalizedInstructionsSpanned
import com.google.android.fhir.datacapture.extensions.initHelpViews
@@ -64,6 +65,12 @@ class HeaderView(context: Context, attrs: AttributeSet?) : LinearLayout(context,
// Make the entire view GONE if there is nothing to show. This is to avoid an empty row in the
// questionnaire.
visibility = getHeaderViewVisibility(prefix, question, hint)
+ applyCustomOrDefaultStyle(
+ questionnaireViewItem.questionnaireItem,
+ prefixTextView = prefix,
+ questionTextView = question,
+ instructionTextView = hint,
+ )
}
/**
diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/OptionSelectDialogFragment.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/OptionSelectDialogFragment.kt
index 15ad260a81..4fb0238ca0 100644
--- a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/OptionSelectDialogFragment.kt
+++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/OptionSelectDialogFragment.kt
@@ -107,12 +107,14 @@ internal class OptionSelectDialogFragment(
WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM,
)
// Adjust the dialog after the keyboard is on so that OK-CANCEL buttons are visible.
- // SOFT_INPUT_ADJUST_RESIZE is deprecated and the suggested alternative
- // setDecorFitsSystemWindows is available api level 30 and above.
+ // Ideally SOFT_INPUT_ADJUST_RESIZE supposed to be used, but in some devices the
+ // keyboard immediately hide itself after being opened, that's why SOFT_INPUT_ADJUST_PAN
+ // is used instead. There's no issue with setDecorFitsSystemWindows and is only
+ // available for api level 30 and above.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
it.setDecorFitsSystemWindows(false)
} else {
- it.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE)
+ it.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_PAN)
}
}
}
@@ -136,7 +138,13 @@ internal class OptionSelectDialogFragment(
SelectedOptions(
options = currentList.filterIsInstance().map { it.option },
otherOptions =
- currentList.filterIsInstance().map { it.currentText },
+ currentList
+ .filterIsInstance()
+ .filter {
+ it.currentText.isNotEmpty()
+ } // Filters out empty answers when the user inputs nothing into a new option choice
+ // edit text field.
+ .map { it.currentText },
),
)
}
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 123696c46f..85769b3679 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
@@ -32,7 +32,6 @@ import com.google.android.fhir.datacapture.extensions.getRequiredOrOptionalText
import com.google.android.fhir.datacapture.extensions.getValidationErrorMessage
import com.google.android.fhir.datacapture.extensions.itemControl
import com.google.android.fhir.datacapture.extensions.localizedFlyoverSpanned
-import com.google.android.fhir.datacapture.extensions.localizedTextSpanned
import com.google.android.fhir.datacapture.extensions.tryUnwrapContext
import com.google.android.fhir.datacapture.validation.ValidationResult
import com.google.android.fhir.datacapture.views.HeaderView
@@ -92,7 +91,10 @@ internal object QuestionnaireItemDialogSelectViewHolderFactory :
View.OnClickListener {
val fragment =
OptionSelectDialogFragment(
- title = questionnaireItem.localizedTextSpanned ?: "",
+ // We use the question text for the dialog title. If there is no question text, we
+ // use flyover text as it is sometimes used in text fields instead of question text.
+ title = questionnaireViewItem.questionText
+ ?: questionnaireItem.localizedFlyoverSpanned ?: "",
config = questionnaireItem.buildConfig(),
selectedOptions = selectedOptions,
)
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 32a2de35c3..a6621b2aba 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-2023 Google LLC
+ * Copyright 2022-2024 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,6 @@ 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.localizedPrefixSpanned
-import com.google.android.fhir.datacapture.extensions.localizedTextSpanned
import com.google.android.fhir.datacapture.extensions.updateTextAndVisibility
import com.google.android.fhir.datacapture.validation.Invalid
import com.google.android.fhir.datacapture.views.QuestionnaireViewItem
@@ -66,7 +65,7 @@ internal object ReviewViewHolderFactory : QuestionnaireItemViewHolderFactory(R.l
questionnaireViewItem.questionnaireItem.localizedPrefixSpanned,
)
question.updateTextAndVisibility(
- questionnaireViewItem.questionnaireItem.localizedTextSpanned,
+ questionnaireViewItem.questionText,
)
hint.updateTextAndVisibility(
questionnaireViewItem.enabledDisplayItems.getLocalizedInstructionsSpanned(),
diff --git a/datacapture/src/main/res/values/attrs.xml b/datacapture/src/main/res/values/attrs.xml
index 0b6cd1efae..340f81a367 100644
--- a/datacapture/src/main/res/values/attrs.xml
+++ b/datacapture/src/main/res/values/attrs.xml
@@ -194,4 +194,24 @@
extend Theme.Questionnaire. If unspecified, Theme.Questionnaire will be used. -->
+
+
+
+
+
+
+
+
+
diff --git a/datacapture/src/test/java/com/google/android/fhir/datacapture/QuestionnaireValidationErrorMessageDialogFragmentTest.kt b/datacapture/src/test/java/com/google/android/fhir/datacapture/QuestionnaireValidationErrorMessageDialogFragmentTest.kt
index 37f75add4b..b8b4e98451 100644
--- a/datacapture/src/test/java/com/google/android/fhir/datacapture/QuestionnaireValidationErrorMessageDialogFragmentTest.kt
+++ b/datacapture/src/test/java/com/google/android/fhir/datacapture/QuestionnaireValidationErrorMessageDialogFragmentTest.kt
@@ -16,15 +16,20 @@
package com.google.android.fhir.datacapture
+import android.os.Bundle
import android.widget.TextView
+import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.FragmentFactory
+import androidx.fragment.app.testing.launchFragment
import androidx.fragment.app.testing.launchFragmentInContainer
import androidx.fragment.app.testing.withFragment
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
+import androidx.test.platform.app.InstrumentationRegistry
import com.google.android.fhir.datacapture.validation.QuestionnaireResponseValidator
import com.google.common.truth.Truth.assertThat
+import kotlin.test.assertEquals
import kotlinx.coroutines.test.runTest
import org.hl7.fhir.r4.model.Questionnaire
import org.hl7.fhir.r4.model.QuestionnaireResponse
@@ -75,6 +80,93 @@ class QuestionnaireValidationErrorMessageDialogFragmentTest {
}
}
+ @Test
+ fun `check alertDialog when submit anyway button argument is true should show Submit anyway button`() {
+ runTest {
+ val questionnaireValidationErrorMessageDialogArguments = Bundle()
+ questionnaireValidationErrorMessageDialogArguments.putBoolean(
+ QuestionnaireValidationErrorMessageDialogFragment.EXTRA_SHOW_SUBMIT_ANYWAY_BUTTON,
+ true,
+ )
+ with(
+ launchFragment(
+ themeResId = R.style.Theme_Questionnaire,
+ fragmentArgs = questionnaireValidationErrorMessageDialogArguments,
+ ),
+ ) {
+ onFragment { fragment ->
+ assertThat(fragment.dialog).isNotNull()
+ assertThat(fragment.requireDialog().isShowing).isTrue()
+ val alertDialog = fragment.dialog as? AlertDialog
+ val context = InstrumentationRegistry.getInstrumentation().targetContext
+ val positiveButtonText =
+ context.getString(R.string.questionnaire_validation_error_fix_button_text)
+ val negativeButtonText =
+ context.getString(R.string.questionnaire_validation_error_submit_button_text)
+ assertThat(alertDialog?.getButton(AlertDialog.BUTTON_POSITIVE)?.text)
+ .isEqualTo(positiveButtonText)
+ assertThat(alertDialog?.getButton(AlertDialog.BUTTON_NEGATIVE)?.text)
+ .isEqualTo(negativeButtonText)
+ }
+ }
+ }
+ }
+
+ @Test
+ fun `check alertDialog when no arguments are passed should show Submit anyway button`() {
+ runTest {
+ with(
+ launchFragment(
+ themeResId = R.style.Theme_Questionnaire,
+ ),
+ ) {
+ onFragment { fragment ->
+ assertThat(fragment.dialog).isNotNull()
+ assertThat(fragment.requireDialog().isShowing).isTrue()
+ val alertDialog = fragment.dialog as? AlertDialog
+ val context = InstrumentationRegistry.getInstrumentation().targetContext
+ val positiveButtonText =
+ context.getString(R.string.questionnaire_validation_error_fix_button_text)
+ val negativeButtonText =
+ context.getString(R.string.questionnaire_validation_error_submit_button_text)
+ assertThat(alertDialog?.getButton(AlertDialog.BUTTON_POSITIVE)?.text)
+ .isEqualTo(positiveButtonText)
+ assertThat(alertDialog?.getButton(AlertDialog.BUTTON_NEGATIVE)?.text)
+ .isEqualTo(negativeButtonText)
+ }
+ }
+ }
+ }
+
+ @Test
+ fun `check alertDialog when submit anyway button argument is false should hide Submit anyway button`() {
+ runTest {
+ val validationErrorBundle = Bundle()
+ validationErrorBundle.putBoolean(
+ QuestionnaireValidationErrorMessageDialogFragment.EXTRA_SHOW_SUBMIT_ANYWAY_BUTTON,
+ false,
+ )
+ with(
+ launchFragment(
+ themeResId = R.style.Theme_Questionnaire,
+ fragmentArgs = validationErrorBundle,
+ ),
+ ) {
+ onFragment { fragment ->
+ assertThat(fragment.dialog).isNotNull()
+ assertThat(fragment.requireDialog().isShowing).isTrue()
+ val alertDialog = fragment.dialog as? AlertDialog
+ val context = InstrumentationRegistry.getInstrumentation().targetContext
+ val positiveButtonText =
+ context.getString(R.string.questionnaire_validation_error_fix_button_text)
+ assertThat(alertDialog?.getButton(AlertDialog.BUTTON_POSITIVE)?.text)
+ .isEqualTo(positiveButtonText)
+ assertEquals(alertDialog?.getButton(AlertDialog.BUTTON_NEGATIVE)?.text, "")
+ }
+ }
+ }
+ }
+
private suspend fun createTestValidationErrorViewModel(
questionnaire: Questionnaire,
questionnaireResponse: QuestionnaireResponse,
diff --git a/datacapture/src/test/java/com/google/android/fhir/datacapture/QuestionnaireViewModelTest.kt b/datacapture/src/test/java/com/google/android/fhir/datacapture/QuestionnaireViewModelTest.kt
index f4de30897b..e644e4c6d6 100644
--- a/datacapture/src/test/java/com/google/android/fhir/datacapture/QuestionnaireViewModelTest.kt
+++ b/datacapture/src/test/java/com/google/android/fhir/datacapture/QuestionnaireViewModelTest.kt
@@ -997,8 +997,8 @@ class QuestionnaireViewModelTest {
"""
.trimIndent()
- state.set(EXTRA_QUESTIONNAIRE_JSON_STRING, questionnaireString)
- state.set(EXTRA_QUESTIONNAIRE_RESPONSE_JSON_STRING, questionnaireResponseString)
+ state[EXTRA_QUESTIONNAIRE_JSON_STRING] = questionnaireString
+ state[EXTRA_QUESTIONNAIRE_RESPONSE_JSON_STRING] = questionnaireResponseString
val viewModel = QuestionnaireViewModel(context, state)
runTest {
val value = viewModel.getQuestionnaireResponse()
@@ -1125,8 +1125,8 @@ class QuestionnaireViewModelTest {
"""
.trimIndent()
- state.set(EXTRA_QUESTIONNAIRE_JSON_STRING, questionnaireString)
- state.set(EXTRA_QUESTIONNAIRE_RESPONSE_JSON_STRING, questionnaireResponseString)
+ state[EXTRA_QUESTIONNAIRE_JSON_STRING] = questionnaireString
+ state[EXTRA_QUESTIONNAIRE_RESPONSE_JSON_STRING] = questionnaireResponseString
val viewModel = QuestionnaireViewModel(context, state)
runTest {
val value = viewModel.getQuestionnaireResponse()
@@ -1138,6 +1138,141 @@ class QuestionnaireViewModelTest {
}
}
+ @Test
+ fun `should add missing response item inside a repeated group`() {
+ val questionnaireString =
+ """
+ {
+ "resourceType": "Questionnaire",
+ "item": [
+ {
+ "linkId": "1",
+ "type": "group",
+ "text": "Repeated Group",
+ "repeats": true,
+ "item": [
+ {
+ "linkId": "1-1",
+ "type": "date",
+ "extension": [
+ {
+ "url": "http://hl7.org/fhir/StructureDefinition/entryFormat",
+ "valueString": "yyyy-mm-dd"
+ }
+ ]
+ },
+ {
+ "linkId": "1-2",
+ "type": "boolean"
+ }
+ ]
+ }
+ ]
+ }
+ """
+ .trimIndent()
+
+ val questionnaireResponseString =
+ """
+ {
+ "resourceType": "QuestionnaireResponse",
+ "item": [
+ {
+ "linkId": "1",
+ "text": "Repeated Group",
+ "item": [
+ {
+ "linkId": "1-1",
+ "answer": [
+ {
+ "valueDate": "2023-06-14"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "linkId": "1",
+ "text": "Repeated Group",
+ "item": [
+ {
+ "linkId": "1-1",
+ "answer": [
+ {
+ "valueDate": "2023-06-13"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ """
+ .trimIndent()
+
+ val expectedQuestionnaireResponseString =
+ """
+ {
+ "resourceType": "QuestionnaireResponse",
+ "item": [
+ {
+ "linkId": "1",
+ "text": "Repeated Group",
+ "item": [
+ {
+ "linkId": "1-1",
+ "answer": [
+ {
+ "valueDate": "2023-06-14"
+ }
+ ]
+ },
+ {
+ "linkId": "1-2"
+ }
+ ]
+ },
+ {
+ "linkId": "1",
+ "text": "Repeated Group",
+ "item": [
+ {
+ "linkId": "1-1",
+ "answer": [
+ {
+ "valueDate": "2023-06-13"
+ }
+ ]
+ },
+ {
+ "linkId": "1-2"
+ }
+ ]
+ }
+ ]
+ }
+ """
+ .trimIndent()
+
+ val questionnaire =
+ printer.parseResource(Questionnaire::class.java, questionnaireString) as Questionnaire
+
+ val response =
+ printer.parseResource(QuestionnaireResponse::class.java, questionnaireResponseString)
+ as QuestionnaireResponse
+
+ val expectedResponse =
+ printer.parseResource(QuestionnaireResponse::class.java, expectedQuestionnaireResponseString)
+ as QuestionnaireResponse
+
+ val viewModel = createQuestionnaireViewModel(questionnaire, response)
+
+ runTest {
+ viewModel.addMissingResponseItems(questionnaire.item, response.item)
+ assertResourceEquals(response, expectedResponse)
+ }
+ }
+
// ==================================================================== //
// //
// Questionnaire State Flow //
@@ -1321,7 +1456,7 @@ class QuestionnaireViewModelTest {
}
val serializedQuestionnaire = printer.encodeResourceToString(questionnaire)
- state.set(EXTRA_QUESTIONNAIRE_JSON_STRING, serializedQuestionnaire)
+ state[EXTRA_QUESTIONNAIRE_JSON_STRING] = serializedQuestionnaire
val viewModel = QuestionnaireViewModel(context, state)
@@ -1346,7 +1481,7 @@ class QuestionnaireViewModelTest {
)
}
val serializedQuestionnaire = printer.encodeResourceToString(questionnaire)
- state.set(EXTRA_QUESTIONNAIRE_JSON_STRING, serializedQuestionnaire)
+ state[EXTRA_QUESTIONNAIRE_JSON_STRING] = serializedQuestionnaire
val viewModel = QuestionnaireViewModel(context, state)
viewModel.runViewModelBlocking {
@@ -1987,11 +2122,8 @@ class QuestionnaireViewModelTest {
)
assertThat(
- questionnaireState.bottomNavItems
- .single()
- .questionnaireNavigationUIState
- .navReview
- .isShown,
+ questionnaireState.bottomNavItem!!.questionnaireNavigationUIState.navReview
+ is QuestionnaireNavigationViewUIState.Enabled,
)
.isTrue()
}
@@ -2240,11 +2372,8 @@ class QuestionnaireViewModelTest {
),
)
assertThat(
- questionnaireState.bottomNavItems
- .single()
- .questionnaireNavigationUIState
- .navSubmit
- .isShown,
+ questionnaireState.bottomNavItem!!.questionnaireNavigationUIState.navSubmit
+ is QuestionnaireNavigationViewUIState.Enabled,
)
.isTrue()
}
@@ -2360,11 +2489,8 @@ class QuestionnaireViewModelTest {
),
)
assertThat(
- questionnaireState1.bottomNavItems
- .single()
- .questionnaireNavigationUIState
- .navSubmit
- .isShown,
+ questionnaireState1.bottomNavItem!!.questionnaireNavigationUIState.navSubmit
+ is QuestionnaireNavigationViewUIState.Enabled,
)
.isTrue()
@@ -2382,11 +2508,8 @@ class QuestionnaireViewModelTest {
),
)
assertThat(
- questionnaireState2.bottomNavItems
- .single()
- .questionnaireNavigationUIState
- .navSubmit
- .isShown,
+ questionnaireState2.bottomNavItem!!.questionnaireNavigationUIState.navSubmit
+ is QuestionnaireNavigationViewUIState.Enabled,
)
.isTrue()
}
@@ -2414,13 +2537,10 @@ class QuestionnaireViewModelTest {
val viewModel = createQuestionnaireViewModel(questionnaire, enableReviewPage = false)
val questionnaireState = viewModel.questionnaireStateFlow.first()
assertThat(
- questionnaireState.bottomNavItems
- .single()
- .questionnaireNavigationUIState
- .navReview
- .isShown,
+ questionnaireState.bottomNavItem!!.questionnaireNavigationUIState.navReview
+ is QuestionnaireNavigationViewUIState.Hidden,
)
- .isFalse()
+ .isTrue()
}
}
@@ -2445,13 +2565,10 @@ class QuestionnaireViewModelTest {
)
val questionnaireState = viewModel.questionnaireStateFlow.first()
assertThat(
- questionnaireState.bottomNavItems
- .single()
- .questionnaireNavigationUIState
- .navReview
- .isShown,
+ questionnaireState.bottomNavItem!!.questionnaireNavigationUIState.navReview
+ is QuestionnaireNavigationViewUIState.Hidden,
)
- .isFalse()
+ .isTrue()
}
}
@@ -2471,11 +2588,8 @@ class QuestionnaireViewModelTest {
val viewModel = createQuestionnaireViewModel(questionnaire, enableReviewPage = true)
val questionnaireState = viewModel.questionnaireStateFlow.first()
assertThat(
- questionnaireState.bottomNavItems
- .single()
- .questionnaireNavigationUIState
- .navReview
- .isShown,
+ questionnaireState.bottomNavItem!!.questionnaireNavigationUIState.navReview
+ is QuestionnaireNavigationViewUIState.Enabled,
)
.isTrue()
}
@@ -2580,13 +2694,11 @@ class QuestionnaireViewModelTest {
viewModel.runViewModelBlocking {
viewModel.goToNextPage()
assertThat(
- viewModel.questionnaireStateFlow.value.bottomNavItems
- .single()
+ viewModel.questionnaireStateFlow.value.bottomNavItem!!
.questionnaireNavigationUIState
- .navReview
- .isShown,
+ .navReview is QuestionnaireNavigationViewUIState.Hidden,
)
- .isFalse()
+ .isTrue()
}
}
@@ -2629,13 +2741,11 @@ class QuestionnaireViewModelTest {
val viewModel = createQuestionnaireViewModel(questionnaire, enableReviewPage = false)
viewModel.runViewModelBlocking {
assertThat(
- viewModel.questionnaireStateFlow.value.bottomNavItems
- .single()
+ viewModel.questionnaireStateFlow.value.bottomNavItem!!
.questionnaireNavigationUIState
- .navReview
- .isShown,
+ .navReview is QuestionnaireNavigationViewUIState.Hidden,
)
- .isFalse()
+ .isTrue()
}
}
@@ -2678,11 +2788,9 @@ class QuestionnaireViewModelTest {
viewModel.runViewModelBlocking {
viewModel.goToNextPage()
assertThat(
- viewModel.questionnaireStateFlow.value.bottomNavItems
- .single()
+ viewModel.questionnaireStateFlow.value.bottomNavItem!!
.questionnaireNavigationUIState
- .navReview
- .isShown,
+ .navReview is QuestionnaireNavigationViewUIState.Enabled,
)
.isTrue()
}
@@ -2727,11 +2835,9 @@ class QuestionnaireViewModelTest {
val viewModel = createQuestionnaireViewModel(questionnaire, enableReviewPage = true)
viewModel.runViewModelBlocking {
assertThat(
- viewModel.questionnaireStateFlow.value.bottomNavItems
- .single()
+ viewModel.questionnaireStateFlow.value.bottomNavItem!!
.questionnaireNavigationUIState
- .navReview
- .isShown,
+ .navReview is QuestionnaireNavigationViewUIState.Enabled,
)
.isTrue()
}
@@ -2755,11 +2861,9 @@ class QuestionnaireViewModelTest {
assertThat(
viewModel.questionnaireStateFlow
.first()
- .bottomNavItems
- .single()
+ .bottomNavItem!!
.questionnaireNavigationUIState
- .navReview
- .isShown,
+ .navReview is QuestionnaireNavigationViewUIState.Enabled,
)
.isTrue()
}
@@ -2795,7 +2899,7 @@ class QuestionnaireViewModelTest {
Questionnaire().apply {
id = "a-questionnaire"
addItem(
- Questionnaire.QuestionnaireItemComponent().apply {
+ QuestionnaireItemComponent().apply {
linkId = "a-linkId"
type = Questionnaire.QuestionnaireItemType.BOOLEAN
},
@@ -2849,7 +2953,7 @@ class QuestionnaireViewModelTest {
Questionnaire().apply {
id = "a-questionnaire"
addItem(
- Questionnaire.QuestionnaireItemComponent().apply {
+ QuestionnaireItemComponent().apply {
linkId = "a-linkId"
type = Questionnaire.QuestionnaireItemType.BOOLEAN
},
@@ -2889,13 +2993,11 @@ class QuestionnaireViewModelTest {
assertThat(
viewModel.questionnaireStateFlow
.first()
- .bottomNavItems
- .single()
+ .bottomNavItem!!
.questionnaireNavigationUIState
- .navSubmit
- .isShown,
+ .navSubmit is QuestionnaireNavigationViewUIState.Hidden,
)
- .isFalse()
+ .isTrue()
}
@Test
@@ -2914,11 +3016,9 @@ class QuestionnaireViewModelTest {
assertThat(
viewModel.questionnaireStateFlow
.first()
- .bottomNavItems
- .single()
+ .bottomNavItem!!
.questionnaireNavigationUIState
- .navSubmit
- .isShown,
+ .navSubmit is QuestionnaireNavigationViewUIState.Enabled,
)
.isTrue()
}
@@ -2939,11 +3039,9 @@ class QuestionnaireViewModelTest {
assertThat(
viewModel.questionnaireStateFlow
.first()
- .bottomNavItems
- .single()
+ .bottomNavItem!!
.questionnaireNavigationUIState
- .navSubmit
- .isShown,
+ .navSubmit is QuestionnaireNavigationViewUIState.Enabled,
)
.isTrue()
}
@@ -2970,13 +3068,11 @@ class QuestionnaireViewModelTest {
assertThat(
viewModel.questionnaireStateFlow
.first()
- .bottomNavItems
- .single()
+ .bottomNavItem!!
.questionnaireNavigationUIState
- .navCancel
- .isShown,
+ .navCancel is QuestionnaireNavigationViewUIState.Hidden,
)
- .isFalse()
+ .isTrue()
}
@Test
@@ -2995,11 +3091,9 @@ class QuestionnaireViewModelTest {
assertThat(
viewModel.questionnaireStateFlow
.first()
- .bottomNavItems
- .single()
+ .bottomNavItem!!
.questionnaireNavigationUIState
- .navCancel
- .isShown,
+ .navCancel is QuestionnaireNavigationViewUIState.Enabled,
)
.isTrue()
}
@@ -3020,13 +3114,11 @@ class QuestionnaireViewModelTest {
assertThat(
viewModel.questionnaireStateFlow
.first()
- .bottomNavItems
- .single()
+ .bottomNavItem!!
.questionnaireNavigationUIState
- .navCancel
- .isShown,
+ .navCancel is QuestionnaireNavigationViewUIState.Hidden,
)
- .isFalse()
+ .isTrue()
}
@Test
@@ -3068,13 +3160,11 @@ class QuestionnaireViewModelTest {
assertThat(
viewModel.questionnaireStateFlow
.first()
- .bottomNavItems
- .single()
+ .bottomNavItem!!
.questionnaireNavigationUIState
- .navCancel
- .isShown,
+ .navCancel is QuestionnaireNavigationViewUIState.Hidden,
)
- .isFalse()
+ .isTrue()
}
@Test
@@ -3116,11 +3206,9 @@ class QuestionnaireViewModelTest {
assertThat(
viewModel.questionnaireStateFlow
.first()
- .bottomNavItems
- .single()
+ .bottomNavItem!!
.questionnaireNavigationUIState
- .navCancel
- .isShown,
+ .navCancel is QuestionnaireNavigationViewUIState.Enabled,
)
.isTrue()
}
@@ -3164,13 +3252,11 @@ class QuestionnaireViewModelTest {
assertThat(
viewModel.questionnaireStateFlow
.first()
- .bottomNavItems
- .single()
+ .bottomNavItem!!
.questionnaireNavigationUIState
- .navCancel
- .isShown,
+ .navCancel is QuestionnaireNavigationViewUIState.Hidden,
)
- .isFalse()
+ .isTrue()
}
// ==================================================================== //
// //
@@ -3185,7 +3271,7 @@ class QuestionnaireViewModelTest {
Questionnaire().apply {
id = "a-questionnaire"
addItem(
- Questionnaire.QuestionnaireItemComponent().apply {
+ QuestionnaireItemComponent().apply {
linkId = "a-linkId"
type = Questionnaire.QuestionnaireItemType.BOOLEAN
},
@@ -3193,11 +3279,15 @@ class QuestionnaireViewModelTest {
}
val viewModel = createQuestionnaireViewModel(questionnaire, showNavigationInLongScroll = true)
val questionnaireState = viewModel.questionnaireStateFlow.first()
- assertThat(questionnaireState.bottomNavItems.isEmpty()).isTrue()
+ assertThat(questionnaireState.bottomNavItem).isNull()
assertThat(questionnaireState.items.last())
.isInstanceOf(QuestionnaireAdapterItem.Navigation::class.java)
val navigationItem = questionnaireState.items.last() as QuestionnaireAdapterItem.Navigation
- assertThat(navigationItem.questionnaireNavigationUIState.navSubmit.isEnabled).isTrue()
+ assertThat(
+ navigationItem.questionnaireNavigationUIState.navSubmit
+ is QuestionnaireNavigationViewUIState.Enabled,
+ )
+ .isTrue()
}
fun `EXTRA_SHOW_NAVIGATION_IN_DEFAULT_LONG_SCROLL not setting should not add navigation item to questionnaireState items`() =
@@ -3206,7 +3296,7 @@ class QuestionnaireViewModelTest {
Questionnaire().apply {
id = "a-questionnaire"
addItem(
- Questionnaire.QuestionnaireItemComponent().apply {
+ QuestionnaireItemComponent().apply {
linkId = "a-linkId"
type = Questionnaire.QuestionnaireItemType.BOOLEAN
},
@@ -3216,13 +3306,9 @@ class QuestionnaireViewModelTest {
val questionnaireState = viewModel.questionnaireStateFlow.first()
assertThat(questionnaireState.items.map { it::class.java })
.doesNotContain(QuestionnaireAdapterItem.Navigation::class.java)
- assertThat(questionnaireState.bottomNavItems.isNotEmpty()).isTrue()
assertThat(
- questionnaireState.bottomNavItems
- .single()
- .questionnaireNavigationUIState
- .navSubmit
- .isEnabled,
+ questionnaireState.bottomNavItem!!.questionnaireNavigationUIState.navSubmit
+ is QuestionnaireNavigationViewUIState.Enabled,
)
.isTrue()
}
@@ -3399,7 +3485,7 @@ class QuestionnaireViewModelTest {
},
)
}
- state.set(EXTRA_QUESTIONNAIRE_JSON_STRING, printer.encodeResourceToString(questionnaire))
+ state[EXTRA_QUESTIONNAIRE_JSON_STRING] = printer.encodeResourceToString(questionnaire)
val viewModel = QuestionnaireViewModel(context, state)
@@ -3444,7 +3530,7 @@ class QuestionnaireViewModelTest {
},
)
}
- state.set(EXTRA_QUESTIONNAIRE_JSON_STRING, printer.encodeResourceToString(questionnaire))
+ state[EXTRA_QUESTIONNAIRE_JSON_STRING] = printer.encodeResourceToString(questionnaire)
val viewModel = QuestionnaireViewModel(context, state)
@@ -3504,7 +3590,7 @@ class QuestionnaireViewModelTest {
},
)
}
- state.set(EXTRA_QUESTIONNAIRE_JSON_STRING, printer.encodeResourceToString(questionnaire))
+ state[EXTRA_QUESTIONNAIRE_JSON_STRING] = printer.encodeResourceToString(questionnaire)
val questionnaireResponse =
QuestionnaireResponse().apply {
@@ -3530,10 +3616,8 @@ class QuestionnaireViewModelTest {
},
)
}
- state.set(
- EXTRA_QUESTIONNAIRE_RESPONSE_JSON_STRING,
- printer.encodeResourceToString(questionnaireResponse),
- )
+ state[EXTRA_QUESTIONNAIRE_RESPONSE_JSON_STRING] =
+ printer.encodeResourceToString(questionnaireResponse)
val viewModel = QuestionnaireViewModel(context, state)
@@ -4087,8 +4171,8 @@ class QuestionnaireViewModelTest {
"""
.trimIndent()
- state.set(EXTRA_QUESTIONNAIRE_JSON_STRING, questionnaireString)
- state.set(EXTRA_QUESTIONNAIRE_RESPONSE_JSON_STRING, questionnaireResponseString)
+ state[EXTRA_QUESTIONNAIRE_JSON_STRING] = questionnaireString
+ state[EXTRA_QUESTIONNAIRE_RESPONSE_JSON_STRING] = questionnaireResponseString
val viewModel = QuestionnaireViewModel(context, state)
runTest {
val value = viewModel.getQuestionnaireResponse()
@@ -4185,8 +4269,8 @@ class QuestionnaireViewModelTest {
"""
.trimIndent()
- state.set(EXTRA_QUESTIONNAIRE_JSON_STRING, questionnaireString)
- state.set(EXTRA_QUESTIONNAIRE_RESPONSE_JSON_STRING, questionnaireResponseString)
+ state[EXTRA_QUESTIONNAIRE_JSON_STRING] = questionnaireString
+ state[EXTRA_QUESTIONNAIRE_RESPONSE_JSON_STRING] = questionnaireResponseString
val viewModel = QuestionnaireViewModel(context, state)
viewModel.clearAllAnswers()
runTest {
@@ -4828,11 +4912,9 @@ class QuestionnaireViewModelTest {
},
)
}
- state.set(EXTRA_QUESTIONNAIRE_JSON_STRING, printer.encodeResourceToString(questionnaire))
- state.set(
- EXTRA_QUESTIONNAIRE_LAUNCH_CONTEXT_MAP,
- mapOf("patient" to printer.encodeResourceToString(patient)),
- )
+ state[EXTRA_QUESTIONNAIRE_JSON_STRING] = printer.encodeResourceToString(questionnaire)
+ state[EXTRA_QUESTIONNAIRE_LAUNCH_CONTEXT_MAP] =
+ mapOf("patient" to printer.encodeResourceToString(patient))
val viewModel = QuestionnaireViewModel(context, state)
viewModel.runViewModelBlocking {
@@ -4886,7 +4968,7 @@ class QuestionnaireViewModelTest {
},
)
}
- state.set(EXTRA_QUESTIONNAIRE_JSON_STRING, printer.encodeResourceToString(questionnaire))
+ state[EXTRA_QUESTIONNAIRE_JSON_STRING] = printer.encodeResourceToString(questionnaire)
val viewModel = QuestionnaireViewModel(context, state)
viewModel.runViewModelBlocking {
@@ -4930,7 +5012,7 @@ class QuestionnaireViewModelTest {
},
)
}
- state.set(EXTRA_QUESTIONNAIRE_JSON_STRING, printer.encodeResourceToString(questionnaire))
+ state[EXTRA_QUESTIONNAIRE_JSON_STRING] = printer.encodeResourceToString(questionnaire)
val viewModel = QuestionnaireViewModel(context, state)
val exception =
assertThrows(IllegalStateException::class.java) {
@@ -4987,7 +5069,7 @@ class QuestionnaireViewModelTest {
)
}
- state.set(EXTRA_QUESTIONNAIRE_JSON_STRING, printer.encodeResourceToString(questionnaire))
+ state[EXTRA_QUESTIONNAIRE_JSON_STRING] = printer.encodeResourceToString(questionnaire)
val viewModel = QuestionnaireViewModel(context, state)
viewModel.runViewModelBlocking {
@@ -5039,7 +5121,7 @@ class QuestionnaireViewModelTest {
)
}
- state.set(EXTRA_QUESTIONNAIRE_JSON_STRING, printer.encodeResourceToString(questionnaire))
+ state[EXTRA_QUESTIONNAIRE_JSON_STRING] = printer.encodeResourceToString(questionnaire)
val viewModel = QuestionnaireViewModel(context, state)
viewModel.runViewModelBlocking {
@@ -5091,7 +5173,7 @@ class QuestionnaireViewModelTest {
)
}
- state.set(EXTRA_QUESTIONNAIRE_JSON_STRING, printer.encodeResourceToString(questionnaire))
+ state[EXTRA_QUESTIONNAIRE_JSON_STRING] = printer.encodeResourceToString(questionnaire)
val viewModel = QuestionnaireViewModel(context, state)
viewModel.runViewModelBlocking {
@@ -5142,7 +5224,7 @@ class QuestionnaireViewModelTest {
)
}
- state.set(EXTRA_QUESTIONNAIRE_JSON_STRING, printer.encodeResourceToString(questionnaire))
+ state[EXTRA_QUESTIONNAIRE_JSON_STRING] = printer.encodeResourceToString(questionnaire)
val viewModel = QuestionnaireViewModel(context, state)
viewModel.runViewModelBlocking {
@@ -6883,7 +6965,7 @@ class QuestionnaireViewModelTest {
},
)
}
- state.set(EXTRA_QUESTIONNAIRE_JSON_STRING, printer.encodeResourceToString(questionnaire))
+ state[EXTRA_QUESTIONNAIRE_JSON_STRING] = printer.encodeResourceToString(questionnaire)
val viewModel = QuestionnaireViewModel(context, state)
viewModel.runViewModelBlocking {
@@ -6933,7 +7015,7 @@ class QuestionnaireViewModelTest {
},
)
}
- state.set(EXTRA_QUESTIONNAIRE_JSON_STRING, printer.encodeResourceToString(questionnaire))
+ state[EXTRA_QUESTIONNAIRE_JSON_STRING] = printer.encodeResourceToString(questionnaire)
val viewModel = QuestionnaireViewModel(context, state)
viewModel.runViewModelBlocking {
@@ -6988,7 +7070,7 @@ class QuestionnaireViewModelTest {
},
)
}
- state.set(EXTRA_QUESTIONNAIRE_JSON_STRING, printer.encodeResourceToString(questionnaire))
+ state[EXTRA_QUESTIONNAIRE_JSON_STRING] = printer.encodeResourceToString(questionnaire)
val viewModel = QuestionnaireViewModel(context, state)
viewModel.runViewModelBlocking {
@@ -7043,7 +7125,7 @@ class QuestionnaireViewModelTest {
},
)
}
- state.set(EXTRA_QUESTIONNAIRE_JSON_STRING, printer.encodeResourceToString(questionnaire))
+ state[EXTRA_QUESTIONNAIRE_JSON_STRING] = printer.encodeResourceToString(questionnaire)
val viewModel = QuestionnaireViewModel(context, state)
viewModel.runViewModelBlocking {
@@ -7170,10 +7252,8 @@ class QuestionnaireViewModelTest {
}
}
- state.set(
- EXTRA_QUESTIONNAIRE_JSON_STRING,
- printer.encodeResourceToString(questionnaire(emptyList())),
- )
+ state[EXTRA_QUESTIONNAIRE_JSON_STRING] =
+ printer.encodeResourceToString(questionnaire(emptyList()))
// empty initial value
var viewModel = QuestionnaireViewModel(context, state)
@@ -7187,16 +7267,14 @@ class QuestionnaireViewModelTest {
}
// initial value is set to false
- state.set(
- EXTRA_QUESTIONNAIRE_JSON_STRING,
+ state[EXTRA_QUESTIONNAIRE_JSON_STRING] =
printer.encodeResourceToString(
questionnaire(
listOf(
Questionnaire.QuestionnaireItemInitialComponent().apply { value = BooleanType(false) },
),
),
- ),
- )
+ )
viewModel = QuestionnaireViewModel(context, state)
var enabledDisplayItems: List
@@ -7211,16 +7289,14 @@ class QuestionnaireViewModelTest {
}
// initial value is set to true
- state.set(
- EXTRA_QUESTIONNAIRE_JSON_STRING,
+ state[EXTRA_QUESTIONNAIRE_JSON_STRING] =
printer.encodeResourceToString(
questionnaire(
listOf(
Questionnaire.QuestionnaireItemInitialComponent().apply { value = BooleanType(true) },
),
),
- ),
- )
+ )
viewModel = QuestionnaireViewModel(context, state)
viewModel.runViewModelBlocking {
@@ -7274,9 +7350,9 @@ class QuestionnaireViewModelTest {
val job =
this.launch {
- viewModel.questionnaireStateFlow.collect {
+ viewModel.questionnaireStateFlow.collect { questionnaireState ->
descriptionResponseItem =
- it.items
+ questionnaireState.items
.find { it.asQuestion().questionnaireItem.linkId == "a-description" }!!
.asQuestion()
this@launch.cancel()
@@ -7381,20 +7457,18 @@ class QuestionnaireViewModelTest {
showCancelButton: Boolean? = null,
showNavigationInLongScroll: Boolean = false,
): QuestionnaireViewModel {
- state.set(EXTRA_QUESTIONNAIRE_JSON_STRING, printer.encodeResourceToString(questionnaire))
+ state[EXTRA_QUESTIONNAIRE_JSON_STRING] = printer.encodeResourceToString(questionnaire)
questionnaireResponse?.let {
- state.set(
- EXTRA_QUESTIONNAIRE_RESPONSE_JSON_STRING,
- printer.encodeResourceToString(questionnaireResponse),
- )
+ state[EXTRA_QUESTIONNAIRE_RESPONSE_JSON_STRING] =
+ printer.encodeResourceToString(questionnaireResponse)
}
- enableReviewPage.let { state.set(EXTRA_ENABLE_REVIEW_PAGE, it) }
- showReviewPageFirst.let { state.set(EXTRA_SHOW_REVIEW_PAGE_FIRST, it) }
- readOnlyMode.let { state.set(EXTRA_READ_ONLY, it) }
- showSubmitButton?.let { state.set(EXTRA_SHOW_SUBMIT_BUTTON, it) }
- showCancelButton?.let { state.set(EXTRA_SHOW_CANCEL_BUTTON, it) }
- showNavigationInLongScroll.let { state.set(EXTRA_SHOW_NAVIGATION_IN_DEFAULT_LONG_SCROLL, it) }
+ enableReviewPage.let { state[EXTRA_ENABLE_REVIEW_PAGE] = it }
+ showReviewPageFirst.let { state[EXTRA_SHOW_REVIEW_PAGE_FIRST] = it }
+ readOnlyMode.let { state[EXTRA_READ_ONLY] = it }
+ showSubmitButton?.let { state[EXTRA_SHOW_SUBMIT_BUTTON] = it }
+ showCancelButton?.let { state[EXTRA_SHOW_CANCEL_BUTTON] = it }
+ showNavigationInLongScroll.let { state[EXTRA_SHOW_NAVIGATION_IN_DEFAULT_LONG_SCROLL] = it }
return QuestionnaireViewModel(context, state)
}
diff --git a/demo/build.gradle.kts b/demo/build.gradle.kts
index d5440eba6e..7d0fb5c13c 100644
--- a/demo/build.gradle.kts
+++ b/demo/build.gradle.kts
@@ -61,7 +61,7 @@ dependencies {
implementation(libs.kotlinx.coroutines.android)
implementation(libs.kotlinx.coroutines.core)
implementation(project(":datacapture")) {
- exclude(group = Dependencies.androidFhirGroup, module = Dependencies.androidFhirEngineModule)
+ exclude(group = "com.google.android.fhir", module = "engine")
}
implementation(project(":engine"))
diff --git a/demo/src/main/java/com/google/android/fhir/demo/ActivityViewModel.kt b/demo/src/main/java/com/google/android/fhir/demo/ActivityViewModel.kt
new file mode 100644
index 0000000000..6df961cc92
--- /dev/null
+++ b/demo/src/main/java/com/google/android/fhir/demo/ActivityViewModel.kt
@@ -0,0 +1,43 @@
+/*
+ * Copyright 2024 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.demo
+
+import android.app.Application
+import androidx.lifecycle.AndroidViewModel
+import androidx.lifecycle.viewModelScope
+import com.google.android.fhir.FhirEngine
+import com.google.android.fhir.demo.extensions.isFirstLaunch
+import com.google.android.fhir.demo.extensions.setFirstLaunchCompleted
+import com.google.android.fhir.demo.helpers.PatientCreationHelper
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import timber.log.Timber
+
+class ActivityViewModel(application: Application) : AndroidViewModel(application) {
+ private var fhirEngine: FhirEngine = FhirApplication.fhirEngine(application.applicationContext)
+
+ fun createPatientsOnAppFirstLaunch() {
+ viewModelScope.launch(Dispatchers.IO) {
+ if (getApplication().applicationContext.isFirstLaunch()) {
+ Timber.i("Creating patients on first launch")
+ PatientCreationHelper.createSamplePatients().forEach { fhirEngine.create(it) }
+ getApplication().applicationContext.setFirstLaunchCompleted()
+ Timber.i("Patients created on first launch")
+ }
+ }
+ }
+}
diff --git a/demo/src/main/java/com/google/android/fhir/demo/AddPatientFragment.kt b/demo/src/main/java/com/google/android/fhir/demo/AddPatientFragment.kt
index be15d4cb05..d271e3cbcb 100644
--- a/demo/src/main/java/com/google/android/fhir/demo/AddPatientFragment.kt
+++ b/demo/src/main/java/com/google/android/fhir/demo/AddPatientFragment.kt
@@ -44,7 +44,6 @@ class AddPatientFragment : Fragment(R.layout.add_patient_fragment) {
addQuestionnaireFragment()
}
observePatientSaveAction()
- (activity as MainActivity).setDrawerEnabled(false)
/** Use the provided cancel|submit buttons from the sdc library */
childFragmentManager.setFragmentResultListener(
diff --git a/demo/src/main/java/com/google/android/fhir/demo/EditPatientFragment.kt b/demo/src/main/java/com/google/android/fhir/demo/EditPatientFragment.kt
index 43b0914b1b..8b11187b71 100644
--- a/demo/src/main/java/com/google/android/fhir/demo/EditPatientFragment.kt
+++ b/demo/src/main/java/com/google/android/fhir/demo/EditPatientFragment.kt
@@ -55,7 +55,6 @@ class EditPatientFragment : Fragment(R.layout.add_patient_fragment) {
Toast.makeText(requireContext(), R.string.message_patient_updated, Toast.LENGTH_SHORT).show()
NavHostFragment.findNavController(this).navigateUp()
}
- (activity as MainActivity).setDrawerEnabled(false)
/** Use the provided cancel|submit buttons from the sdc library */
childFragmentManager.setFragmentResultListener(
diff --git a/demo/src/main/java/com/google/android/fhir/demo/HomeFragment.kt b/demo/src/main/java/com/google/android/fhir/demo/HomeFragment.kt
index c6285d685e..7ba3df3f09 100644
--- a/demo/src/main/java/com/google/android/fhir/demo/HomeFragment.kt
+++ b/demo/src/main/java/com/google/android/fhir/demo/HomeFragment.kt
@@ -17,7 +17,6 @@
package com.google.android.fhir.demo
import android.os.Bundle
-import android.view.MenuItem
import android.view.View
import androidx.appcompat.app.AppCompatActivity
import androidx.cardview.widget.CardView
@@ -30,32 +29,21 @@ class HomeFragment : Fragment(R.layout.fragment_home) {
super.onViewCreated(view, savedInstanceState)
(requireActivity() as AppCompatActivity).supportActionBar?.apply {
title = resources.getString(R.string.app_name)
- setDisplayHomeAsUpEnabled(true)
+ setDisplayHomeAsUpEnabled(false)
}
- setHasOptionsMenu(true)
- (activity as MainActivity).setDrawerEnabled(true)
setOnClicks()
}
private fun setOnClicks() {
- requireView().findViewById(R.id.item_new_patient).setOnClickListener {
- findNavController().navigate(HomeFragmentDirections.actionHomeFragmentToAddPatientFragment())
- }
- requireView().findViewById(R.id.item_patient_list).setOnClickListener {
- findNavController().navigate(HomeFragmentDirections.actionHomeFragmentToPatientList())
- }
requireView().findViewById(R.id.item_search).setOnClickListener {
findNavController().navigate(HomeFragmentDirections.actionHomeFragmentToPatientList())
}
- }
-
- override fun onOptionsItemSelected(item: MenuItem): Boolean {
- return when (item.itemId) {
- android.R.id.home -> {
- (requireActivity() as MainActivity).openNavigationDrawer()
- true
- }
- else -> false
+ requireView().findViewById(R.id.item_sync).setOnClickListener {
+ findNavController().navigate(HomeFragmentDirections.actionHomeFragmentToSyncFragment())
+ }
+ requireView().findViewById(R.id.item_periodic_sync).setOnClickListener {
+ findNavController()
+ .navigate(HomeFragmentDirections.actionHomeFragmentToPeriodicSyncFragment())
}
}
}
diff --git a/demo/src/main/java/com/google/android/fhir/demo/MainActivity.kt b/demo/src/main/java/com/google/android/fhir/demo/MainActivity.kt
index 422110d638..7c21b1df67 100644
--- a/demo/src/main/java/com/google/android/fhir/demo/MainActivity.kt
+++ b/demo/src/main/java/com/google/android/fhir/demo/MainActivity.kt
@@ -17,78 +17,26 @@
package com.google.android.fhir.demo
import android.os.Bundle
-import android.view.MenuItem
-import android.widget.TextView
import androidx.activity.viewModels
-import androidx.appcompat.app.ActionBarDrawerToggle
import androidx.appcompat.app.AppCompatActivity
-import androidx.core.view.GravityCompat
-import androidx.drawerlayout.widget.DrawerLayout
import com.google.android.fhir.demo.databinding.ActivityMainBinding
const val MAX_RESOURCE_COUNT = 20
class MainActivity : AppCompatActivity() {
private lateinit var binding: ActivityMainBinding
- private lateinit var drawerToggle: ActionBarDrawerToggle
- private val viewModel: MainActivityViewModel by viewModels()
+ private val activityViewModel: ActivityViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
initActionBar()
- initNavigationDrawer()
- observeLastSyncTime()
- viewModel.updateLastSyncTimestamp()
- }
-
- override fun onBackPressed() {
- if (binding.drawer.isDrawerOpen(GravityCompat.START)) {
- binding.drawer.closeDrawer(GravityCompat.START)
- return
- }
- super.onBackPressed()
- }
-
- fun setDrawerEnabled(enabled: Boolean) {
- val lockMode =
- if (enabled) DrawerLayout.LOCK_MODE_UNLOCKED else DrawerLayout.LOCK_MODE_LOCKED_CLOSED
- binding.drawer.setDrawerLockMode(lockMode)
- drawerToggle.isDrawerIndicatorEnabled = enabled
- }
-
- fun openNavigationDrawer() {
- binding.drawer.openDrawer(GravityCompat.START)
- viewModel.updateLastSyncTimestamp()
+ activityViewModel.createPatientsOnAppFirstLaunch()
}
private fun initActionBar() {
val toolbar = binding.toolbar
setSupportActionBar(toolbar)
}
-
- private fun initNavigationDrawer() {
- binding.navigationView.setNavigationItemSelectedListener(this::onNavigationItemSelected)
- drawerToggle = ActionBarDrawerToggle(this, binding.drawer, R.string.open, R.string.close)
- binding.drawer.addDrawerListener(drawerToggle)
- drawerToggle.syncState()
- }
-
- private fun onNavigationItemSelected(item: MenuItem): Boolean {
- when (item.itemId) {
- R.id.menu_sync -> {
- viewModel.triggerOneTimeSync()
- binding.drawer.closeDrawer(GravityCompat.START)
- return false
- }
- }
- return false
- }
-
- private fun observeLastSyncTime() {
- viewModel.lastSyncTimestampLiveData.observe(this) {
- binding.navigationView.getHeaderView(0).findViewById(R.id.last_sync_tv).text = it
- }
- }
}
diff --git a/demo/src/main/java/com/google/android/fhir/demo/PatientDetailsFragment.kt b/demo/src/main/java/com/google/android/fhir/demo/PatientDetailsFragment.kt
index 22ad52bfcb..db805fc64f 100644
--- a/demo/src/main/java/com/google/android/fhir/demo/PatientDetailsFragment.kt
+++ b/demo/src/main/java/com/google/android/fhir/demo/PatientDetailsFragment.kt
@@ -1,5 +1,5 @@
/*
- * Copyright 2022-2023 Google LLC
+ * Copyright 2022-2024 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,6 @@ class PatientDetailsFragment : Fragment() {
}
}
patientDetailsViewModel.getPatientDetailData()
- (activity as MainActivity).setDrawerEnabled(false)
}
private fun onAddScreenerClick() {
diff --git a/demo/src/main/java/com/google/android/fhir/demo/PatientListFragment.kt b/demo/src/main/java/com/google/android/fhir/demo/PatientListFragment.kt
index de218bfd46..e61ab37648 100644
--- a/demo/src/main/java/com/google/android/fhir/demo/PatientListFragment.kt
+++ b/demo/src/main/java/com/google/android/fhir/demo/PatientListFragment.kt
@@ -20,22 +20,15 @@ import android.content.Context
import android.graphics.Color
import android.graphics.drawable.ColorDrawable
import android.os.Bundle
-import android.os.Handler
-import android.os.Looper
import android.view.LayoutInflater
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
-import android.view.animation.AnimationUtils
import android.view.inputmethod.InputMethodManager
-import android.widget.LinearLayout
-import android.widget.ProgressBar
-import android.widget.TextView
import androidx.activity.OnBackPressedCallback
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.widget.SearchView
import androidx.fragment.app.Fragment
-import androidx.fragment.app.activityViewModels
import androidx.lifecycle.ViewModelProvider
import androidx.navigation.fragment.NavHostFragment
import androidx.navigation.fragment.findNavController
@@ -44,28 +37,16 @@ import androidx.recyclerview.widget.RecyclerView
import com.google.android.fhir.FhirEngine
import com.google.android.fhir.demo.PatientListViewModel.PatientListViewModelFactory
import com.google.android.fhir.demo.databinding.FragmentPatientListBinding
-import com.google.android.fhir.demo.extensions.launchAndRepeatStarted
-import com.google.android.fhir.sync.CurrentSyncJobStatus
-import com.google.android.fhir.sync.LastSyncJobStatus
-import com.google.android.fhir.sync.PeriodicSyncJobStatus
-import com.google.android.fhir.sync.SyncJobStatus
-import kotlin.math.roundToInt
import timber.log.Timber
class PatientListFragment : Fragment() {
private lateinit var fhirEngine: FhirEngine
private lateinit var patientListViewModel: PatientListViewModel
private lateinit var searchView: SearchView
- private lateinit var topBanner: LinearLayout
- private lateinit var syncStatus: TextView
- private lateinit var syncPercent: TextView
- private lateinit var syncProgress: ProgressBar
private var _binding: FragmentPatientListBinding? = null
private val binding
get() = _binding!!
- private val mainActivityViewModel: MainActivityViewModel by activityViewModels()
-
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
@@ -107,11 +88,6 @@ class PatientListFragment : Fragment() {
}
searchView = binding.search
- topBanner = binding.syncStatusContainer.linearLayoutSyncStatus
- topBanner.visibility = View.GONE
- syncStatus = binding.syncStatusContainer.tvSyncingStatus
- syncPercent = binding.syncStatusContainer.tvSyncingPercent
- syncProgress = binding.syncStatusContainer.progressSyncing
searchView.setOnQueryTextListener(
object : SearchView.OnQueryTextListener {
override fun onQueryTextChange(newText: String): Boolean {
@@ -148,94 +124,7 @@ class PatientListFragment : Fragment() {
},
)
- binding.apply {
- addPatient.setOnClickListener { onAddPatientClick() }
- addPatient.setColorFilter(Color.WHITE)
- }
setHasOptionsMenu(true)
- (activity as MainActivity).setDrawerEnabled(false)
- launchAndRepeatStarted(
- { mainActivityViewModel.pollState.collect(::currentSyncJobStatus) },
- { mainActivityViewModel.pollPeriodicSyncJobStatus.collect(::periodicSyncJobStatus) },
- )
- }
-
- private fun currentSyncJobStatus(currentSyncJobStatus: CurrentSyncJobStatus) {
- when (currentSyncJobStatus) {
- is CurrentSyncJobStatus.Running -> {
- Timber.i(
- "Sync: ${currentSyncJobStatus::class.java.simpleName} with data ${currentSyncJobStatus.inProgressSyncJob}",
- )
- fadeInTopBanner(currentSyncJobStatus)
- }
- is CurrentSyncJobStatus.Succeeded -> {
- Timber.i(
- "Sync: ${currentSyncJobStatus::class.java.simpleName} at ${currentSyncJobStatus.timestamp}",
- )
- patientListViewModel.searchPatientsByName(searchView.query.toString().trim())
- mainActivityViewModel.updateLastSyncTimestamp(currentSyncJobStatus.timestamp)
- fadeOutTopBanner(currentSyncJobStatus)
- }
- is CurrentSyncJobStatus.Failed -> {
- Timber.i(
- "Sync: ${currentSyncJobStatus::class.java.simpleName} at ${currentSyncJobStatus.timestamp}",
- )
- patientListViewModel.searchPatientsByName(searchView.query.toString().trim())
- mainActivityViewModel.updateLastSyncTimestamp(currentSyncJobStatus.timestamp)
- fadeOutTopBanner(currentSyncJobStatus)
- }
- is CurrentSyncJobStatus.Enqueued -> {
- Timber.i("Sync: Enqueued")
- patientListViewModel.searchPatientsByName(searchView.query.toString().trim())
- fadeOutTopBanner(currentSyncJobStatus)
- }
- is CurrentSyncJobStatus.Cancelled -> {
- Timber.i("Sync: Cancelled")
- fadeOutTopBanner(currentSyncJobStatus)
- }
- is CurrentSyncJobStatus.Blocked -> {
- Timber.i("Sync: Blocked")
- fadeOutTopBanner(currentSyncJobStatus)
- }
- }
- }
-
- private fun periodicSyncJobStatus(periodicSyncJobStatus: PeriodicSyncJobStatus) {
- when (periodicSyncJobStatus.currentSyncJobStatus) {
- is CurrentSyncJobStatus.Running -> {
- fadeInTopBanner(periodicSyncJobStatus.currentSyncJobStatus)
- }
- is CurrentSyncJobStatus.Succeeded -> {
- val lastSyncTimestamp =
- (periodicSyncJobStatus.currentSyncJobStatus as CurrentSyncJobStatus.Succeeded).timestamp
- patientListViewModel.searchPatientsByName(searchView.query.toString().trim())
- mainActivityViewModel.updateLastSyncTimestamp(lastSyncTimestamp)
- fadeOutTopBanner(periodicSyncJobStatus.currentSyncJobStatus)
- }
- is CurrentSyncJobStatus.Failed -> {
- val lastSyncTimestamp =
- (periodicSyncJobStatus.currentSyncJobStatus as CurrentSyncJobStatus.Failed).timestamp
- Timber.i(
- "Sync: ${periodicSyncJobStatus.currentSyncJobStatus::class.java.simpleName} at $lastSyncTimestamp}",
- )
- patientListViewModel.searchPatientsByName(searchView.query.toString().trim())
- mainActivityViewModel.updateLastSyncTimestamp(lastSyncTimestamp)
- fadeOutTopBanner(periodicSyncJobStatus.currentSyncJobStatus)
- }
- is CurrentSyncJobStatus.Enqueued -> {
- Timber.i("Sync: Enqueued")
- patientListViewModel.searchPatientsByName(searchView.query.toString().trim())
- fadeOutTopBanner(periodicSyncJobStatus.currentSyncJobStatus)
- }
- is CurrentSyncJobStatus.Cancelled -> {
- Timber.i("Sync: Cancelled")
- fadeOutTopBanner(periodicSyncJobStatus.currentSyncJobStatus)
- }
- is CurrentSyncJobStatus.Blocked -> {
- Timber.i("Sync: Blocked")
- fadeOutTopBanner(periodicSyncJobStatus.currentSyncJobStatus)
- }
- }
}
override fun onDestroyView() {
@@ -257,54 +146,4 @@ class PatientListFragment : Fragment() {
findNavController()
.navigate(PatientListFragmentDirections.navigateToProductDetail(patientItem.resourceId))
}
-
- private fun onAddPatientClick() {
- findNavController()
- .navigate(PatientListFragmentDirections.actionPatientListToAddPatientFragment())
- }
-
- private fun fadeInTopBanner(state: CurrentSyncJobStatus) {
- if (topBanner.visibility != View.VISIBLE) {
- syncStatus.text = resources.getString(R.string.syncing).uppercase()
- syncPercent.text = ""
- syncProgress.progress = 0
- syncProgress.visibility = View.VISIBLE
- topBanner.visibility = View.VISIBLE
- val animation = AnimationUtils.loadAnimation(topBanner.context, R.anim.fade_in)
- topBanner.startAnimation(animation)
- } else if (
- state is CurrentSyncJobStatus.Running && state.inProgressSyncJob is SyncJobStatus.InProgress
- ) {
- val inProgressState = state.inProgressSyncJob as? SyncJobStatus.InProgress
- val progress =
- inProgressState
- ?.let { it.completed.toDouble().div(it.total) }
- ?.let { if (it.isNaN()) 0.0 else it }
- ?.times(100)
- ?.roundToInt()
- "$progress% ${inProgressState?.syncOperation?.name?.lowercase()}ed"
- .also { syncPercent.text = it }
- syncProgress.progress = progress ?: 0
- }
- }
-
- private fun fadeOutTopBanner(state: CurrentSyncJobStatus) {
- fadeOutTopBanner(state::class.java.simpleName.uppercase())
- }
-
- private fun fadeOutTopBanner(state: LastSyncJobStatus) {
- fadeOutTopBanner(state::class.java.simpleName.uppercase())
- }
-
- private fun fadeOutTopBanner(statusText: String) {
- syncPercent.text = ""
- syncProgress.visibility = View.GONE
- if (topBanner.visibility == View.VISIBLE) {
- "${resources.getString(R.string.sync).uppercase()} $statusText".also { syncStatus.text = it }
-
- val animation = AnimationUtils.loadAnimation(topBanner.context, R.anim.fade_out)
- topBanner.startAnimation(animation)
- Handler(Looper.getMainLooper()).postDelayed({ topBanner.visibility = View.GONE }, 2000)
- }
- }
}
diff --git a/demo/src/main/java/com/google/android/fhir/demo/PeriodicSyncFragment.kt b/demo/src/main/java/com/google/android/fhir/demo/PeriodicSyncFragment.kt
new file mode 100644
index 0000000000..4fc6d3c830
--- /dev/null
+++ b/demo/src/main/java/com/google/android/fhir/demo/PeriodicSyncFragment.kt
@@ -0,0 +1,103 @@
+/*
+ * Copyright 2024 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.demo
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.MenuItem
+import android.view.View
+import android.view.ViewGroup
+import android.widget.ProgressBar
+import android.widget.TextView
+import androidx.appcompat.app.AppCompatActivity
+import androidx.fragment.app.Fragment
+import androidx.fragment.app.viewModels
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.lifecycleScope
+import androidx.lifecycle.repeatOnLifecycle
+import androidx.navigation.fragment.NavHostFragment
+import kotlinx.coroutines.launch
+
+class PeriodicSyncFragment : Fragment() {
+ private val periodicSyncViewModel: PeriodicSyncViewModel by viewModels()
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?,
+ ): View {
+ return inflater.inflate(R.layout.periodic_sync, container, false)
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+ setUpActionBar()
+ setHasOptionsMenu(true)
+ refreshPeriodicSynUi()
+ }
+
+ override fun onOptionsItemSelected(item: MenuItem): Boolean {
+ return when (item.itemId) {
+ android.R.id.home -> {
+ NavHostFragment.findNavController(this).navigateUp()
+ true
+ }
+ else -> false
+ }
+ }
+
+ private fun setUpActionBar() {
+ (requireActivity() as AppCompatActivity).supportActionBar?.apply {
+ title = requireContext().getString(R.string.periodic_sync)
+ setDisplayHomeAsUpEnabled(true)
+ }
+ }
+
+ private fun refreshPeriodicSynUi() {
+ viewLifecycleOwner.lifecycleScope.launch {
+ viewLifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
+ periodicSyncViewModel.uiStateFlow.collect { uiState ->
+ uiState.lastSyncStatus?.let {
+ requireView().findViewById(R.id.last_sync_status).text = it
+ }
+
+ uiState.lastSyncTime?.let {
+ requireView().findViewById(R.id.last_sync_time).text = it
+ }
+
+ uiState.currentSyncStatus?.let {
+ requireView().findViewById(R.id.current_sync_status).text = it
+ }
+
+ val syncIndicator = requireView().findViewById(R.id.sync_indicator)
+ val progressLabel = requireView().findViewById(R.id.progress_percentage_label)
+
+ if (uiState.progress != null) {
+ syncIndicator.progress = uiState.progress
+ syncIndicator.visibility = View.VISIBLE
+
+ progressLabel.text = "${uiState.progress}%"
+ progressLabel.visibility = View.VISIBLE
+ } else {
+ syncIndicator.progress = 0
+ progressLabel.visibility = View.GONE
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/demo/src/main/java/com/google/android/fhir/demo/PeriodicSyncViewModel.kt b/demo/src/main/java/com/google/android/fhir/demo/PeriodicSyncViewModel.kt
new file mode 100644
index 0000000000..f5a230bd10
--- /dev/null
+++ b/demo/src/main/java/com/google/android/fhir/demo/PeriodicSyncViewModel.kt
@@ -0,0 +1,126 @@
+/*
+ * Copyright 2024 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.demo
+
+import android.app.Application
+import androidx.lifecycle.AndroidViewModel
+import androidx.lifecycle.viewModelScope
+import androidx.work.Constraints
+import com.google.android.fhir.demo.data.DemoFhirSyncWorker
+import com.google.android.fhir.demo.extensions.formatSyncTimestamp
+import com.google.android.fhir.demo.helpers.ProgressHelper
+import com.google.android.fhir.sync.CurrentSyncJobStatus
+import com.google.android.fhir.sync.LastSyncJobStatus
+import com.google.android.fhir.sync.PeriodicSyncConfiguration
+import com.google.android.fhir.sync.PeriodicSyncJobStatus
+import com.google.android.fhir.sync.RepeatInterval
+import com.google.android.fhir.sync.Sync
+import com.google.android.fhir.sync.SyncJobStatus
+import java.util.concurrent.TimeUnit
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharedFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.shareIn
+import kotlinx.coroutines.launch
+
+class PeriodicSyncViewModel(application: Application) : AndroidViewModel(application) {
+
+ val pollPeriodicSyncJobStatus: SharedFlow =
+ Sync.periodicSync(
+ application.applicationContext,
+ periodicSyncConfiguration =
+ PeriodicSyncConfiguration(
+ syncConstraints = Constraints.Builder().build(),
+ repeat = RepeatInterval(interval = 15, timeUnit = TimeUnit.MINUTES),
+ ),
+ )
+ .shareIn(viewModelScope, SharingStarted.Eagerly, 10)
+
+ private val _uiStateFlow = MutableStateFlow(PeriodicSyncUiState())
+ val uiStateFlow: StateFlow = _uiStateFlow
+
+ init {
+ collectPeriodicSyncJobStatus()
+ }
+
+ private fun collectPeriodicSyncJobStatus() {
+ viewModelScope.launch {
+ pollPeriodicSyncJobStatus.collect { periodicSyncJobStatus ->
+ val lastSyncStatus = getLastSyncStatus(periodicSyncJobStatus.lastSyncJobStatus)
+ val lastSyncTime = getLastSyncTime(periodicSyncJobStatus.lastSyncJobStatus)
+ val currentSyncStatus =
+ getApplication()
+ .getString(
+ R.string.current_status,
+ periodicSyncJobStatus.currentSyncJobStatus::class.java.simpleName,
+ )
+ val progress = getProgress(periodicSyncJobStatus.currentSyncJobStatus)
+
+ // Update the UI state
+ _uiStateFlow.value =
+ _uiStateFlow.value.copy(
+ lastSyncStatus = lastSyncStatus,
+ lastSyncTime = lastSyncTime,
+ currentSyncStatus = currentSyncStatus,
+ progress = progress,
+ )
+ }
+ }
+ }
+
+ private fun getLastSyncStatus(lastSyncJobStatus: LastSyncJobStatus?): String? {
+ return when (lastSyncJobStatus) {
+ is LastSyncJobStatus.Succeeded ->
+ getApplication()
+ .getString(
+ R.string.last_sync_status,
+ LastSyncJobStatus.Succeeded::class.java.simpleName,
+ )
+ is LastSyncJobStatus.Failed ->
+ getApplication()
+ .getString(R.string.last_sync_status, LastSyncJobStatus.Failed::class.java.simpleName)
+ else -> getApplication().getString(R.string.last_sync_status_na)
+ }
+ }
+
+ private fun getLastSyncTime(lastSyncJobStatus: LastSyncJobStatus?): String {
+ val applicationContext = getApplication()
+ return lastSyncJobStatus?.let { status ->
+ applicationContext.getString(
+ R.string.last_sync_timestamp,
+ status.timestamp.formatSyncTimestamp(applicationContext),
+ )
+ }
+ ?: applicationContext.getString(R.string.last_sync_status_na)
+ }
+
+ private fun getProgress(currentSyncJobStatus: CurrentSyncJobStatus): Int? {
+ val inProgressSyncJob =
+ (currentSyncJobStatus as? CurrentSyncJobStatus.Running)?.inProgressSyncJob
+ return (inProgressSyncJob as? SyncJobStatus.InProgress)?.let {
+ ProgressHelper.calculateProgressPercentage(it.total, it.completed)
+ }
+ }
+}
+
+data class PeriodicSyncUiState(
+ val lastSyncStatus: String? = null,
+ val lastSyncTime: String? = null,
+ val currentSyncStatus: String? = null,
+ val progress: Int? = null,
+)
diff --git a/demo/src/main/java/com/google/android/fhir/demo/SyncFragment.kt b/demo/src/main/java/com/google/android/fhir/demo/SyncFragment.kt
new file mode 100644
index 0000000000..4aaafe8950
--- /dev/null
+++ b/demo/src/main/java/com/google/android/fhir/demo/SyncFragment.kt
@@ -0,0 +1,104 @@
+/*
+ * Copyright 2024 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.demo
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.MenuItem
+import android.view.View
+import android.view.ViewGroup
+import android.widget.Button
+import android.widget.ProgressBar
+import android.widget.TextView
+import androidx.appcompat.app.AppCompatActivity
+import androidx.fragment.app.Fragment
+import androidx.fragment.app.viewModels
+import androidx.navigation.fragment.NavHostFragment
+import com.google.android.fhir.demo.extensions.launchAndRepeatStarted
+import com.google.android.fhir.sync.CurrentSyncJobStatus
+
+class SyncFragment : Fragment() {
+ private val syncFragmentViewModel: SyncFragmentViewModel by viewModels()
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?,
+ ): View {
+ return inflater.inflate(R.layout.sync, container, false)
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+ setUpActionBar()
+ setHasOptionsMenu(true)
+ view.findViewById