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