From 4e2f7cde0b3fed21f86228f33eeacc4ebbff6cbd Mon Sep 17 00:00:00 2001 From: Comfort Mwalija Date: Tue, 26 Nov 2024 11:26:58 +0200 Subject: [PATCH] Performance improvements for large facilities :v2 -> Downloading resources using FHIR SDK [org.smartregister.fhircore.engine.data.remote.resource.syncStrategy.fhir.LogicalIdSyncParamsBased] --- android/engine/src/main/AndroidManifest.xml | 2 + .../engine/configuration/Configuration.kt | 10 + .../configuration/ConfigurationRegistry.kt | 3 + .../engine/data/local/TingatheDatabase.kt | 6 +- .../syncStrategy/SyncStrategyCacheDao.kt | 34 +++ .../syncStrategy/SyncStrategyCacheEntity.kt | 29 ++ .../syncStrategy/ApiRepositoryImpl.kt | 76 +++--- .../resource/syncStrategy/ListResourceExt.kt | 88 ++----- .../remote/resource/syncStrategy/Utils.kt | 93 +++++++ .../broadcast/SyncStatusBroadcastReceiver.kt | 36 +++ .../syncStrategy/fhir/IdentifierSyncParams.kt | 93 +++++++ .../fhir/LogicalIdSyncParamsBased.kt | 102 ++++++++ .../syncStrategy/fhir/ParamSyncStatus.kt | 25 ++ .../resource/syncStrategy/utils/SyncState.kt | 2 +- .../fhircore/engine/sync/AppSyncWorker.kt | 79 ++++-- .../engine/util/SharedPreferenceKey.kt | 2 + android/quest/build.gradle.kts | 4 +- .../fhircore/quest/ui/main/AppMainActivity.kt | 51 +++- .../fhircore/quest/ui/main/AppMainScreen.kt | 83 +++++- .../patient/register/PatientRegisterScreen.kt | 208 --------------- .../register/PatientRegisterViewModel.kt | 247 +++--------------- .../register/dialog/AttentionDialog.kt | 77 ++++++ .../patient/register/dialog/ProgressDialog.kt | 83 ++++++ .../register/dialog/SyncCompleteDialog.kt | 65 +++++ 24 files changed, 936 insertions(+), 562 deletions(-) create mode 100644 android/engine/src/main/java/org/smartregister/fhircore/engine/data/local/syncStrategy/SyncStrategyCacheDao.kt create mode 100644 android/engine/src/main/java/org/smartregister/fhircore/engine/data/local/syncStrategy/SyncStrategyCacheEntity.kt create mode 100644 android/engine/src/main/java/org/smartregister/fhircore/engine/data/remote/resource/syncStrategy/Utils.kt create mode 100644 android/engine/src/main/java/org/smartregister/fhircore/engine/data/remote/resource/syncStrategy/broadcast/SyncStatusBroadcastReceiver.kt create mode 100644 android/engine/src/main/java/org/smartregister/fhircore/engine/data/remote/resource/syncStrategy/fhir/IdentifierSyncParams.kt create mode 100644 android/engine/src/main/java/org/smartregister/fhircore/engine/data/remote/resource/syncStrategy/fhir/LogicalIdSyncParamsBased.kt create mode 100644 android/engine/src/main/java/org/smartregister/fhircore/engine/data/remote/resource/syncStrategy/fhir/ParamSyncStatus.kt create mode 100644 android/quest/src/main/java/org/smartregister/fhircore/quest/ui/patient/register/dialog/AttentionDialog.kt create mode 100644 android/quest/src/main/java/org/smartregister/fhircore/quest/ui/patient/register/dialog/ProgressDialog.kt create mode 100644 android/quest/src/main/java/org/smartregister/fhircore/quest/ui/patient/register/dialog/SyncCompleteDialog.kt diff --git a/android/engine/src/main/AndroidManifest.xml b/android/engine/src/main/AndroidManifest.xml index e101670a7d..cb6d1b88c0 100644 --- a/android/engine/src/main/AndroidManifest.xml +++ b/android/engine/src/main/AndroidManifest.xml @@ -49,5 +49,7 @@ tools:node="remove" /> + + diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/Configuration.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/Configuration.kt index 411559b9a4..9ea51609ed 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/Configuration.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/Configuration.kt @@ -43,6 +43,7 @@ data class AppConfiguration( val appFeatures: AppFeatureConfig, val syncConfig: SyncConfig, val formConfigs: List = listOf(), + val organizationSyncConfig: OrganizationSyncConfig, ) @Serializable @@ -66,3 +67,12 @@ data class Resource( data class Parameter( @SerializedName("resource") var resource: Resource, ) + +@Serializable data class OrganizationSyncConfig(@SerializedName("items") val items: List) + +@Serializable +data class Item( + @SerializedName("id") val id: String, + @SerializedName("name") val name: String, + @SerializedName("offline_first") val offlineFirst: Boolean, +) diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/ConfigurationRegistry.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/ConfigurationRegistry.kt index 0244e166a0..095d9fb832 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/ConfigurationRegistry.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/ConfigurationRegistry.kt @@ -77,6 +77,9 @@ constructor( fun getFormConfigs(): List? = applicationConfiguration.value?.formConfigs + fun getPerOrgSyncConfigs(): OrganizationSyncConfig? = + applicationConfiguration.value?.organizationSyncConfig + private suspend fun getBinary(id: String): Binary = fhirEngine.get(id) companion object { diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/data/local/TingatheDatabase.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/data/local/TingatheDatabase.kt index c6f355033d..5704c7c139 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/data/local/TingatheDatabase.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/data/local/TingatheDatabase.kt @@ -24,19 +24,23 @@ import org.smartregister.fhircore.engine.data.local.localChange.LocalChangeDao import org.smartregister.fhircore.engine.data.local.localChange.LocalChangeEntity import org.smartregister.fhircore.engine.data.local.syncAttempt.SyncAttemptTrackerDao import org.smartregister.fhircore.engine.data.local.syncAttempt.SyncAttemptTrackerEntity +import org.smartregister.fhircore.engine.data.local.syncStrategy.SyncStrategyCacheDao +import org.smartregister.fhircore.engine.data.local.syncStrategy.SyncStrategyCacheEntity @Database( - version = 2, + version = 3, entities = [ LocalChangeEntity::class, SyncAttemptTrackerEntity::class, + SyncStrategyCacheEntity::class, ], ) abstract class TingatheDatabase : RoomDatabase() { abstract val localChangeDao: LocalChangeDao abstract val syncAttemptTrackerDao: SyncAttemptTrackerDao + abstract val syncStrategyCacheDao: SyncStrategyCacheDao companion object { fun databaseBuilder(context: Context): Builder { diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/data/local/syncStrategy/SyncStrategyCacheDao.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/data/local/syncStrategy/SyncStrategyCacheDao.kt new file mode 100644 index 0000000000..e0f25ba0fc --- /dev/null +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/data/local/syncStrategy/SyncStrategyCacheDao.kt @@ -0,0 +1,34 @@ +/* + * Copyright 2021 Ona Systems, Inc + * + * 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 org.smartregister.fhircore.engine.data.local.syncStrategy + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query + +@Dao +abstract class SyncStrategyCacheDao { + + @Insert(onConflict = OnConflictStrategy.REPLACE) + abstract suspend fun upsert(syncStrategyCacheEntity: List) + + @Query("DELETE FROM syncstrategycacheentity") abstract suspend fun deleteAll() + + @Query("SELECT * FROM syncstrategycacheentity") + abstract suspend fun query(): List +} diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/data/local/syncStrategy/SyncStrategyCacheEntity.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/data/local/syncStrategy/SyncStrategyCacheEntity.kt new file mode 100644 index 0000000000..a2ebdb8592 --- /dev/null +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/data/local/syncStrategy/SyncStrategyCacheEntity.kt @@ -0,0 +1,29 @@ +/* + * Copyright 2021 Ona Systems, Inc + * + * 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 org.smartregister.fhircore.engine.data.local.syncStrategy + +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity +data class SyncStrategyCacheEntity( + @PrimaryKey val logicalId: String, + val shouldSync: Boolean = false, + val timestamp: Long = System.currentTimeMillis(), +) + +fun List.toEntity() = map { SyncStrategyCacheEntity(logicalId = it) } diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/data/remote/resource/syncStrategy/ApiRepositoryImpl.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/data/remote/resource/syncStrategy/ApiRepositoryImpl.kt index 775ed662b1..f12b0928e2 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/data/remote/resource/syncStrategy/ApiRepositoryImpl.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/data/remote/resource/syncStrategy/ApiRepositoryImpl.kt @@ -36,6 +36,8 @@ import org.hl7.fhir.r4.model.ResourceType import org.smartregister.fhircore.engine.R import org.smartregister.fhircore.engine.data.remote.fhir.resource.FhirApiService import org.smartregister.fhircore.engine.data.remote.fhir.resource.FhirResourceService +import org.smartregister.fhircore.engine.data.remote.resource.syncStrategy.ApiRepositoryImpl.Companion.SYNC_TIMESTAMP_INPUT_FORMAT +import org.smartregister.fhircore.engine.data.remote.resource.syncStrategy.ApiRepositoryImpl.OfType import org.smartregister.fhircore.engine.data.remote.resource.syncStrategy.repository.ApiRepository import org.smartregister.fhircore.engine.data.remote.resource.syncStrategy.utils.Progress import org.smartregister.fhircore.engine.data.remote.resource.syncStrategy.utils.SearchBy @@ -160,7 +162,7 @@ class ApiRepositoryImpl( .onFailure { Timber.e(it) } progressStatus(Progress(index, patientSize, logicalId)) } - saveLastUpdatedTimestamp() + saveLastUpdatedTimestamp(appDataStore) onCompleteListener(runSync, patientSize) } @@ -170,7 +172,7 @@ class ApiRepositoryImpl( .map { it.resource } .lastOrNull() - private enum class OfType { + enum class OfType { Patient, Encounter, Observation, @@ -183,44 +185,44 @@ class ApiRepositoryImpl( Appointment, } - private suspend fun saveLastUpdatedTimestamp() { - OfType.entries - .map { - when (it) { - OfType.Patient -> ResourceType.Patient - OfType.Observation -> ResourceType.Observation - OfType.CarePlan -> ResourceType.CarePlan - OfType.Task -> ResourceType.Task - OfType.Condition -> ResourceType.Condition - OfType.Appointment -> ResourceType.Appointment - OfType.Encounter -> ResourceType.Encounter - OfType.List -> ResourceType.List - OfType.Practitioner -> ResourceType.Practitioner - OfType.RelatedPerson -> ResourceType.RelatedPerson - } - } - .onEach { resourceType -> - val lastSyncTimestamp = Date().toOffsetDateTime().formatLastSyncTimestamp() - appDataStore.saveLastUpdatedTimestamp(resourceType, lastSyncTimestamp) - } + companion object { + const val SYNC_TIMESTAMP_INPUT_FORMAT = "yyyy-MM-dd'T'HH:mm:ss" } +} - private fun Date.toOffsetDateTime(): OffsetDateTime { - return OffsetDateTime.ofInstant(toInstant(), ZoneId.systemDefault()) - } +fun simpleDateFormat() = SimpleDateFormat(SYNC_TIMESTAMP_INPUT_FORMAT, Locale.getDefault()) - private fun OffsetDateTime.formatLastSyncTimestamp(): String { - val syncTimestampFormatter = - SimpleDateFormat(SYNC_TIMESTAMP_INPUT_FORMAT, Locale.getDefault()).apply { - timeZone = TimeZone.getDefault() - } - val parse: Date? = syncTimestampFormatter.parse(toString()) - return if (parse == null) "" else simpleDateFormat.format(parse) - } +fun Date.toOffsetDateTime(): OffsetDateTime { + return OffsetDateTime.ofInstant(toInstant(), ZoneId.systemDefault()) +} - companion object { - const val SYNC_TIMESTAMP_INPUT_FORMAT = "yyyy-MM-dd'T'HH:mm:ss" - } +private fun OffsetDateTime.formatLastSyncTimestamp(): String { + val syncTimestampFormatter = + SimpleDateFormat(SYNC_TIMESTAMP_INPUT_FORMAT, Locale.getDefault()).apply { + timeZone = TimeZone.getDefault() + } + val parse: Date? = syncTimestampFormatter.parse(toString()) + return if (parse == null) "" else simpleDateFormat().format(parse) +} - private val simpleDateFormat = SimpleDateFormat(SYNC_TIMESTAMP_INPUT_FORMAT, Locale.getDefault()) +suspend fun saveLastUpdatedTimestamp(appDataStore: AppDataStore) { + OfType.entries + .map { + when (it) { + OfType.Patient -> ResourceType.Patient + OfType.Observation -> ResourceType.Observation + OfType.CarePlan -> ResourceType.CarePlan + OfType.Task -> ResourceType.Task + OfType.Condition -> ResourceType.Condition + OfType.Appointment -> ResourceType.Appointment + OfType.Encounter -> ResourceType.Encounter + OfType.List -> ResourceType.List + OfType.Practitioner -> ResourceType.Practitioner + OfType.RelatedPerson -> ResourceType.RelatedPerson + } + } + .onEach { resourceType -> + val lastSyncTimestamp = Date().toOffsetDateTime().formatLastSyncTimestamp() + appDataStore.saveLastUpdatedTimestamp(resourceType, lastSyncTimestamp) + } } diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/data/remote/resource/syncStrategy/ListResourceExt.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/data/remote/resource/syncStrategy/ListResourceExt.kt index 04c25d5a94..34aff38c9b 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/data/remote/resource/syncStrategy/ListResourceExt.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/data/remote/resource/syncStrategy/ListResourceExt.kt @@ -17,75 +17,29 @@ package org.smartregister.fhircore.engine.data.remote.resource.syncStrategy import com.google.android.fhir.FhirEngine -import com.google.android.fhir.db.ResourceNotFoundException import com.google.android.fhir.search.Search import org.hl7.fhir.r4.model.ListResource import org.hl7.fhir.r4.model.ResourceType -import org.smartregister.fhircore.engine.data.remote.resource.syncStrategy.utils.Progress -import org.smartregister.fhircore.engine.data.remote.resource.syncStrategy.utils.SearchBy -import org.smartregister.fhircore.engine.util.SharedPreferenceKey.PATIENT_IDENTIFIER_LIST_TIMESTAMP -import org.smartregister.fhircore.engine.util.SharedPreferencesHelper -import timber.log.Timber -class ListResourceExt( - private val fhirEngine: FhirEngine, - private val sharedPreferences: SharedPreferencesHelper, - private val apiRepository: ApiRepositoryImpl, -) { - - private var invokeCounter = 0 - - suspend fun onListResourceExt( - onProgress: (Progress) -> Unit, - ): Boolean { - var shouldRunSync = false - var size = 0 - shouldRun() - ?.filterNotNull() - ?.also { size = it.size } - ?.forEachIndexed { index, identifier -> - apiRepository.search(identifier.toString(), SearchBy.IDENTIFIER).onEach { patient -> - kotlin - .runCatching { fhirEngine.get(patient.resourceType, patient.idPart) } - .onFailure { throwable -> - if (throwable is ResourceNotFoundException) { - apiRepository.fetchAndSaveToDb( - logicalId = patient.idPart, - onCompleteListener = { shouldRunSync = it }, - ) - } - } - .onSuccess { Timber.e("Skipping -> ${it.resourceType} - ${it.idPart}") } - } - onProgress(Progress(index, size, identifier.toString())) - } - - return shouldRunSync - } - - private suspend fun get() = - fhirEngine - .search( - Search(ResourceType.List).apply { - filter(ListResource.TITLE, { value = "Patient Identifier List" }) - }, - ) - .map { it.resource } - .firstOrNull() - - suspend fun shouldRun(): List? { - invokeCounter += 1 - if (invokeCounter == 1) { - val listResource: ListResource = get() ?: return null - val preferenceKey = PATIENT_IDENTIFIER_LIST_TIMESTAMP.name - val oldTimestamp = sharedPreferences.read(preferenceKey, 0L) - val currentTimestamp = listResource.meta.lastUpdated.time - if (oldTimestamp == 0L || oldTimestamp > currentTimestamp) { - sharedPreferences.write(preferenceKey, currentTimestamp) - return listResource.entry.map { entry -> entry.item.display.toIntOrNull() }.toList() - } - return null - } - return null - } +suspend fun getListResource(fhirEngine: FhirEngine) = + fhirEngine + .search( + Search(ResourceType.List).apply { + filter(ListResource.TITLE, { value = "Patient Identifier List" }) + }, + ) + .map { it.resource } + .firstOrNull() + +suspend fun getIdentifiers(fhirEngine: FhirEngine): ListResourceItem? { + val listResource: ListResource = getListResource(fhirEngine) ?: return null + return ListResourceItem( + data = listResource.entry.mapNotNull { entry -> entry.item.display.toInt() }.toList(), + idPart = listResource.idPart, + ) } + +data class ListResourceItem( + val data: List, + val idPart: String, +) diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/data/remote/resource/syncStrategy/Utils.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/data/remote/resource/syncStrategy/Utils.kt new file mode 100644 index 0000000000..c2298ce9b7 --- /dev/null +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/data/remote/resource/syncStrategy/Utils.kt @@ -0,0 +1,93 @@ +/* + * Copyright 2021 Ona Systems, Inc + * + * 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 org.smartregister.fhircore.engine.data.remote.resource.syncStrategy + +import com.google.android.fhir.FhirEngine +import com.google.android.fhir.search.search +import java.util.concurrent.TimeUnit +import kotlinx.coroutines.runBlocking +import org.hl7.fhir.r4.model.ListResource +import org.hl7.fhir.r4.model.Patient +import org.smartregister.fhircore.engine.configuration.ConfigurationRegistry +import org.smartregister.fhircore.engine.configuration.Item +import org.smartregister.fhircore.engine.data.local.syncStrategy.SyncStrategyCacheDao +import org.smartregister.fhircore.engine.data.remote.resource.syncStrategy.utils.SyncState +import org.smartregister.fhircore.engine.util.SharedPreferenceKey +import org.smartregister.fhircore.engine.util.SharedPreferenceKey.SYNC_STATUS +import org.smartregister.fhircore.engine.util.SharedPreferencesHelper + +fun logicalIds(fhirEngine: FhirEngine) = runBlocking { + fhirEngine + .search { filter(Patient.ACTIVE, { value = of(true) }) } + .map { it.resource.idPart } +} + +fun List.subListIds(syncStrategyCacheDao: SyncStrategyCacheDao): List { + return runBlocking { + val cachedIds = syncStrategyCacheDao.query().map { it.logicalId } + ((cachedIds union this@subListIds) - (cachedIds intersect this@subListIds.toSet())).toList() + } +} + +fun hasCompletedInitialSync(sharedPreferencesHelper: SharedPreferencesHelper) = + getSyncState(sharedPreferencesHelper) < SyncState.CompletedInitialSync.value + +fun setSubSequentSync(sharedPreferencesHelper: SharedPreferencesHelper) = + sharedPreferencesHelper.write(SYNC_STATUS.name, SyncState.SubSequentSync.value) + +fun isSubSequentSync(sharedPreferencesHelper: SharedPreferencesHelper) = + getSyncState(sharedPreferencesHelper) == SyncState.SubSequentSync.value + +fun isRunSyncNow(sharedPreferencesHelper: SharedPreferencesHelper) = + getSyncState(sharedPreferencesHelper) == SyncState.RunSyncNow.value + +fun getSyncState(sharedPreferencesHelper: SharedPreferencesHelper) = + sharedPreferencesHelper.read(SYNC_STATUS.name, SyncState.InitialSync.value) + +fun Long.before1min(): Boolean { + val currentTimeMillis = System.currentTimeMillis() + val diffMillis = currentTimeMillis - this + return TimeUnit.MILLISECONDS.toMinutes(diffMillis) < 1 +} + +fun String.splitIdTimestamp() = split("|") + +fun toIdTimestamp(sharedPreferencesHelper: SharedPreferencesHelper): IdTimestamp? = + sharedPreferencesHelper + .read(SharedPreferenceKey.SEARCH_PATIENT_ID_TIMESTAMP.name, null) + ?.splitIdTimestamp() + ?.map { IdTimestamp(it.first().toString(), it.last().toString().toLong()) } + ?.firstOrNull() + +suspend fun getIdentifier(fhirEngine: FhirEngine) = + fhirEngine + .search { filter(ListResource.TITLE, { value = "Patient Identifier List" }) } + .map { it.resource } + .firstOrNull() + +data class IdTimestamp( + val logicalId: String, + val timestamp: Long, +) + +fun perOrgSyncConfig( + configurationRegistry: ConfigurationRegistry, + sharedPreferencesHelper: SharedPreferencesHelper, +): Item? = + configurationRegistry.getPerOrgSyncConfigs()?.items?.find { + it.id == sharedPreferencesHelper.organisationCode() + } diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/data/remote/resource/syncStrategy/broadcast/SyncStatusBroadcastReceiver.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/data/remote/resource/syncStrategy/broadcast/SyncStatusBroadcastReceiver.kt new file mode 100644 index 0000000000..e9a5ef3d31 --- /dev/null +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/data/remote/resource/syncStrategy/broadcast/SyncStatusBroadcastReceiver.kt @@ -0,0 +1,36 @@ +/* + * Copyright 2021 Ona Systems, Inc + * + * 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 org.smartregister.fhircore.engine.data.remote.resource.syncStrategy.broadcast + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import org.smartregister.fhircore.engine.data.remote.resource.syncStrategy.fhir.ParamSyncStatus + +const val SYNC_STATUS_BROADCAST_RECEIVER_KEY = "SYNC_STATUS_BROADCAST_RECEIVER_KEY" + +@Suppress("DEPRECATION") +class SyncStatusBroadcastReceiver( + val onReceiveCallback: (paramSyncStatus: ParamSyncStatus) -> Unit, +) : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + if (intent == null) return + val paramSyncStatus = + intent.getSerializableExtra(SYNC_STATUS_BROADCAST_RECEIVER_KEY) as? ParamSyncStatus ?: return + onReceiveCallback(paramSyncStatus) + } +} diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/data/remote/resource/syncStrategy/fhir/IdentifierSyncParams.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/data/remote/resource/syncStrategy/fhir/IdentifierSyncParams.kt new file mode 100644 index 0000000000..21bf958d6c --- /dev/null +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/data/remote/resource/syncStrategy/fhir/IdentifierSyncParams.kt @@ -0,0 +1,93 @@ +/* + * Copyright 2021 Ona Systems, Inc + * + * 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 org.smartregister.fhircore.engine.data.remote.resource.syncStrategy.fhir + +import com.google.android.fhir.sync.DownloadWorkManager +import com.google.android.fhir.sync.download.DownloadRequest +import java.util.LinkedList +import org.hl7.fhir.exceptions.FHIRException +import org.hl7.fhir.r4.model.Bundle +import org.hl7.fhir.r4.model.OperationOutcome +import org.hl7.fhir.r4.model.Resource +import org.hl7.fhir.r4.model.ResourceType + +class IdentifierSyncParams( + private val identifiers: List, + private val callback: (ParamSyncStatus) -> Unit, +) : DownloadWorkManager { + + private val urlOfTheNextPagesToDownloadForAResource = LinkedList() + private val resourcesToDownloadWithSearchParams = LinkedList(identifiers.chunked(12)) + private var patientPosition = 0 + + override suspend fun getNextRequest(): DownloadRequest? { + if (urlOfTheNextPagesToDownloadForAResource.isNotEmpty()) { + return urlOfTheNextPagesToDownloadForAResource.poll()?.let { DownloadRequest.of(it) } + } + + return resourcesToDownloadWithSearchParams.poll()?.let { ids -> + DownloadRequest.of(bundle = ids.bundleOf().also { patientPosition += ids.size }) + } + } + + override suspend fun processResponse(response: Resource): Collection { + if (response is OperationOutcome) { + throw FHIRException(response.issueFirstRep.diagnostics) + } + + if ((response !is Bundle || response.type != Bundle.BundleType.BATCHRESPONSE)) { + return emptyList() + } + + response.link + .firstOrNull { component -> component.relation == "next" } + ?.url + ?.let { next -> urlOfTheNextPagesToDownloadForAResource.add(next) } + + return response.entry + .mapNotNull { it.resource as Bundle } + .map { it.entry.map { it.resource } } + .flatten() + .also { catchIds() } + } + + private fun catchIds() = + callback(ParamSyncStatus(identifiers.map { it.toString() }, identifiers.size, patientPosition)) + + private fun List.bundleOf(): Bundle { + return Bundle().apply { + type = Bundle.BundleType.BATCH + entry = bundleEntryComponent() + } + } + + private fun List.bundleEntryComponent(): List { + return flatMap { + listOf("Patient?identifier=$it").map { url -> + Bundle.BundleEntryComponent().apply { + request = + Bundle.BundleEntryRequestComponent().apply { + method = Bundle.HTTPVerb.GET + this.url = url + } + } + } + } + } + + override suspend fun getSummaryRequestUrls() = mapOf() +} diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/data/remote/resource/syncStrategy/fhir/LogicalIdSyncParamsBased.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/data/remote/resource/syncStrategy/fhir/LogicalIdSyncParamsBased.kt new file mode 100644 index 0000000000..11f07c7427 --- /dev/null +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/data/remote/resource/syncStrategy/fhir/LogicalIdSyncParamsBased.kt @@ -0,0 +1,102 @@ +/* + * Copyright 2021 Ona Systems, Inc + * + * 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 org.smartregister.fhircore.engine.data.remote.resource.syncStrategy.fhir + +import com.google.android.fhir.sync.DownloadWorkManager +import com.google.android.fhir.sync.download.DownloadRequest +import java.util.LinkedList +import org.hl7.fhir.exceptions.FHIRException +import org.hl7.fhir.r4.model.Bundle +import org.hl7.fhir.r4.model.OperationOutcome +import org.hl7.fhir.r4.model.Resource +import org.hl7.fhir.r4.model.ResourceType + +class LogicalIdSyncParamsBased( + private val logicalIds: List, + private val callback: (ParamSyncStatus) -> Unit, +) : DownloadWorkManager { + + private val urlOfTheNextPagesToDownloadForAResource = LinkedList() + private val resourcesToDownloadWithSearchParams = LinkedList(logicalIds.chunked(12)) + private var patientPosition = 0 + + override suspend fun getNextRequest(): DownloadRequest? { + if (urlOfTheNextPagesToDownloadForAResource.isNotEmpty()) { + return urlOfTheNextPagesToDownloadForAResource.poll()?.let { DownloadRequest.of(it) } + } + + return resourcesToDownloadWithSearchParams.poll()?.let { ids -> + DownloadRequest.of(bundle = ids.bundleOf().also { patientPosition += ids.size }) + } + } + + override suspend fun processResponse(response: Resource): Collection { + if (response is OperationOutcome) { + throw FHIRException(response.issueFirstRep.diagnostics) + } + + if ((response !is Bundle || response.type != Bundle.BundleType.BATCHRESPONSE)) { + return emptyList() + } + + response.link + .firstOrNull { component -> component.relation == "next" } + ?.url + ?.let { next -> urlOfTheNextPagesToDownloadForAResource.add(next) } + + return response.entry + .mapNotNull { it.resource as Bundle } + .map { it.entry.map { it.resource } } + .flatten() + .also { catchIds() } + } + + private fun catchIds() = callback(ParamSyncStatus(logicalIds, logicalIds.size, patientPosition)) + + private fun List.bundleOf(): Bundle { + return Bundle().apply { + type = Bundle.BundleType.BATCH + entry = bundleEntryComponent() + } + } + + private fun List.bundleEntryComponent(): List { + return flatMap { + listOf( + "Patient?_id=$it&_include=Patient:link&_include=Patient:general-practitioner", + "Observation?subject=$it&status=preliminary", + "CarePlan?subject=$it&_count=1&status=completed&_sort=-_lastUpdated", + "CarePlan?subject=$it&status=active,on-hold&_revinclude=Task:based-on", + "Task?patient=$it&_count=100000", + "Condition?subject=$it&clinical-status=active", + "Appointment?actor=$it&status=waitlist,booked,noshow", + "List?subject=$it&status=current", + ) + .map { url -> + Bundle.BundleEntryComponent().apply { + request = + Bundle.BundleEntryRequestComponent().apply { + method = Bundle.HTTPVerb.GET + this.url = url + } + } + } + } + } + + override suspend fun getSummaryRequestUrls() = mapOf() +} diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/data/remote/resource/syncStrategy/fhir/ParamSyncStatus.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/data/remote/resource/syncStrategy/fhir/ParamSyncStatus.kt new file mode 100644 index 0000000000..b3006e4da3 --- /dev/null +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/data/remote/resource/syncStrategy/fhir/ParamSyncStatus.kt @@ -0,0 +1,25 @@ +/* + * Copyright 2021 Ona Systems, Inc + * + * 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 org.smartregister.fhircore.engine.data.remote.resource.syncStrategy.fhir + +import java.io.Serializable + +data class ParamSyncStatus( + val logicalId: List, + val idsTotal: Int, + val patientPositionAt: Int, +) : Serializable diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/data/remote/resource/syncStrategy/utils/SyncState.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/data/remote/resource/syncStrategy/utils/SyncState.kt index 0a72b8a935..e08bec6dce 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/data/remote/resource/syncStrategy/utils/SyncState.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/data/remote/resource/syncStrategy/utils/SyncState.kt @@ -17,9 +17,9 @@ package org.smartregister.fhircore.engine.data.remote.resource.syncStrategy.utils enum class SyncState(val value: Long) { - ShowDialog(value = 0), InitialSync(value = 1), CompletedInitialSync(value = 2), SubSequentSync(value = 3), SubSequentRunSync(value = 4), + RunSyncNow(value = 5), } diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/sync/AppSyncWorker.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/sync/AppSyncWorker.kt index 3fb60ddca4..5914c43e14 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/sync/AppSyncWorker.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/sync/AppSyncWorker.kt @@ -17,6 +17,7 @@ package org.smartregister.fhircore.engine.sync import android.content.Context +import android.content.Intent import androidx.hilt.work.HiltWorker import androidx.work.WorkerParameters import com.google.android.fhir.FhirEngine @@ -27,20 +28,30 @@ import com.google.android.fhir.sync.FhirSyncWorker import com.google.android.fhir.sync.upload.UploadStrategy import dagger.assisted.Assisted import dagger.assisted.AssistedInject +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext import org.hl7.fhir.r4.model.ResourceType +import org.smartregister.fhircore.engine.configuration.ConfigurationRegistry import org.smartregister.fhircore.engine.configuration.preferences.SyncUploadStrategy +import org.smartregister.fhircore.engine.data.local.TingatheDatabase +import org.smartregister.fhircore.engine.data.local.syncStrategy.toEntity import org.smartregister.fhircore.engine.data.remote.resource.syncStrategy.SyncParamStrategy +import org.smartregister.fhircore.engine.data.remote.resource.syncStrategy.broadcast.SYNC_STATUS_BROADCAST_RECEIVER_KEY +import org.smartregister.fhircore.engine.data.remote.resource.syncStrategy.broadcast.SyncStatusBroadcastReceiver +import org.smartregister.fhircore.engine.data.remote.resource.syncStrategy.fhir.LogicalIdSyncParamsBased import org.smartregister.fhircore.engine.data.remote.resource.syncStrategy.fhir.ResourceParamsBasedDownload import org.smartregister.fhircore.engine.data.remote.resource.syncStrategy.fhir.TimestampContext -import org.smartregister.fhircore.engine.data.remote.resource.syncStrategy.utils.SyncState.CompletedInitialSync -import org.smartregister.fhircore.engine.data.remote.resource.syncStrategy.utils.SyncState.InitialSync +import org.smartregister.fhircore.engine.data.remote.resource.syncStrategy.hasCompletedInitialSync +import org.smartregister.fhircore.engine.data.remote.resource.syncStrategy.logicalIds +import org.smartregister.fhircore.engine.data.remote.resource.syncStrategy.perOrgSyncConfig +import org.smartregister.fhircore.engine.data.remote.resource.syncStrategy.saveLastUpdatedTimestamp +import org.smartregister.fhircore.engine.data.remote.resource.syncStrategy.subListIds import org.smartregister.fhircore.engine.ui.questionnaire.ContentCache import org.smartregister.fhircore.engine.util.AppDataStore import org.smartregister.fhircore.engine.util.DispatcherProvider -import org.smartregister.fhircore.engine.util.SharedPreferenceKey -import org.smartregister.fhircore.engine.util.SharedPreferenceKey.SYNC_STATUS +import org.smartregister.fhircore.engine.util.SharedPreferenceKey.SYNC_UPLOAD_STRATEGY import org.smartregister.fhircore.engine.util.SharedPreferencesHelper +import timber.log.Timber @HiltWorker class AppSyncWorker @@ -53,23 +64,53 @@ constructor( val dataStore: AppDataStore, val preference: SharedPreferencesHelper, val dispatcherProvider: DispatcherProvider, + val configurationRegistry: ConfigurationRegistry, + val syncBroadcaster: SyncBroadcaster, + database: TingatheDatabase, ) : FhirSyncWorker(appContext, workerParams) { - private fun syncParams(): Map> { - return if ( - preference.read( - SYNC_STATUS.name, - InitialSync.value, - ) <= CompletedInitialSync.value - ) { - preference.write(SharedPreferenceKey.LAST_SYNC_TIMESTAMP.name, null) - SyncParamStrategy(preference).syncParams() - } else syncListenerManager.loadSyncParams() + private val syncStrategyCacheDao = database.syncStrategyCacheDao + + private fun downloadWorkManager(): DownloadWorkManager { + return when { + hasCompletedInitialSync(preference) -> defaultDownloadManager() + else -> { + val subList = logicalIds(engine).subListIds(syncStrategyCacheDao) + return if (subList.isNotEmpty()) { + LogicalIdSyncParamsBased(subList) { + Timber.tag("TAG") + .e("downloadWorkManager: " + it.patientPositionAt + " of " + it.idsTotal) + runBlocking { + syncStrategyCacheDao.upsert(it.logicalId.toEntity()) + saveLastUpdatedTimestamp(dataStore) + } + val broadcastIntent = + Intent(SyncStatusBroadcastReceiver::class.java.name).apply { + putExtra(SYNC_STATUS_BROADCAST_RECEIVER_KEY, it) + } + dataStore.context.sendBroadcast(broadcastIntent) + } + } else { + defaultDownloadManager() + } + } + } } - override fun getConflictResolver(): ConflictResolver = AcceptLocalConflictResolver + private fun syncParams(): Map> { + val configs = + perOrgSyncConfig(configurationRegistry, preference) + ?: return syncListenerManager.loadSyncParams() - override fun getDownloadWorkManager(): DownloadWorkManager = + if (configs.offlineFirst) return syncListenerManager.loadSyncParams() + + return when { + hasCompletedInitialSync(preference) -> SyncParamStrategy(preference).syncParams() + else -> syncListenerManager.loadSyncParams() + } + } + + private fun defaultDownloadManager() = ResourceParamsBasedDownload( syncParams = syncParams(), context = @@ -86,6 +127,10 @@ constructor( }, ) + override fun getConflictResolver(): ConflictResolver = AcceptLocalConflictResolver + + override fun getDownloadWorkManager(): DownloadWorkManager = downloadWorkManager() + override suspend fun doWork(): Result { // Cache resources that might be needed urgent before sync ContentCache.saveResources(engine) @@ -97,7 +142,7 @@ constructor( val strategy = SyncUploadStrategy.valueOf( preference.read( - SharedPreferenceKey.SYNC_UPLOAD_STRATEGY.name, + SYNC_UPLOAD_STRATEGY.name, SyncUploadStrategy.Default.name, ) ?: SyncUploadStrategy.Default.name, ) diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/util/SharedPreferenceKey.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/SharedPreferenceKey.kt index 2c94db3565..1f981f5fc7 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/util/SharedPreferenceKey.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/SharedPreferenceKey.kt @@ -35,4 +35,6 @@ enum class SharedPreferenceKey { SYNC_ON_SAVE, SYNC_STATUS, PATIENT_IDENTIFIER_LIST_TIMESTAMP, + SEARCH_PATIENT_ID_TIMESTAMP, + SEARCH_PATIENT_ID_OPEN, } diff --git a/android/quest/build.gradle.kts b/android/quest/build.gradle.kts index 732545242e..38d8826880 100644 --- a/android/quest/build.gradle.kts +++ b/android/quest/build.gradle.kts @@ -147,8 +147,8 @@ android { dimension = "apps" applicationIdSuffix = ".mwcoreDev" versionNameSuffix = "-mwcoreDev" - versionCode = 38 - versionName = "0.2.0.2" + versionCode = 40 + versionName = "0.2.1.0-fhir-sdk-sync-strategy" } create("mwcoreStaging") { dimension = "apps" diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/main/AppMainActivity.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/main/AppMainActivity.kt index 4b6b852289..97a8b6da34 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/main/AppMainActivity.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/main/AppMainActivity.kt @@ -18,6 +18,8 @@ package org.smartregister.fhircore.quest.ui.main import android.app.Activity import android.content.Intent +import android.content.IntentFilter +import android.os.Build import android.os.Bundle import androidx.activity.compose.setContent import androidx.activity.result.contract.ActivityResultContracts @@ -28,10 +30,12 @@ import com.google.android.fhir.sync.SyncJobStatus import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject import kotlin.math.max -import kotlinx.coroutines.launch +import kotlinx.coroutines.flow.MutableStateFlow import org.hl7.fhir.r4.model.ResourceType import org.smartregister.fhircore.engine.R import org.smartregister.fhircore.engine.configuration.app.ConfigService +import org.smartregister.fhircore.engine.data.remote.resource.syncStrategy.broadcast.SyncStatusBroadcastReceiver +import org.smartregister.fhircore.engine.data.remote.resource.syncStrategy.fhir.ParamSyncStatus import org.smartregister.fhircore.engine.data.remote.shared.TokenAuthenticator import org.smartregister.fhircore.engine.sync.OnSyncListener import org.smartregister.fhircore.engine.sync.SyncBroadcaster @@ -63,6 +67,8 @@ open class AppMainActivity : BaseMultiLanguageActivity(), OnSyncListener { val appMainViewModel by viewModels() + private lateinit var syncStatusBroadcastReceiver: SyncStatusBroadcastReceiver + private val authActivityLauncherForResult = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { res -> if (res.resultCode == Activity.RESULT_OK) { @@ -73,11 +79,35 @@ open class AppMainActivity : BaseMultiLanguageActivity(), OnSyncListener { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setupTimeOutListener() - setContent { AppTheme { MainScreen(appMainViewModel = appMainViewModel) } } + val paramSyncStatus = MutableStateFlow(null) + syncStatusBroadcastReceiver = SyncStatusBroadcastReceiver { + showToast("Syncing ${it.patientPositionAt} of ${it.idsTotal}") + paramSyncStatus.value = it + } + val intentFilter = IntentFilter(SyncStatusBroadcastReceiver::class.java.name) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + registerReceiver(syncStatusBroadcastReceiver, intentFilter, RECEIVER_NOT_EXPORTED) + } else { + registerReceiver(syncStatusBroadcastReceiver, intentFilter) + } + + setContent { + AppTheme { + MainScreen( + appMainViewModel = appMainViewModel, + paramSyncStatus = paramSyncStatus, + ) + } + } syncBroadcaster.registerSyncListener(this, lifecycleScope) scheduleAuthWorkers() } + override fun onDestroy() { + super.onDestroy() + unregisterReceiver(syncStatusBroadcastReceiver) + } + override fun onResume() { super.onResume() // appMainViewModel.updateRefreshState() @@ -95,7 +125,14 @@ open class AppMainActivity : BaseMultiLanguageActivity(), OnSyncListener { } is SyncJobStatus.InProgress -> { Timber.d( - "Syncing in progress: ${state.syncOperation.name} ${state.completed.div(max(state.total, 1).toDouble()).times(100)}%", + "Syncing in progress: ${state.syncOperation.name} ${ + state.completed.div( + max( + state.total, + 1, + ).toDouble(), + ).times(100) + }%", ) appMainViewModel.onEvent( AppMainEvent.UpdateSyncState(state, getString(R.string.syncing_in_progress)), @@ -103,7 +140,7 @@ open class AppMainActivity : BaseMultiLanguageActivity(), OnSyncListener { } is SyncJobStatus.Failed -> { if ( - !state?.exceptions.isNullOrEmpty() && + !state.exceptions.isNullOrEmpty() && state.exceptions.first().resourceType == ResourceType.Flag ) { showToast(state.exceptions.first().exception.message!!) @@ -111,9 +148,9 @@ open class AppMainActivity : BaseMultiLanguageActivity(), OnSyncListener { } showToast(getString(R.string.sync_failed_text)) val hasAuthError = - state?.exceptions?.any { + state.exceptions.any { it.exception is HttpException && (it.exception as HttpException).code() == 401 - } ?: false + } if (hasAuthError) { showToast(getString(R.string.session_expired)) } @@ -134,7 +171,7 @@ open class AppMainActivity : BaseMultiLanguageActivity(), OnSyncListener { }, ) } - Timber.w(state?.exceptions?.joinToString { it.exception.message.toString() }) + Timber.w(state.exceptions.joinToString { it.exception.message.toString() }) scheduleFhirBackgroundWorkers() } is SyncJobStatus.Succeeded -> { diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/main/AppMainScreen.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/main/AppMainScreen.kt index e78a8310fb..74a7511840 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/main/AppMainScreen.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/main/AppMainScreen.kt @@ -35,8 +35,10 @@ import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController import androidx.navigation.navArgument +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch import org.smartregister.fhircore.engine.appfeature.model.HealthModule +import org.smartregister.fhircore.engine.data.remote.resource.syncStrategy.fhir.ParamSyncStatus import org.smartregister.fhircore.engine.domain.model.SideMenuOption import org.smartregister.fhircore.engine.ui.settings.SettingsScreen import org.smartregister.fhircore.quest.R @@ -66,6 +68,7 @@ import org.smartregister.fhircore.quest.ui.tracing.register.TracingRegisterScree fun MainScreen( modifier: Modifier = Modifier, appMainViewModel: AppMainViewModel = hiltViewModel(), + paramSyncStatus: MutableStateFlow, ) { val navController = rememberNavController() val scope = rememberCoroutineScope() @@ -115,6 +118,7 @@ fun MainScreen( openDrawer = openDrawer, sideMenuOptions = uiState.sideMenuOptions, appMainViewModel = appMainViewModel, + paramSyncStatus = paramSyncStatus, ) } } @@ -128,6 +132,7 @@ private fun AppMainNavigationGraph( sideMenuOptions: List, measureReportViewModel: MeasureReportViewModel = hiltViewModel(), appMainViewModel: AppMainViewModel, + paramSyncStatus: MutableStateFlow, ) { val firstSideMenuOption = sideMenuOptions.first() val firstScreenTitle = stringResource(firstSideMenuOption.titleResource) @@ -148,7 +153,12 @@ private fun AppMainNavigationGraph( MainNavigationScreen.Home -> composable( route = - "${it.route}${NavigationArg.routePathsOf(includeCommonArgs = true, NavigationArg.SCREEN_TITLE)}", + "${it.route}${ + NavigationArg.routePathsOf( + includeCommonArgs = true, + NavigationArg.SCREEN_TITLE, + ) + }", arguments = commonNavArgs.plus( navArgument(NavigationArg.SCREEN_TITLE) { @@ -194,7 +204,13 @@ private fun AppMainNavigationGraph( MainNavigationScreen.PatientProfile -> composable( route = - "${it.route}${NavigationArg.routePathsOf(includeCommonArgs = true, NavigationArg.PATIENT_ID, NavigationArg.FAMILY_ID)}", + "${it.route}${ + NavigationArg.routePathsOf( + includeCommonArgs = true, + NavigationArg.PATIENT_ID, + NavigationArg.FAMILY_ID, + ) + }", arguments = commonNavArgs.plus(patientIdNavArgument()), ) { PatientProfileScreen(navController = navController, appMainViewModel = appMainViewModel) @@ -202,7 +218,14 @@ private fun AppMainNavigationGraph( MainNavigationScreen.FixPatientProfile -> composable( route = - "${it.route}${NavigationArg.routePathsOf(includeCommonArgs = true, NavigationArg.PATIENT_ID, FixPatientViewModel.NAVIGATION_ARG_START, FixPatientViewModel.NAVIGATION_ARG_CARE_PLAN)}", + "${it.route}${ + NavigationArg.routePathsOf( + includeCommonArgs = true, + NavigationArg.PATIENT_ID, + FixPatientViewModel.NAVIGATION_ARG_START, + FixPatientViewModel.NAVIGATION_ARG_CARE_PLAN, + ) + }", arguments = commonNavArgs.plus(patientIdNavArgument()), ) { FixPatientScreen(navController = navController, appMainViewModel = appMainViewModel) @@ -210,7 +233,13 @@ private fun AppMainNavigationGraph( MainNavigationScreen.TracingProfile -> composable( route = - "${it.route}${NavigationArg.routePathsOf(includeCommonArgs = true, NavigationArg.PATIENT_ID, NavigationArg.FAMILY_ID)}", + "${it.route}${ + NavigationArg.routePathsOf( + includeCommonArgs = true, + NavigationArg.PATIENT_ID, + NavigationArg.FAMILY_ID, + ) + }", arguments = commonNavArgs.plus(patientIdNavArgument()), ) { TracingProfileScreen(navController = navController, appViewModel = appMainViewModel) @@ -232,7 +261,12 @@ private fun AppMainNavigationGraph( MainNavigationScreen.GuardianProfile -> composable( route = - "${it.route}/{${NavigationArg.PATIENT_ID}}${NavigationArg.routePathsOf(includeCommonArgs = true, NavigationArg.ON_ART)}", + "${it.route}/{${NavigationArg.PATIENT_ID}}${ + NavigationArg.routePathsOf( + includeCommonArgs = true, + NavigationArg.ON_ART, + ) + }", arguments = commonNavArgs.plus( listOf( @@ -258,7 +292,12 @@ private fun AppMainNavigationGraph( MainNavigationScreen.FamilyProfile -> composable( route = - "${it.route}${NavigationArg.routePathsOf(includeCommonArgs = true, NavigationArg.PATIENT_ID)}", + "${it.route}${ + NavigationArg.routePathsOf( + includeCommonArgs = true, + NavigationArg.PATIENT_ID, + ) + }", arguments = commonNavArgs.plus(patientIdNavArgument()), ) { FamilyProfileScreen(navController = navController) @@ -266,7 +305,12 @@ private fun AppMainNavigationGraph( MainNavigationScreen.ViewChildContacts -> composable( route = - "${it.route}${NavigationArg.routePathsOf(includeCommonArgs = true, NavigationArg.PATIENT_ID)}", + "${it.route}${ + NavigationArg.routePathsOf( + includeCommonArgs = true, + NavigationArg.PATIENT_ID, + ) + }", arguments = commonNavArgs.plus(patientIdNavArgument()), ) { ChildContactsProfileScreen(navController = navController) @@ -274,7 +318,12 @@ private fun AppMainNavigationGraph( MainNavigationScreen.TracingHistory -> composable( route = - "${it.route}${NavigationArg.routePathsOf(includeCommonArgs = true, NavigationArg.PATIENT_ID)}", + "${it.route}${ + NavigationArg.routePathsOf( + includeCommonArgs = true, + NavigationArg.PATIENT_ID, + ) + }", arguments = commonNavArgs.plus(patientIdNavArgument()), ) { TracingHistoryScreen(navController = navController) @@ -282,7 +331,13 @@ private fun AppMainNavigationGraph( MainNavigationScreen.TracingOutcomes -> composable( route = - "${it.route}${NavigationArg.routePathsOf(includeCommonArgs = true, NavigationArg.PATIENT_ID, NavigationArg.TRACING_ID)}", + "${it.route}${ + NavigationArg.routePathsOf( + includeCommonArgs = true, + NavigationArg.PATIENT_ID, + NavigationArg.TRACING_ID, + ) + }", arguments = commonNavArgs.plus( listOf( @@ -299,7 +354,15 @@ private fun AppMainNavigationGraph( MainNavigationScreen.TracingHistoryDetails -> composable( route = - "${it.route}${NavigationArg.routePathsOf(includeCommonArgs = true, NavigationArg.PATIENT_ID, NavigationArg.TRACING_ID, NavigationArg.TRACING_ENCOUNTER_ID, NavigationArg.SCREEN_TITLE)}", + "${it.route}${ + NavigationArg.routePathsOf( + includeCommonArgs = true, + NavigationArg.PATIENT_ID, + NavigationArg.TRACING_ID, + NavigationArg.TRACING_ENCOUNTER_ID, + NavigationArg.SCREEN_TITLE, + ) + }", arguments = commonNavArgs.plus( listOf( diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/patient/register/PatientRegisterScreen.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/patient/register/PatientRegisterScreen.kt index dc5a90f9eb..9e49518f7e 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/patient/register/PatientRegisterScreen.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/patient/register/PatientRegisterScreen.kt @@ -21,56 +21,26 @@ import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.material.Button -import androidx.compose.material.Card -import androidx.compose.material.CircularProgressIndicator -import androidx.compose.material.Icon -import androidx.compose.material.MaterialTheme import androidx.compose.material.Scaffold -import androidx.compose.material.SnackbarHost -import androidx.compose.material.SnackbarHostState import androidx.compose.material.Text -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.CheckCircle -import androidx.compose.material.icons.rounded.Info import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Dialog -import androidx.compose.ui.window.DialogProperties import androidx.hilt.navigation.compose.hiltViewModel -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.repeatOnLifecycle import androidx.navigation.NavHostController import androidx.paging.compose.LazyPagingItems import androidx.paging.compose.collectAsLazyPagingItems import com.google.accompanist.swiperefresh.SwipeRefresh import com.google.accompanist.swiperefresh.rememberSwipeRefreshState -import kotlinx.coroutines.launch import org.hl7.fhir.r4.model.QuestionnaireResponse -import org.smartregister.fhircore.engine.data.remote.resource.syncStrategy.EventCallback -import org.smartregister.fhircore.engine.data.remote.resource.syncStrategy.utils.Progress -import org.smartregister.fhircore.engine.data.remote.resource.syncStrategy.utils.SyncState import org.smartregister.fhircore.engine.ui.components.register.LoaderDialog import org.smartregister.fhircore.engine.ui.components.register.RegisterHeader import org.smartregister.fhircore.engine.ui.questionnaire.QuestionnaireActivity @@ -79,7 +49,6 @@ import org.smartregister.fhircore.engine.util.extension.extractId import org.smartregister.fhircore.quest.ui.components.RegisterFooter import org.smartregister.fhircore.quest.ui.components.RegisterList import org.smartregister.fhircore.quest.ui.main.components.TopScreenSection -import org.smartregister.fhircore.quest.ui.patient.register.components.KeepScreenOn import org.smartregister.fhircore.quest.ui.shared.models.RegisterViewData @Composable @@ -124,175 +93,7 @@ fun PatientRegisterScreen( .value .collectAsLazyPagingItems() - LaunchedEffect(key1 = searchText.trim()) { - if (searchText.trim().isNotEmpty()) { - patientRegisterViewModel.searchPatient(searchText.trim()) - } - } - - val lifecycleOwner = LocalLifecycleOwner.current - val coroutineScope = rememberCoroutineScope() - - val snackbarHostState = remember { SnackbarHostState() } - - var showProgressDialog by remember { mutableStateOf(false) } - var showAttentionDialog by remember { mutableStateOf(false) } - var onSyncListenerDialog by remember { mutableStateOf(false) } - var keepScreenOn by remember { mutableStateOf(false) } - - LaunchedEffect(key1 = patientRegisterViewModel.channelFlow) { - lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { - patientRegisterViewModel.channelFlow.collect { - when (it) { - is EventCallback.InProgress -> { - showProgressDialog = true - keepScreenOn = true - } - is EventCallback.Finished -> { - with(patientRegisterViewModel) { - onEvent(PatientRegisterEvent.SearchRegister(searchText = "")) - onEvent(PatientRegisterEvent.OpenProfile(it.logicalId, navController)) - showLocalData() - keepScreenOn = false - } - } - EventCallback.Stated -> { - coroutineScope.launch { - snackbarHostState.showSnackbar(message = "Working on it, please wait ...") - } - } - is EventCallback.OnSyncListener -> { - with(patientRegisterViewModel.getSyncState() == SyncState.SubSequentSync.value) { - onSyncListenerDialog = this - keepScreenOn = not() - } - } - EventCallback.ShowAttentionDialog -> { - showAttentionDialog = true - } - } - } - } - } - - val state = patientRegisterViewModel.progressStatusUiState.collectAsState().value - val status = state.progress ?: Progress(0, 0, "") - - LaunchedEffect(key1 = status) { - if (status.totalResources == status.currentPosition.plus(1)) { - showProgressDialog = false - } - } - - KeepScreenOn(keepScreenOn) - - if (showProgressDialog) { - Dialog(onDismissRequest = { showProgressDialog = false }) { - Card(modifier = Modifier.padding(24.dp)) { - Column( - modifier = Modifier.fillMaxWidth().padding(24.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Box(contentAlignment = Alignment.Center) { - CircularProgressIndicator( - progress = 1f, - modifier = Modifier.size(90.dp), - strokeWidth = 16.dp, - color = Color.LightGray, - ) - - CircularProgressIndicator( - progress = status.currentPosition.toFloat().div(status.totalResources.toFloat()), - modifier = Modifier.size(90.dp), - strokeWidth = 16.dp, - strokeCap = StrokeCap.Round, - ) - } - - Spacer(modifier = Modifier.height(16.dp)) - Text( - text = "${status.currentPosition} of ${status.totalResources}", - style = MaterialTheme.typography.h5, - color = Color.Gray, - fontWeight = FontWeight.Bold, - ) - Text(text = "This takes a while, please wait ...") - } - } - } - } - - if (onSyncListenerDialog) { - Dialog(onDismissRequest = { onSyncListenerDialog = false }) { - Card(modifier = Modifier.padding(24.dp)) { - Column( - modifier = Modifier.fillMaxWidth().padding(24.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Spacer(modifier = Modifier.size(8.dp)) - Icon( - imageVector = Icons.Rounded.CheckCircle, - contentDescription = null, - modifier = Modifier.size(62.dp), - tint = Color(0xFF1E5220), - ) - - Text( - text = "Sync Completed", - style = MaterialTheme.typography.h5, - color = Color.Gray, - modifier = Modifier.padding(16.dp), - ) - } - } - } - } - - if (showAttentionDialog) { - Dialog( - onDismissRequest = { showAttentionDialog = false }, - properties = - DialogProperties( - dismissOnClickOutside = false, - dismissOnBackPress = false, - ), - ) { - Card(modifier = Modifier.padding(24.dp)) { - Column( - modifier = Modifier.fillMaxWidth().padding(24.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Spacer(modifier = Modifier.size(8.dp)) - Icon( - imageVector = Icons.Rounded.Info, - contentDescription = null, - modifier = Modifier.size(62.dp), - tint = Color(0xFFFFA726), - ) - - Text( - text = - "Please stay on this screen as the initial sync is in progress, we will notify you know when it is completed.", - textAlign = TextAlign.Center, - modifier = Modifier.padding(bottom = 16.dp), - ) - - Button( - onClick = { - showAttentionDialog = false - keepScreenOn = true - patientRegisterViewModel.initialSync() - }, - ) { - Text(text = "Continue") - } - } - } - } - } - Scaffold( - snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, topBar = { // Top section has toolbar and a results counts view TopScreenSection( @@ -376,15 +177,6 @@ fun PatientRegisterScreen( modifier = iModifier, pagingItems = pagingItems, onRowClick = { patientId: String -> - pagingItems.itemSnapshotList.items - .find { it.logicalId == patientId } - ?.let { - if (it.isLocal.not()) { - patientRegisterViewModel.fetchAndSaveToDb(it.logicalId) - return@RegisterList - } - } - patientRegisterViewModel.onEvent( PatientRegisterEvent.OpenProfile(patientId, navController), ) diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/patient/register/PatientRegisterViewModel.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/patient/register/PatientRegisterViewModel.kt index ce72085403..aa24e22414 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/patient/register/PatientRegisterViewModel.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/patient/register/PatientRegisterViewModel.kt @@ -23,9 +23,6 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.paging.Pager import androidx.paging.PagingConfig -import androidx.paging.PagingData -import androidx.paging.PagingSource -import androidx.paging.PagingState import androidx.paging.cachedIn import androidx.paging.filter import androidx.paging.map @@ -34,11 +31,9 @@ import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import kotlin.math.max import kotlin.time.Duration.Companion.milliseconds +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.Job -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -46,10 +41,8 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.emptyFlow -import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapLatest -import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @@ -58,28 +51,18 @@ import org.smartregister.fhircore.engine.appfeature.AppFeature import org.smartregister.fhircore.engine.appfeature.AppFeatureManager import org.smartregister.fhircore.engine.appfeature.model.HealthModule import org.smartregister.fhircore.engine.configuration.ConfigurationRegistry +import org.smartregister.fhircore.engine.data.local.TingatheDatabase import org.smartregister.fhircore.engine.data.local.register.AppRegisterRepository -import org.smartregister.fhircore.engine.data.local.register.dao.HivRegisterDao -import org.smartregister.fhircore.engine.data.remote.fhir.resource.FhirApiService -import org.smartregister.fhircore.engine.data.remote.fhir.resource.FhirResourceService -import org.smartregister.fhircore.engine.data.remote.resource.syncStrategy.ApiRepositoryImpl -import org.smartregister.fhircore.engine.data.remote.resource.syncStrategy.EventCallback -import org.smartregister.fhircore.engine.data.remote.resource.syncStrategy.ListResourceExt -import org.smartregister.fhircore.engine.data.remote.resource.syncStrategy.utils.Progress -import org.smartregister.fhircore.engine.data.remote.resource.syncStrategy.utils.SearchBy +import org.smartregister.fhircore.engine.data.remote.resource.syncStrategy.logicalIds +import org.smartregister.fhircore.engine.data.remote.resource.syncStrategy.perOrgSyncConfig +import org.smartregister.fhircore.engine.data.remote.resource.syncStrategy.subListIds import org.smartregister.fhircore.engine.data.remote.resource.syncStrategy.utils.SyncState -import org.smartregister.fhircore.engine.data.remote.resource.syncStrategy.utils.SyncState.CompletedInitialSync -import org.smartregister.fhircore.engine.data.remote.resource.syncStrategy.utils.SyncState.InitialSync -import org.smartregister.fhircore.engine.data.remote.resource.syncStrategy.utils.SyncState.SubSequentRunSync -import org.smartregister.fhircore.engine.data.remote.resource.syncStrategy.utils.SyncState.SubSequentSync import org.smartregister.fhircore.engine.domain.util.PaginationConstant import org.smartregister.fhircore.engine.sync.SyncBroadcaster import org.smartregister.fhircore.engine.ui.questionnaire.QuestionnaireActivity import org.smartregister.fhircore.engine.ui.questionnaire.QuestionnaireType -import org.smartregister.fhircore.engine.util.AppDataStore import org.smartregister.fhircore.engine.util.DispatcherProvider -import org.smartregister.fhircore.engine.util.SharedPreferenceKey.LAST_SYNC_TIMESTAMP -import org.smartregister.fhircore.engine.util.SharedPreferenceKey.SYNC_STATUS +import org.smartregister.fhircore.engine.util.SharedPreferenceKey import org.smartregister.fhircore.engine.util.SharedPreferencesHelper import org.smartregister.fhircore.quest.R import org.smartregister.fhircore.quest.data.patient.model.PatientPagingSourceState @@ -89,7 +72,6 @@ import org.smartregister.fhircore.quest.navigation.NavigationArg import org.smartregister.fhircore.quest.ui.shared.models.RegisterViewData import org.smartregister.fhircore.quest.util.REGISTER_FORM_ID_KEY import org.smartregister.fhircore.quest.util.mappers.RegisterViewDataMapper -import timber.log.Timber @OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class) @HiltViewModel @@ -104,33 +86,16 @@ constructor( val appFeatureManager: AppFeatureManager, val dispatcherProvider: DispatcherProvider, val sharedPreferencesHelper: SharedPreferencesHelper, - fhirResourceService: FhirResourceService, - fhirApiService: FhirApiService, - val hivRegisterDao: HivRegisterDao, - appDataStore: AppDataStore, + database: TingatheDatabase, ) : ViewModel() { private val appFeatureName = savedStateHandle.get(NavigationArg.FEATURE) private val healthModule = savedStateHandle.get(NavigationArg.HEALTH_MODULE) ?: HealthModule.DEFAULT + private val dao = database.syncStrategyCacheDao private val _isRefreshing = MutableStateFlow(false) - private val apiRepository = - ApiRepositoryImpl( - fhirApiService = fhirApiService, - fhirEngine = syncBroadcaster.fhirEngine, - sharedPreferencesHelper = sharedPreferencesHelper, - appDataStore = appDataStore, - fhirResourceService = fhirResourceService, - ) - - private val _progressStatusUiState = MutableStateFlow(PatientUiState()) - val progressStatusUiState = _progressStatusUiState.asStateFlow() - private val _channel = Channel() - val channelFlow = _channel.receiveAsFlow() - private var invokeCounter = 0 - val isRefreshing: StateFlow get() = _isRefreshing.asStateFlow() @@ -165,7 +130,7 @@ constructor( private val _syncProgressMutableStateFlow = MutableStateFlow("") val syncProgressStateFlow = _syncProgressMutableStateFlow.asStateFlow() - private val _paginatedRegisterViewData: Flow>> = + private val _paginatedRegisterViewData = combine( searchedText, currentPage, @@ -187,7 +152,7 @@ constructor( return@mapLatest pagingFlow.cachedIn(viewModelScope).also { _isRefreshing.emit(false) } } - var pageRegisterListItemData = + val pageRegisterListItemData = _paginatedRegisterViewData .map { pagingDataFlow -> pagingDataFlow.map { pagingData -> @@ -217,68 +182,8 @@ constructor( initialValue = emptyFlow(), ) - data class PatientUiState(val progress: Progress? = null) - - fun initialSync() { - sharedPreferencesHelper.write(SYNC_STATUS.name, InitialSync.value) - syncBroadcaster.runSync() - } - - fun getSyncState() = sharedPreferencesHelper.read(SYNC_STATUS.name, SyncState.ShowDialog.value) - - private suspend fun onSetupState() { - if (getSyncState() == SyncState.ShowDialog.value) { - _channel.send(EventCallback.ShowAttentionDialog) - } - - if (getSyncState() == InitialSync.value) { - sharedPreferencesHelper.write( - SYNC_STATUS.name, - CompletedInitialSync.value, - ) - } - - if (getSyncState() == CompletedInitialSync.value || getSyncState() == SubSequentRunSync.value) { - apiRepository.invoke( - onCompleteListener = { patientSize -> - patientSize - .takeIf { it > 0 } - ?.let { - viewModelScope.launch { - _channel.send(EventCallback.OnSyncListener(true)) - syncBroadcaster.runSync() - } - } - }, - onProgress = { progressUiState -> - viewModelScope.launch { _channel.send(EventCallback.InProgress) } - _progressStatusUiState.update { it.copy(progress = progressUiState) } - }, - ) - } - - if (getSyncState() == SubSequentSync.value) { - ListResourceExt( - fhirEngine = syncBroadcaster.fhirEngine, - sharedPreferences = sharedPreferencesHelper, - apiRepository = apiRepository, - ) - .onListResourceExt { progressUiState -> - if (progressUiState.totalResources > 0) { - viewModelScope.launch { - _channel.send(EventCallback.InProgress) - _progressStatusUiState.update { it.copy(progress = progressUiState) } - - if (progressUiState.currentPosition.plus(1) == progressUiState.totalResources) { - sharedPreferencesHelper.write(SYNC_STATUS.name, SubSequentRunSync.value) - _channel.send(EventCallback.OnSyncListener(true)) - syncBroadcaster.runSync() - } - } - } - } - } - } + private fun getSyncState() = + sharedPreferencesHelper.read(SharedPreferenceKey.SYNC_STATUS.name, SyncState.InitialSync.value) init { syncBroadcaster.registerSyncListener( @@ -291,18 +196,32 @@ constructor( is SyncJobStatus.Succeeded -> { refresh() _firstTimeSyncState.value = false - viewModelScope.launch { - invokeCounter += 1 - if (invokeCounter == 1) { - onSetupState() + viewModelScope.launch(Dispatchers.IO) { + val configs = + perOrgSyncConfig(configurationRegistry, sharedPreferencesHelper) ?: return@launch + + if (configs.offlineFirst) return@launch + + if (getSyncState() == SyncState.InitialSync.value) { + sharedPreferencesHelper.write( + SharedPreferenceKey.SYNC_STATUS.name, + SyncState.CompletedInitialSync.value, + ) + syncBroadcaster.runSync() } - Timber.e("invokeCounter -> $invokeCounter") + if (getSyncState() < SyncState.SubSequentSync.value) { + if (logicalIds(syncBroadcaster.fhirEngine).subListIds(dao).isNotEmpty()) { + syncBroadcaster.runSync() + } else { + sharedPreferencesHelper.write( + SharedPreferenceKey.SYNC_STATUS.name, + SyncState.SubSequentSync.value, + ) + } + } } } - is SyncJobStatus.Started -> { - invokeCounter = 0 - } is SyncJobStatus.InProgress -> { updateSyncProgress(state) _firstTimeSyncState.value = isFirstTimeSync() @@ -343,102 +262,6 @@ constructor( ) .flow - private var searchJob: Job? = null - - fun searchPatient(searchQuery: String) { - // searchJob?.cancel() - viewModelScope.launch { - // delay(2000) - val criteria = - when { - searchQuery.any { it.isDigit() } -> SearchBy.IDENTIFIER - else -> SearchBy.HUMAN_NAME - } - - pageRegisterListItemData = - patientIdentifierPager(searchQuery, criteria) - .cachedIn(viewModelScope) - .map { flowOf(it) } - .stateIn( - viewModelScope.plus(dispatcherProvider.io()), - SharingStarted.Lazily, - initialValue = emptyFlow(), - ) - } - } - - fun showLocalData() { - pageRegisterListItemData = - _paginatedRegisterViewData - .map { pagingDataFlow -> - pagingDataFlow.map { pagingData -> - pagingData - .filter { it is RegisterViewData.ListItemView } - .map { it as RegisterViewData.ListItemView } - } - } - .stateIn( - viewModelScope.plus(dispatcherProvider.io()), - SharingStarted.Lazily, - initialValue = emptyFlow(), - ) - } - - fun fetchAndSaveToDb(logicalId: String) = - viewModelScope.launch { - _channel.send(EventCallback.Stated) - apiRepository.fetchAndSaveToDb( - logicalId, - onCompleteListener = { - launch { - _channel.send(EventCallback.Finished(logicalId)) - syncBroadcaster.runSync() - } - }, - ) - } - - inner class PatientIdentifierPagingSource( - private val searchQuery: String, - private val criteria: SearchBy, - ) : PagingSource() { - override suspend fun load( - params: LoadParams, - ): LoadResult { - return try { - val registerViewData = - apiRepository - .search(searchQuery, criteria) - .map { hivRegisterDao.transformPatientToHivRegisterData(it) } - .map { registerViewDataMapper.transformInputToOutputModel(it).copy(isLocal = false) } - LoadResult.Page( - data = registerViewData, - prevKey = null, - nextKey = null, - ) - } catch (exception: Exception) { - LoadResult.Error(exception) - } - } - - override fun getRefreshKey(state: PagingState) = null - } - - private fun patientIdentifierPager( - searchQuery: String, - criteria: SearchBy, - ): Flow> = - Pager( - config = - PagingConfig( - pageSize = PaginationConstant.DEFAULT_PAGE_SIZE, - initialLoadSize = PaginationConstant.DEFAULT_INITIAL_LOAD_SIZE, - enablePlaceholders = false, - ), - pagingSourceFactory = { PatientIdentifierPagingSource(searchQuery, criteria) }, - ) - .flow - private fun getPager( appFeatureName: String?, healthModule: HealthModule, @@ -516,7 +339,7 @@ constructor( } fun isFirstTimeSync() = - sharedPreferencesHelper.read(LAST_SYNC_TIMESTAMP.name, null).isNullOrBlank() + sharedPreferencesHelper.read(SharedPreferenceKey.LAST_SYNC_TIMESTAMP.name, null).isNullOrBlank() fun progressMessage() = if (searchedText.value.isEmpty()) { diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/patient/register/dialog/AttentionDialog.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/patient/register/dialog/AttentionDialog.kt new file mode 100644 index 0000000000..9d28eae23b --- /dev/null +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/patient/register/dialog/AttentionDialog.kt @@ -0,0 +1,77 @@ +/* + * Copyright 2021 Ona Systems, Inc + * + * 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 org.smartregister.fhircore.quest.ui.patient.register.dialog + +import androidx.compose.MutableState +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.Button +import androidx.compose.material.Card +import androidx.compose.material.Icon +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Info +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties + +@Composable +fun AttentionDialog( + show: MutableState, + onClick: () -> Unit, +) { + if (show.value) { + Dialog( + onDismissRequest = { show.value = false }, + properties = + DialogProperties( + dismissOnClickOutside = false, + dismissOnBackPress = false, + ), + ) { + Card(modifier = Modifier.padding(24.dp)) { + Column( + modifier = Modifier.fillMaxWidth().padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Spacer(modifier = Modifier.size(8.dp)) + Icon( + imageVector = Icons.Rounded.Info, + contentDescription = null, + modifier = Modifier.size(62.dp), + tint = Color(0xFFFFA726), + ) + Text( + text = + "Please stay on this screen as the initial sync is in progress, we will notify you know when it is completed.", + textAlign = TextAlign.Center, + modifier = Modifier.padding(bottom = 16.dp), + ) + Button(onClick = onClick) { Text(text = "Continue") } + } + } + } + } +} diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/patient/register/dialog/ProgressDialog.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/patient/register/dialog/ProgressDialog.kt new file mode 100644 index 0000000000..7c17f8672a --- /dev/null +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/patient/register/dialog/ProgressDialog.kt @@ -0,0 +1,83 @@ +/* + * Copyright 2021 Ona Systems, Inc + * + * 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 org.smartregister.fhircore.quest.ui.patient.register.dialog + +import androidx.compose.MutableState +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.Card +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog + +@androidx.compose.runtime.Composable +fun ProgressDialog( + show: MutableState, + progress: Float, + status: String, +) { + if (show.value) { + Dialog(onDismissRequest = { show.value = false }) { + Card(modifier = Modifier.padding(24.dp)) { + Column( + modifier = Modifier.fillMaxWidth().padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Box(contentAlignment = Alignment.Center) { + CircularProgressIndicator( + progress = 1f, + modifier = Modifier.size(90.dp), + strokeWidth = 16.dp, + color = Color.LightGray, + ) + + CircularProgressIndicator( + progress = progress, + // progress = + // status.currentPosition.toFloat().div(status.totalResources.toFloat()), + modifier = Modifier.size(90.dp), + strokeWidth = 16.dp, + strokeCap = StrokeCap.Round, + ) + } + + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = status, + // text = "${status.currentPosition} of ${status.totalResources}", + style = MaterialTheme.typography.h5, + color = Color.Gray, + fontWeight = FontWeight.Bold, + ) + Text(text = "This takes a while, please wait ...") + } + } + } + } +} diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/patient/register/dialog/SyncCompleteDialog.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/patient/register/dialog/SyncCompleteDialog.kt new file mode 100644 index 0000000000..6c550b8799 --- /dev/null +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/patient/register/dialog/SyncCompleteDialog.kt @@ -0,0 +1,65 @@ +/* + * Copyright 2021 Ona Systems, Inc + * + * 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 org.smartregister.fhircore.quest.ui.patient.register.dialog + +import androidx.compose.MutableState +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.Card +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.CheckCircle +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog + +@Composable +fun SyncCompleteDialog(show: MutableState) { + if (show.value) { + Dialog(onDismissRequest = { show.value = false }) { + Card(modifier = Modifier.padding(24.dp)) { + Column( + modifier = Modifier.fillMaxWidth().padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Spacer(modifier = Modifier.size(8.dp)) + Icon( + imageVector = Icons.Rounded.CheckCircle, + contentDescription = null, + modifier = Modifier.size(62.dp), + tint = Color(0xFF1E5220), + ) + + Text( + text = "Sync Completed", + style = MaterialTheme.typography.h5, + color = Color.Gray, + modifier = Modifier.padding(16.dp), + ) + } + } + } + } +}