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 7b1e92f953..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" } 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/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 b6c357d107..b84a625a10 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, + ) } } } @@ -407,6 +419,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. */ @@ -509,6 +526,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/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/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/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