diff --git a/demo/src/main/java/com/google/android/fhir/demo/AddPatientViewModel.kt b/demo/src/main/java/com/google/android/fhir/demo/AddPatientViewModel.kt index 50447b8c28..8a5c06bc45 100644 --- a/demo/src/main/java/com/google/android/fhir/demo/AddPatientViewModel.kt +++ b/demo/src/main/java/com/google/android/fhir/demo/AddPatientViewModel.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. @@ -27,6 +27,7 @@ import com.google.android.fhir.FhirEngine import com.google.android.fhir.datacapture.mapping.ResourceMapper import com.google.android.fhir.datacapture.validation.Invalid import com.google.android.fhir.datacapture.validation.QuestionnaireResponseValidator +import com.google.android.fhir.demo.extensions.readFileFromAssets import java.util.UUID import kotlinx.coroutines.launch import org.hl7.fhir.r4.model.Patient @@ -91,16 +92,12 @@ class AddPatientViewModel(application: Application, private val state: SavedStat _questionnaireJson?.let { return it } - _questionnaireJson = readFileFromAssets(state[AddPatientFragment.QUESTIONNAIRE_FILE_PATH_KEY]!!) + _questionnaireJson = + getApplication() + .readFileFromAssets(state[AddPatientFragment.QUESTIONNAIRE_FILE_PATH_KEY]!!) return _questionnaireJson!! } - private fun readFileFromAssets(filename: String): String { - return getApplication().assets.open(filename).bufferedReader().use { - it.readText() - } - } - private fun generateUuid(): String { return UUID.randomUUID().toString() } diff --git a/demo/src/main/java/com/google/android/fhir/demo/EditPatientViewModel.kt b/demo/src/main/java/com/google/android/fhir/demo/EditPatientViewModel.kt index 456f480cf9..8e843af315 100644 --- a/demo/src/main/java/com/google/android/fhir/demo/EditPatientViewModel.kt +++ b/demo/src/main/java/com/google/android/fhir/demo/EditPatientViewModel.kt @@ -1,5 +1,5 @@ /* - * Copyright 2021-2023 Google LLC + * Copyright 2021-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,6 +26,7 @@ import ca.uhn.fhir.context.FhirContext import ca.uhn.fhir.context.FhirVersionEnum import com.google.android.fhir.FhirEngine import com.google.android.fhir.datacapture.mapping.ResourceMapper +import com.google.android.fhir.demo.extensions.readFileFromAssets import com.google.android.fhir.get import kotlinx.coroutines.launch import org.hl7.fhir.r4.model.Patient @@ -47,7 +48,10 @@ class EditPatientViewModel(application: Application, private val state: SavedSta private suspend fun prepareEditPatient(): Pair { val patient = fhirEngine.get(patientId) val launchContexts = mapOf("client" to patient) - val question = readFileFromAssets("new-patient-registration-paginated.json").trimIndent() + val question = + getApplication() + .readFileFromAssets("new-patient-registration-paginated.json") + .trimIndent() val parser = FhirContext.forCached(FhirVersionEnum.R4).newJsonParser() val questionnaire = parser.parseResource(Questionnaire::class.java, question) as Questionnaire @@ -101,13 +105,11 @@ class EditPatientViewModel(application: Application, private val state: SavedSta questionnaireJson?.let { return it } - questionnaireJson = readFileFromAssets(state[EditPatientFragment.QUESTIONNAIRE_FILE_PATH_KEY]!!) + questionnaireJson = + getApplication() + .readFileFromAssets( + state[EditPatientFragment.QUESTIONNAIRE_FILE_PATH_KEY]!!, + ) return questionnaireJson!! } - - private fun readFileFromAssets(filename: String): String { - return getApplication().assets.open(filename).bufferedReader().use { - it.readText() - } - } } diff --git a/demo/src/main/java/com/google/android/fhir/demo/MainActivityViewModel.kt b/demo/src/main/java/com/google/android/fhir/demo/MainActivityViewModel.kt index 3410bf0332..e2003ce21b 100644 --- a/demo/src/main/java/com/google/android/fhir/demo/MainActivityViewModel.kt +++ b/demo/src/main/java/com/google/android/fhir/demo/MainActivityViewModel.kt @@ -32,57 +32,44 @@ import com.google.android.fhir.sync.Sync import java.time.OffsetDateTime import java.time.format.DateTimeFormatter import java.util.concurrent.TimeUnit -import kotlinx.coroutines.InternalCoroutinesApi -import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.shareIn -import kotlinx.coroutines.launch /** View model for [MainActivity]. */ -@OptIn(InternalCoroutinesApi::class) +@OptIn(ExperimentalCoroutinesApi::class) class MainActivityViewModel(application: Application) : AndroidViewModel(application) { private val _lastSyncTimestampLiveData = MutableLiveData() val lastSyncTimestampLiveData: LiveData get() = _lastSyncTimestampLiveData - private val _pollState = MutableSharedFlow() - val pollState: Flow - get() = _pollState + private val _oneTimeSyncTrigger = MutableStateFlow(false) - private val _pollPeriodicSyncJobStatus = MutableSharedFlow() - val pollPeriodicSyncJobStatus: Flow - get() = _pollPeriodicSyncJobStatus - - init { - viewModelScope.launch { - Sync.periodicSync( - application.applicationContext, - periodicSyncConfiguration = - PeriodicSyncConfiguration( - syncConstraints = Constraints.Builder().build(), - repeat = RepeatInterval(interval = 15, timeUnit = TimeUnit.MINUTES), - ), - ) - .shareIn(this, SharingStarted.Eagerly, 10) - .collect { _pollPeriodicSyncJobStatus.emit(it) } - } - } + 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 var oneTimeSyncJob: Job? = null + val pollState: SharedFlow = + _oneTimeSyncTrigger + .combine( + flow = Sync.oneTimeSync(context = application.applicationContext), + ) { _, syncJobStatus -> + syncJobStatus + } + .shareIn(viewModelScope, SharingStarted.Eagerly, 0) fun triggerOneTimeSync() { - // Cancels any ongoing sync job before starting a new one. Since this function may be called - // more than once, not canceling the ongoing job could result in the creation of multiple jobs - // that emit the same object. - oneTimeSyncJob?.cancel() - oneTimeSyncJob = - viewModelScope.launch { - Sync.oneTimeSync(getApplication()) - .shareIn(this, SharingStarted.Eagerly, 0) - .collect { result -> result.let { _pollState.emit(it) } } - } + _oneTimeSyncTrigger.value = !_oneTimeSyncTrigger.value } /** Emits last sync time. */ 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 30b37c4bae..080dba3579 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 @@ -37,7 +37,6 @@ import androidx.appcompat.widget.SearchView import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels import androidx.lifecycle.ViewModelProvider -import androidx.lifecycle.lifecycleScope import androidx.navigation.fragment.NavHostFragment import androidx.navigation.fragment.findNavController import androidx.recyclerview.widget.DividerItemDecoration @@ -45,11 +44,12 @@ 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 kotlinx.coroutines.launch import timber.log.Timber class PatientListFragment : Fragment() { @@ -154,75 +154,73 @@ class PatientListFragment : Fragment() { } setHasOptionsMenu(true) (activity as MainActivity).setDrawerEnabled(false) + launchAndRepeatStarted( + { mainActivityViewModel.pollState.collect(::currentSyncJobStatus) }, + { mainActivityViewModel.pollPeriodicSyncJobStatus.collect(::periodicSyncJobStatus) }, + ) + } - lifecycleScope.launch { - mainActivityViewModel.pollState.collect { - Timber.d("onViewCreated: pollState Got status $it") - when (it) { - is CurrentSyncJobStatus.Running -> { - Timber.i("Sync: ${it::class.java.simpleName} with data ${it.inProgressSyncJob}") - fadeInTopBanner(it) - } - is CurrentSyncJobStatus.Succeeded -> { - Timber.i("Sync: ${it::class.java.simpleName} at ${it.timestamp}") - patientListViewModel.searchPatientsByName(searchView.query.toString().trim()) - mainActivityViewModel.updateLastSyncTimestamp(it.timestamp) - fadeOutTopBanner(it) - } - is CurrentSyncJobStatus.Failed -> { - Timber.i("Sync: ${it::class.java.simpleName} at ${it.timestamp}") - patientListViewModel.searchPatientsByName(searchView.query.toString().trim()) - mainActivityViewModel.updateLastSyncTimestamp(it.timestamp) - fadeOutTopBanner(it) - } - is CurrentSyncJobStatus.Enqueued -> { - Timber.i("Sync: Enqueued") - patientListViewModel.searchPatientsByName(searchView.query.toString().trim()) - fadeOutTopBanner(it) - } - CurrentSyncJobStatus.Cancelled -> TODO() - } + 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) + } + CurrentSyncJobStatus.Cancelled -> TODO() } + } - lifecycleScope.launch { - mainActivityViewModel.pollPeriodicSyncJobStatus.collect { - Timber.d("onViewCreated: pollState Got status ${it.currentSyncJobStatus}") - when (it.currentSyncJobStatus) { - is CurrentSyncJobStatus.Running -> { - Timber.i( - "Sync: ${it.currentSyncJobStatus::class.java.simpleName} with data ${it.currentSyncJobStatus}", - ) - fadeInTopBanner(it.currentSyncJobStatus) - } - is CurrentSyncJobStatus.Succeeded -> { - val lastSyncTimestamp = - (it.currentSyncJobStatus as CurrentSyncJobStatus.Succeeded).timestamp - Timber.i( - "Sync: ${it.currentSyncJobStatus::class.java.simpleName} at $lastSyncTimestamp", - ) - patientListViewModel.searchPatientsByName(searchView.query.toString().trim()) - mainActivityViewModel.updateLastSyncTimestamp(lastSyncTimestamp) - fadeOutTopBanner(it.currentSyncJobStatus) - } - is CurrentSyncJobStatus.Failed -> { - val lastSyncTimestamp = - (it.currentSyncJobStatus as CurrentSyncJobStatus.Failed).timestamp - Timber.i( - "Sync: ${it.currentSyncJobStatus::class.java.simpleName} at $lastSyncTimestamp}", - ) - patientListViewModel.searchPatientsByName(searchView.query.toString().trim()) - mainActivityViewModel.updateLastSyncTimestamp(lastSyncTimestamp) - fadeOutTopBanner(it.currentSyncJobStatus) - } - is CurrentSyncJobStatus.Enqueued -> { - Timber.i("Sync: Enqueued") - patientListViewModel.searchPatientsByName(searchView.query.toString().trim()) - fadeOutTopBanner(it.currentSyncJobStatus) - } - CurrentSyncJobStatus.Cancelled -> TODO() - } + 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) } + CurrentSyncJobStatus.Cancelled -> TODO() } } diff --git a/demo/src/main/java/com/google/android/fhir/demo/ScreenerViewModel.kt b/demo/src/main/java/com/google/android/fhir/demo/ScreenerViewModel.kt index b2aa0f7335..d7228bbcad 100644 --- a/demo/src/main/java/com/google/android/fhir/demo/ScreenerViewModel.kt +++ b/demo/src/main/java/com/google/android/fhir/demo/ScreenerViewModel.kt @@ -1,5 +1,5 @@ /* - * Copyright 2021-2023 Google LLC + * Copyright 2021-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. @@ -25,6 +25,7 @@ import ca.uhn.fhir.context.FhirContext import ca.uhn.fhir.context.FhirVersionEnum import com.google.android.fhir.FhirEngine import com.google.android.fhir.datacapture.mapping.ResourceMapper +import com.google.android.fhir.demo.extensions.readFileFromAssets import java.math.BigDecimal import java.util.UUID import kotlinx.coroutines.launch @@ -113,8 +114,7 @@ class ScreenerViewModel(application: Application, private val state: SavedStateH private fun isRequiredFieldMissing(bundle: Bundle): Boolean { bundle.entry.forEach { - val resource = it.resource - when (resource) { + when (val resource = it.resource) { is Observation -> { if (resource.hasValueQuantity() && !resource.valueQuantity.hasValueElement()) { return true @@ -132,18 +132,14 @@ class ScreenerViewModel(application: Application, private val state: SavedStateH private fun getQuestionnaireJson(): String { questionnaireJson?.let { - return it!! + return it } - questionnaireJson = readFileFromAssets(state[ScreenerFragment.QUESTIONNAIRE_FILE_PATH_KEY]!!) + questionnaireJson = + getApplication() + .readFileFromAssets(state[ScreenerFragment.QUESTIONNAIRE_FILE_PATH_KEY]!!) return questionnaireJson!! } - private fun readFileFromAssets(filename: String): String { - return getApplication().assets.open(filename).bufferedReader().use { - it.readText() - } - } - private fun generateUuid(): String { return UUID.randomUUID().toString() } @@ -217,8 +213,7 @@ class ScreenerViewModel(application: Application, private val state: SavedStateH .filter { it.hasCode() && it.code.hasCoding() } .flatMap { it.code.coding } .map { it.code } - .filter { isSymptomPresent(it) } - .count() + .count { isSymptomPresent(it) } return count > 0 } @@ -234,8 +229,7 @@ class ScreenerViewModel(application: Application, private val state: SavedStateH .filter { it.hasCode() && it.code.hasCoding() } .flatMap { it.code.coding } .map { it.code } - .filter { isComorbidityPresent(it) } - .count() + .count { isComorbidityPresent(it) } return count > 0 } diff --git a/demo/src/main/java/com/google/android/fhir/demo/extensions/AssetsExt.kt b/demo/src/main/java/com/google/android/fhir/demo/extensions/AssetsExt.kt new file mode 100644 index 0000000000..0754188f94 --- /dev/null +++ b/demo/src/main/java/com/google/android/fhir/demo/extensions/AssetsExt.kt @@ -0,0 +1,22 @@ +/* + * 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. + * 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.extensions + +import android.content.Context + +fun Context.readFileFromAssets(fileName: String): String = + assets.open(fileName).bufferedReader().use { it.readText() } diff --git a/demo/src/main/java/com/google/android/fhir/demo/extensions/LifeCycleExt.kt b/demo/src/main/java/com/google/android/fhir/demo/extensions/LifeCycleExt.kt new file mode 100644 index 0000000000..437fcb9ac3 --- /dev/null +++ b/demo/src/main/java/com/google/android/fhir/demo/extensions/LifeCycleExt.kt @@ -0,0 +1,35 @@ +/* + * 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. + * 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.extensions + +import androidx.fragment.app.Fragment +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import kotlinx.coroutines.launch + +fun Fragment.launchAndRepeatStarted( + vararg launchBlock: suspend () -> Unit, + doAfterLaunch: (() -> Unit)? = null, +) { + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + launchBlock.forEach { launch { it.invoke() } } + doAfterLaunch?.invoke() + } + } +}