diff --git a/android/engine/build.gradle.kts b/android/engine/build.gradle.kts index b6dd4e0effb..379a5f5047d 100644 --- a/android/engine/build.gradle.kts +++ b/android/engine/build.gradle.kts @@ -160,7 +160,6 @@ dependencies { api(libs.jjwt) api(libs.fhir.common.utils) { exclude(group = "org.slf4j", module = "jcl-over-slf4j") } api(libs.runtime.livedata) - // api(libs.material3) api(libs.foundation) api(libs.fhir.common.utils) api(libs.kotlinx.serialization.json) diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/ConfigType.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/ConfigType.kt index 584a9cb53da..548b813e598 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/ConfigType.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/ConfigType.kt @@ -30,19 +30,19 @@ sealed class ConfigType( val parseAsResource: Boolean = false, val multiConfig: Boolean = false, ) { - object Application : ConfigType("application") + data object Application : ConfigType("application") - object Sync : ConfigType(name = "sync", parseAsResource = true) + data object Sync : ConfigType(name = "sync", parseAsResource = true) - object Navigation : ConfigType("navigation") + data object Navigation : ConfigType("navigation") - object Register : ConfigType(name = "register", multiConfig = true) + data object Register : ConfigType(name = "register", multiConfig = true) - object MeasureReport : ConfigType(name = "measureReport", multiConfig = true) + data object MeasureReport : ConfigType(name = "measureReport", multiConfig = true) - object Profile : ConfigType(name = "profile", multiConfig = true) + data object Profile : ConfigType(name = "profile", multiConfig = true) - object GeoWidget : ConfigType(name = "geoWidget", multiConfig = true) + data object GeoWidget : ConfigType(name = "geoWidget", multiConfig = true) - object DataMigration : ConfigType(name = "dataMigration", multiConfig = true) + data object DataMigration : ConfigType(name = "dataMigration", multiConfig = true) } diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/app/ApplicationConfiguration.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/app/ApplicationConfiguration.kt index 2906d1e715c..41159a40925 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/app/ApplicationConfiguration.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/app/ApplicationConfiguration.kt @@ -30,7 +30,7 @@ data class ApplicationConfiguration( val languages: List = listOf("en"), val useDarkTheme: Boolean = false, val syncInterval: Long = 15, - val syncStrategies: List = listOf(), + val syncStrategy: List = listOf(), val loginConfig: LoginConfig = LoginConfig(), val deviceToDeviceSync: DeviceToDeviceSyncConfig? = null, val snackBarTheme: SnackBarThemeConfig = SnackBarThemeConfig(), @@ -42,8 +42,19 @@ data class ApplicationConfiguration( val taskBackgroundWorkerBatchSize: Int = 500, val eventWorkflows: List = emptyList(), val logGpsLocation: List = emptyList(), + val usePractitionerAssignedLocationOnSync: Boolean = + true, // TODO This defaults to scheduling periodic sync, otherwise use sync location ids from + // location selector ) : Configuration() +enum class SyncStrategy { + Location, + CareTeam, + RelatedEntityLocation, + Organization, + Practitioner, +} + enum class LocationLogOptions { QUESTIONNAIRE, } diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/workflow/ApplicationWorkflow.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/workflow/ApplicationWorkflow.kt index fe5a3108cad..296efdaffc7 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/workflow/ApplicationWorkflow.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/workflow/ApplicationWorkflow.kt @@ -56,4 +56,7 @@ enum class ApplicationWorkflow { /** A workflow that copies text to keyboard */ COPY_TEXT, + + /** A workflow that launches location selector widget * */ + LAUNCH_LOCATION_SELECTOR, } diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/datastore/PreferenceDataStore.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/datastore/PreferenceDataStore.kt index 8fc21ee943a..c9e3d5f12f6 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/datastore/PreferenceDataStore.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/datastore/PreferenceDataStore.kt @@ -54,6 +54,7 @@ class PreferenceDataStore @Inject constructor(@ApplicationContext val context: C companion object Keys { val APP_ID by lazy { stringPreferencesKey("appId") } val LANG by lazy { stringPreferencesKey("lang") } + val SYNC_LOCATION_IDS by lazy { stringPreferencesKey("syncLocationIds") } val MIGRATION_VERSION by lazy { intPreferencesKey("migrationVersion") } } } diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/datastore/ProtoDataStore.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/datastore/ProtoDataStore.kt index 1cdc4220771..074ce467faf 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/datastore/ProtoDataStore.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/datastore/ProtoDataStore.kt @@ -27,11 +27,14 @@ import kotlinx.coroutines.flow.catch import org.smartregister.fhircore.engine.datastore.mockdata.PractitionerDetails import org.smartregister.fhircore.engine.datastore.mockdata.UserInfo import org.smartregister.fhircore.engine.datastore.serializers.PractitionerDetailsDataStoreSerializer +import org.smartregister.fhircore.engine.datastore.serializers.SyncLocationIdDataStoreSerializer import org.smartregister.fhircore.engine.datastore.serializers.UserInfoDataStoreSerializer +import org.smartregister.fhircore.engine.domain.model.SyncLocationToggleableState import timber.log.Timber private const val PRACTITIONER_DETAILS_DATASTORE_JSON = "practitioner_details.json" private const val USER_INFO_DATASTORE_JSON = "user_info.json" +private const val SYNC_LOCATION_IDS = "sync_location_ids.json" private const val TAG = "Proto DataStore" val Context.practitionerProtoStore: DataStore by @@ -46,6 +49,12 @@ val Context.userInfoProtoStore: DataStore by serializer = UserInfoDataStoreSerializer, ) +val Context.syncLocationIdsProtoStore: DataStore> by + dataStore( + fileName = SYNC_LOCATION_IDS, + serializer = SyncLocationIdDataStoreSerializer, + ) + @Singleton class ProtoDataStore @Inject constructor(@ApplicationContext val context: Context) { diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/datastore/serializers/PractitionerDetailsDataStoreSerializer.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/datastore/serializers/PractitionerDetailsDataStoreSerializer.kt index 80606aa94ba..ebd6510a049 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/datastore/serializers/PractitionerDetailsDataStoreSerializer.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/datastore/serializers/PractitionerDetailsDataStoreSerializer.kt @@ -34,8 +34,8 @@ object PractitionerDetailsDataStoreSerializer : Serializer deserializer = PractitionerDetails.serializer(), string = input.readBytes().decodeToString(), ) - } catch (e: SerializationException) { - Timber.tag(SerializerConstants.PROTOSTORE_SERIALIZER_TAG).d(e) + } catch (serializationException: SerializationException) { + Timber.e(serializationException) defaultValue } } diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/datastore/serializers/SyncLocationIdDataStoreSerializer.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/datastore/serializers/SyncLocationIdDataStoreSerializer.kt new file mode 100644 index 00000000000..9ef7bdbd02a --- /dev/null +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/datastore/serializers/SyncLocationIdDataStoreSerializer.kt @@ -0,0 +1,47 @@ +/* + * Copyright 2021-2024 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.datastore.serializers + +import androidx.datastore.core.Serializer +import java.io.InputStream +import java.io.OutputStream +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.apache.commons.lang3.SerializationException +import org.smartregister.fhircore.engine.domain.model.SyncLocationToggleableState +import org.smartregister.fhircore.engine.util.extension.encodeJson +import org.smartregister.fhircore.engine.util.extension.json +import timber.log.Timber + +object SyncLocationIdDataStoreSerializer : Serializer> { + + override val defaultValue: List + get() = emptyList() + + override suspend fun readFrom(input: InputStream): List { + return try { + json.decodeFromString>(input.readBytes().decodeToString()) + } catch (serializationException: SerializationException) { + Timber.e(serializationException) + defaultValue + } + } + + override suspend fun writeTo(t: List, output: OutputStream) { + withContext(Dispatchers.IO) { output.write(t.encodeJson().encodeToByteArray()) } + } +} diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/datastore/serializers/UserInfoDataStoreSerializer.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/datastore/serializers/UserInfoDataStoreSerializer.kt index fecd42c5a6e..aa234d699cc 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/datastore/serializers/UserInfoDataStoreSerializer.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/datastore/serializers/UserInfoDataStoreSerializer.kt @@ -34,8 +34,8 @@ object UserInfoDataStoreSerializer : Serializer { deserializer = UserInfo.serializer(), string = input.readBytes().decodeToString(), ) - } catch (e: SerializationException) { - Timber.tag(SerializerConstants.PROTOSTORE_SERIALIZER_TAG).d(e) + } catch (serializationException: SerializationException) { + Timber.e(serializationException) defaultValue } } diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/domain/model/ActionConfig.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/domain/model/ActionConfig.kt index d95ddfe5fe5..0bd988d40b0 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/domain/model/ActionConfig.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/domain/model/ActionConfig.kt @@ -41,6 +41,7 @@ data class ActionConfig( val resourceConfig: FhirResourceConfig? = null, val toolBarHomeNavigation: ToolBarHomeNavigation = ToolBarHomeNavigation.OPEN_DRAWER, val popNavigationBackStack: Boolean? = null, + val multiSelectViewConfig: MultiSelectViewConfig? = null, ) : Parcelable, java.io.Serializable { fun paramsBundle(computedValuesMap: Map = emptyMap()): Bundle = Bundle().apply { diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/domain/model/MultiSelectViewConfig.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/domain/model/MultiSelectViewConfig.kt new file mode 100644 index 00000000000..3da938c5fd1 --- /dev/null +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/domain/model/MultiSelectViewConfig.kt @@ -0,0 +1,40 @@ +/* + * Copyright 2021-2024 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.domain.model + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize +import kotlinx.serialization.Serializable + +/** + * @property resourceConfig The configuration for FHIR resource to be loaded + * @property parentIdFhirPathExpression FhirPath expression for extracting the ID for the parent + * resource + * @property contentFhirPathExpression FhirPath expression for extracting the content displayed on + * the multi select widget e.g. the name of the Location in a Location hierarchy + * @property rootNodeFhirPathExpression A key value pair containing a FHIRPath expression for + * extracting the value used to identify if the current resource is Root. The key is the FHIRPath + * expression while value is the content to compare against. + */ +@Serializable +@Parcelize +data class MultiSelectViewConfig( + val resourceConfig: FhirResourceConfig, + val parentIdFhirPathExpression: String, + val contentFhirPathExpression: String, + val rootNodeFhirPathExpression: KeyValueConfig, +) : java.io.Serializable, Parcelable diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/datastore/serializers/SerializerConstants.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/domain/model/SyncLocationToggleableState.kt similarity index 69% rename from android/engine/src/main/java/org/smartregister/fhircore/engine/datastore/serializers/SerializerConstants.kt rename to android/engine/src/main/java/org/smartregister/fhircore/engine/domain/model/SyncLocationToggleableState.kt index f2bb36eadb4..d4cda89cc43 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/datastore/serializers/SerializerConstants.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/domain/model/SyncLocationToggleableState.kt @@ -14,8 +14,13 @@ * limitations under the License. */ -package org.smartregister.fhircore.engine.datastore.serializers +package org.smartregister.fhircore.engine.domain.model -object SerializerConstants { - const val PROTOSTORE_SERIALIZER_TAG = "Proto DataStore" -} +import androidx.compose.ui.state.ToggleableState +import kotlinx.serialization.Serializable + +@Serializable +data class SyncLocationToggleableState( + val locationId: String, + val toggleableState: ToggleableState, +) diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/sync/SyncListenerManager.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/sync/SyncListenerManager.kt index 15217043688..e2f5e6cdaa8 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/sync/SyncListenerManager.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/sync/SyncListenerManager.kt @@ -16,13 +16,18 @@ package org.smartregister.fhircore.engine.sync +import android.content.Context +import androidx.compose.ui.state.ToggleableState import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner import com.google.android.fhir.sync.SyncJobStatus +import dagger.hilt.android.qualifiers.ApplicationContext import java.lang.ref.WeakReference import javax.inject.Inject import javax.inject.Singleton +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.runBlocking import org.hl7.fhir.r4.model.Parameters import org.hl7.fhir.r4.model.ResourceType import org.hl7.fhir.r4.model.SearchParameter @@ -30,6 +35,8 @@ import org.smartregister.fhircore.engine.configuration.ConfigType import org.smartregister.fhircore.engine.configuration.ConfigurationRegistry import org.smartregister.fhircore.engine.configuration.app.ApplicationConfiguration import org.smartregister.fhircore.engine.configuration.app.ConfigService +import org.smartregister.fhircore.engine.datastore.syncLocationIdsProtoStore +import org.smartregister.fhircore.engine.util.DefaultDispatcherProvider import org.smartregister.fhircore.engine.util.SharedPreferenceKey import org.smartregister.fhircore.engine.util.SharedPreferencesHelper import timber.log.Timber @@ -45,8 +52,14 @@ constructor( val configService: ConfigService, val configurationRegistry: ConfigurationRegistry, val sharedPreferencesHelper: SharedPreferencesHelper, + @ApplicationContext val context: Context, + val dispatcherProvider: DefaultDispatcherProvider, ) { - + private val appConfig by lazy { + configurationRegistry.retrieveConfiguration( + ConfigType.Application, + ) + } private val syncConfig by lazy { configurationRegistry.retrieveResourceConfiguration(ConfigType.Sync) } @@ -87,10 +100,7 @@ constructor( /** Retrieve registry sync params */ fun loadSyncParams(): Map> { - val pairs = mutableListOf>>() - - val appConfig = - configurationRegistry.retrieveConfiguration(ConfigType.Application) + val pairs = mutableListOf>>() val organizationResourceTag = configService.defineResourceTags().find { it.type == ResourceType.Organization.name } @@ -143,7 +153,8 @@ constructor( pairs.add( Pair( resourceType, - expressionValue?.let { mapOf(sp.code to expressionValue) } ?: mapOf(), + expressionValue?.let { mutableMapOf(sp.code to expressionValue) } + ?: mutableMapOf(), ), ) } else { @@ -151,7 +162,7 @@ constructor( // add another parameter if there is a matching resource type // e.g. [(Patient, {organization=105})] to [(Patient, {organization=105, // _count=100})] - val updatedPair = pair.second.toMutableMap().apply { put(sp.code, expressionValue) } + val updatedPair = pair.second.apply { put(sp.code, expressionValue) } val index = pairs.indexOfFirst { it.first == resourceType } pairs.set(index, Pair(resourceType, updatedPair)) } @@ -159,8 +170,24 @@ constructor( } } + // Set sync locations Location query params + runBlocking { + context.syncLocationIdsProtoStore.data + .firstOrNull() + ?.filter { it.toggleableState == ToggleableState.On } + ?.map { it.locationId } + .takeIf { !it.isNullOrEmpty() } + ?.let { locationIds -> + pairs.forEach { it.second[SYNC_LOCATION_IDS] = locationIds.joinToString(",") } + } + } + Timber.i("SYNC CONFIG $pairs") return mapOf(*pairs.toTypedArray()) } + + companion object { + private const val SYNC_LOCATION_IDS = "_syncLocations" + } } diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/multiselect/MultiSelectView.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/multiselect/MultiSelectView.kt new file mode 100644 index 00000000000..81e99ee8c75 --- /dev/null +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/multiselect/MultiSelectView.kt @@ -0,0 +1,138 @@ +/* + * Copyright 2021-2024 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.ui.multiselect + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.CheckboxDefaults +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.TriStateCheckbox +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowRight +import androidx.compose.material.icons.filled.ArrowDropDown +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.state.ToggleableState +import androidx.compose.ui.unit.dp +import java.util.LinkedList + +@Composable +fun ColumnScope.MultiSelectView( + rootTreeNode: TreeNode, + selectedNodes: MutableMap, + depth: Int = 0, + content: @Composable (TreeNode) -> Unit, +) { + val collapsedState = remember { mutableStateOf(false) } + MultiSelectCheckbox( + selectedNodes = selectedNodes, + currentTreeNode = rootTreeNode, + depth = depth, + content = content, + collapsedState = collapsedState, + ) + if (collapsedState.value) { + rootTreeNode.children.forEach { + MultiSelectView( + rootTreeNode = it, + selectedNodes = selectedNodes, + depth = depth + 16, + content = content, + ) + } + } +} + +@Composable +fun MultiSelectCheckbox( + selectedNodes: MutableMap, + currentTreeNode: TreeNode, + depth: Int, + content: @Composable (TreeNode) -> Unit, + collapsedState: MutableState, +) { + val checked = remember { mutableStateOf(false) } + Column { + Row( + modifier = Modifier.fillMaxWidth().padding(start = depth.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (currentTreeNode.children.isNotEmpty()) { + Icon( + imageVector = + if (collapsedState.value) { + Icons.Default.ArrowDropDown + } else Icons.AutoMirrored.Filled.ArrowRight, + contentDescription = null, + tint = Color.Gray, + modifier = Modifier.clickable { collapsedState.value = !collapsedState.value }, + ) + } + + TriStateCheckbox( + state = selectedNodes[currentTreeNode.id] ?: ToggleableState.Off, + onClick = { + selectedNodes[currentTreeNode.id] = + ToggleableState(selectedNodes[currentTreeNode.id] != ToggleableState.On) + checked.value = selectedNodes[currentTreeNode.id] == ToggleableState.On + + var toggleableState: ToggleableState + var parent = currentTreeNode.parent + while (parent != null) { + toggleableState = ToggleableState.Indeterminate + if ( + parent.children.all { + selectedNodes[it.id] == ToggleableState.Off || selectedNodes[it.id] == null + } + ) { + toggleableState = ToggleableState.Off + } + if (parent.children.all { selectedNodes[it.id] == ToggleableState.On }) { + toggleableState = ToggleableState.On + } + selectedNodes[parent.id] = toggleableState + parent = parent.parent + } + + // Select all the nested checkboxes + val linkedList = LinkedList(currentTreeNode.children) + + while (linkedList.isNotEmpty()) { + val currentNode = linkedList.removeFirst() + selectedNodes[currentNode.id] = ToggleableState(checked.value) + currentNode.children.forEach { linkedList.add(it) } + } + }, + modifier = Modifier.padding(0.dp), + colors = CheckboxDefaults.colors(checkedColor = MaterialTheme.colors.primary), + interactionSource = remember { MutableInteractionSource() }, + ) + content(currentTreeNode) + } + } +} diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/multiselect/TreeBuilder.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/multiselect/TreeBuilder.kt new file mode 100644 index 00000000000..fb7738e8e08 --- /dev/null +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/multiselect/TreeBuilder.kt @@ -0,0 +1,64 @@ +/* + * Copyright 2021-2024 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.ui.multiselect + +import androidx.compose.runtime.Stable + +@Stable +class TreeNode( + val id: String, + var parent: TreeNode?, + val data: T, + val children: MutableList> = mutableListOf(), +) + +object TreeBuilder { + + /** This function creates and return a list of root [TreeNode]'s */ + fun buildTrees( + items: List>, + rootNodeIds: Set, + ): List> { + val lookupMap = mutableMapOf>() + items.forEach { item -> + val childNode = findOrCreate(item, lookupMap) + val parentNode = findOrCreate(item.parent, lookupMap) + if (parentNode != null && childNode != null) { + parentNode.children.add(childNode) + childNode.parent = parentNode + } + } + return rootNodeIds.mapNotNull { lookupMap[it] } + } + + private fun findOrCreate( + treeNode: TreeNode?, + lookupMap: MutableMap>, + ): TreeNode? { + treeNode?.let { node -> + return lookupMap.getOrPut(node.id) { + TreeNode( + id = node.id, + parent = null, + data = node.data, + children = mutableListOf(), + ) + } + } + return null + } +} diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/theme/Colors.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/theme/Colors.kt index efb05d50898..48d88a21550 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/theme/Colors.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/theme/Colors.kt @@ -43,10 +43,22 @@ val MenuActionButtonTextColor = Color(0xFF28B8F9) val MenuItemColor = Color(0xFFBFBFBF) val SearchHeaderColor = Color(0xFFF2F4F7) private val PrimaryColor = Color(0xFF0077CC) +private val SecondaryColor = Color(0xFFF8DF4B) +private val SurfaceColor = Color(0xFFFFFFFF) private val PrimaryVariantColor = Color(0xFF006BBA) val LightColors = - lightColors(primary = PrimaryColor, primaryVariant = PrimaryVariantColor, error = DangerColor) + lightColors( + primary = PrimaryColor, + primaryVariant = PrimaryVariantColor, + error = DangerColor, + secondary = SecondaryColor, + ) val DarkColors = - darkColors(primary = PrimaryColor, primaryVariant = PrimaryVariantColor, error = DangerColor) + darkColors( + primary = PrimaryColor, + primaryVariant = PrimaryVariantColor, + error = DangerColor, + secondary = SecondaryColor, + ) diff --git a/android/engine/src/main/res/values/strings.xml b/android/engine/src/main/res/values/strings.xml index 6dca37709b5..1e267d62db8 100644 --- a/android/engine/src/main/res/values/strings.xml +++ b/android/engine/src/main/res/values/strings.xml @@ -4,6 +4,8 @@ Log out as Show overdue Search name or ID + Search for name + SYNC DATA SCAN BARCODE No Results Sorry, we could not find client with given name or ID @@ -123,6 +125,7 @@ UPCOMING SERVICES SERVICE CARD Other patients + Select location RESPONSES (%1$s) Attempted to login with a different provider Please wait… @@ -180,4 +183,5 @@ ADD Started data migration from version %1$d Application data migrated to version %1$d + No data set diff --git a/android/engine/src/test/assets/locations.json b/android/engine/src/test/assets/locations.json new file mode 100644 index 00000000000..5ac8070e767 --- /dev/null +++ b/android/engine/src/test/assets/locations.json @@ -0,0 +1,628 @@ +{ + "resourceType": "Bundle", + "id": "c924070b-c44e-4d66-828b-7b58008f9862", + "type": "batch-response", + "link": [ + { + "relation": "self", + "url": "http://test-localhost.local" + } + ], + "entry": [ + { + "resource": { + "resourceType": "Location", + "id": "eff94f33-c356-4634-8795-d52340706ba9", + "meta": { + "versionId": "1", + "lastUpdated": "2023-02-22T16:03:03.752+00:00", + "source": "#797f2c80a50102e1" + }, + "identifier": [ + { + "use": "official", + "value": "eff94f33-c356-4634-8795-d52340706ba9" + } + ], + "type": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/v3-RoleCode", + "code": "csc", + "display": "Community Service Center" + } + ] + }, + "status": "active", + "name": "Nairobi", + "alias": [ + "Kanairo" + ], + "description": "This is Nairobi county", + "physicalType": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/location-physical-type", + "code": "jdn", + "display": "Jurisdiction" + } + ] + } + } + }, + { + "resource": { + "resourceType": "Location", + "id": "25c56dd5-4dca-449d-bf6e-665f90d0ff77", + "meta": { + "versionId": "1", + "lastUpdated": "2023-02-22T16:03:03.752+00:00", + "source": "#797f2c80a50102e1" + }, + "identifier": [ + { + "use": "official", + "value": "25c56dd5-4dca-449d-bf6e-665f90d0ff77" + } + ], + "type": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/v3-RoleCode", + "code": "csc", + "display": "Community Service Center" + } + ] + }, + "status": "active", + "name": "Dagoreti North", + "alias": [ + "Dagoreti North" + ], + "description": "This is Dagoreti North within Nairobi", + "physicalType": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/location-physical-type", + "code": "jdn", + "display": "Jurisdiction" + } + ] + }, + "partOf": { + "reference": "Location/eff94f33-c356-4634-8795-d52340706ba9" + } + } + }, + { + "resource": { + "resourceType": "Location", + "id": "f15ff8ab-9475-4356-8363-7f518fdd66ce", + "meta": { + "versionId": "1", + "lastUpdated": "2023-02-22T16:03:03.752+00:00", + "source": "#797f2c80a50102e1" + }, + "identifier": [ + { + "use": "official", + "value": "f15ff8ab-9475-4356-8363-7f518fdd66ce" + } + ], + "type": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/v3-RoleCode", + "code": "work", + "display": "Work Site" + }, + { + "system": "http://terminology.hl7.org/CodeSystem/v3-RoleCode", + "code": "csb1", + "display": "CSB1" + } + ] + }, + "status": "active", + "name": "One Padmore Place", + "alias": [ + "One Padamore" + ], + "description": "This is One Padmore in Kilimani", + "physicalType": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/location-physical-type", + "code": "bu", + "display": "Building" + } + ] + }, + "partOf": { + "reference": "Location/25c56dd5-4dca-449d-bf6e-665f90d0ff77" + }, + "position": { + "longitude": 36.79213832441258, + "latitude": -1.2960023988225766 + } + } + }, + { + "resource": { + "resourceType": "Location", + "id": "5c70635b-286d-4a65-8275-3169554b7ee8", + "meta": { + "versionId": "1", + "lastUpdated": "2023-02-22T16:03:03.752+00:00", + "source": "#797f2c80a50102e1" + }, + "identifier": [ + { + "use": "official", + "value": "5c70635b-286d-4a65-8275-3169554b7ee8" + } + ], + "type": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/v3-RoleCode", + "code": "work", + "display": "Work Site" + }, + { + "system": "http://terminology.hl7.org/CodeSystem/v3-RoleCode", + "code": "bsd", + "display": "BSD" + } + ] + }, + "status": "active", + "name": "Studio House", + "alias": [ + "Studio House" + ], + "description": "This is studio house in kilimani", + "physicalType": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/location-physical-type", + "code": "bu", + "display": "Building" + } + ] + }, + "partOf": { + "reference": "Location/25c56dd5-4dca-449d-bf6e-665f90d0ff77" + }, + "position": { + "longitude": 36.791180231756776, + "latitude": -1.2932783526742726 + } + } + }, + { + "resource": { + "resourceType": "Location", + "id": "493f46d8-6dfe-4505-ab63-9d78c789400e", + "meta": { + "versionId": "1", + "lastUpdated": "2023-02-22T16:03:03.752+00:00", + "source": "#797f2c80a50102e1" + }, + "identifier": [ + { + "use": "official", + "value": "493f46d8-6dfe-4505-ab63-9d78c789400e" + } + ], + "type": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/v3-RoleCode", + "code": "work", + "display": "Work Site" + }, + { + "system": "http://terminology.hl7.org/CodeSystem/v3-RoleCode", + "code": "chrd1", + "display": "CHRD1" + } + ] + }, + "status": "active", + "name": "Bishop Magua", + "alias": [ + "Bishop Magua" + ], + "description": "This is Bishop Magua in kilimani", + "physicalType": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/location-physical-type", + "code": "bu", + "display": "Building" + } + ] + }, + "partOf": { + "reference": "Location/25c56dd5-4dca-449d-bf6e-665f90d0ff77" + }, + "position": { + "longitude": 36.7908806585347, + "latitude": -1.2988988476440322 + } + } + }, + { + "resource": { + "resourceType": "Location", + "id": "20bef46f-b5f2-490f-beca-d9fa6205be06", + "meta": { + "versionId": "1", + "lastUpdated": "2023-02-22T16:03:03.752+00:00", + "source": "#797f2c80a50102e1" + }, + "identifier": [ + { + "use": "official", + "value": "20bef46f-b5f2-490f-beca-d9fa6205be06" + } + ], + "type": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/v3-RoleCode", + "code": "work", + "display": "Work Site" + }, + { + "system": "http://terminology.hl7.org/CodeSystem/v3-RoleCode", + "code": "meah", + "display": "MEAH" + } + ] + }, + "status": "active", + "name": "Yaya Centre", + "alias": [ + "Yaya Centre" + ], + "description": "This is Yaya Centre in kilimani", + "physicalType": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/location-physical-type", + "code": "bu", + "display": "Building" + } + ] + }, + "partOf": { + "reference": "Location/25c56dd5-4dca-449d-bf6e-665f90d0ff77" + }, + "position": { + "longitude": 36.787517583606544, + "latitude": -1.2926708714152355 + } + } + }, + { + "resource": { + "resourceType": "Location", + "id": "077b72ab-19f1-44d9-ae27-7b2796a3c1da", + "meta": { + "versionId": "1", + "lastUpdated": "2023-02-22T16:03:03.752+00:00", + "source": "#797f2c80a50102e1" + }, + "identifier": [ + { + "use": "official", + "value": "077b72ab-19f1-44d9-ae27-7b2796a3c1da" + } + ], + "type": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/v3-RoleCode", + "code": "work", + "display": "Work Site" + }, + { + "system": "http://terminology.hl7.org/CodeSystem/v3-RoleCode", + "code": "epp", + "display": "EPP" + } + ] + }, + "status": "active", + "name": "Ashaki Grill", + "alias": [ + "Ashaki Grill" + ], + "description": "This is Ashaki Grill in kilimani", + "physicalType": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/location-physical-type", + "code": "bu", + "display": "Building" + } + ] + }, + "partOf": { + "reference": "Location/25c56dd5-4dca-449d-bf6e-665f90d0ff77" + }, + "position": { + "longitude": 36.79421363691832, + "latitude": -1.297417288903469 + } + } + }, + { + "resource": { + "resourceType": "Location", + "id": "ee69ce9e-e998-48b0-b3ec-1b5459ee51e0", + "meta": { + "versionId": "1", + "lastUpdated": "2023-02-22T16:03:03.752+00:00", + "source": "#797f2c80a50102e1" + }, + "identifier": [ + { + "use": "official", + "value": "ee69ce9e-e998-48b0-b3ec-1b5459ee51e0" + } + ], + "type": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/v3-RoleCode", + "code": "work", + "display": "Work Site" + }, + { + "system": "http://terminology.hl7.org/CodeSystem/v3-RoleCode", + "code": "warehouse", + "display": "Warehouse" + } + ] + }, + "status": "active", + "name": "Aible Pharmacy", + "alias": [ + "Aible Pharmacy" + ], + "description": "This is Aible Pharmacy in kilimani", + "physicalType": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/location-physical-type", + "code": "bu", + "display": "Building" + } + ] + }, + "partOf": { + "reference": "Location/25c56dd5-4dca-449d-bf6e-665f90d0ff77" + }, + "position": { + "longitude": 36.78973149702598, + "latitude": -1.292916994341975 + } + } + }, + { + "resource": { + "resourceType": "Location", + "id": "590cb1f9-4263-4ea5-8216-24c6cb845c86", + "meta": { + "versionId": "1", + "lastUpdated": "2023-02-22T16:03:03.752+00:00", + "source": "#797f2c80a50102e1" + }, + "identifier": [ + { + "use": "official", + "value": "590cb1f9-4263-4ea5-8216-24c6cb845c86" + } + ], + "type": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/v3-RoleCode", + "code": "work", + "display": "Work Site" + }, + { + "system": "http://terminology.hl7.org/CodeSystem/v3-RoleCode", + "code": "water_point", + "display": "Water Point" + } + ] + }, + "status": "active", + "name": "Maa Hotel Suites", + "alias": [ + "Maa Hotel Suites" + ], + "description": "This is Maa Hotel Suites in kilimani", + "physicalType": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/location-physical-type", + "code": "bu", + "display": "Building" + } + ] + }, + "partOf": { + "reference": "Location/25c56dd5-4dca-449d-bf6e-665f90d0ff77" + }, + "position": { + "longitude": 36.79909168044962, + "latitude": -1.2926893106286492 + } + } + }, + { + "resource": { + "resourceType": "Location", + "id": "d452c392-26bc-4fbf-abba-6c2048f12461", + "meta": { + "versionId": "1", + "lastUpdated": "2023-02-22T16:03:03.752+00:00", + "source": "#797f2c80a50102e1" + }, + "identifier": [ + { + "use": "official", + "value": "d452c392-26bc-4fbf-abba-6c2048f12461" + } + ], + "type": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/v3-RoleCode", + "code": "work", + "display": "Work Site" + }, + { + "system": "http://terminology.hl7.org/CodeSystem/v3-RoleCode", + "code": "men", + "display": "MEN" + } + ] + }, + "status": "active", + "name": "Signeex Kenya", + "alias": [ + "Signeex Kenya" + ], + "description": "This is Signeex Kenya in kilimani", + "physicalType": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/location-physical-type", + "code": "bu", + "display": "Building" + } + ] + }, + "partOf": { + "reference": "Location/25c56dd5-4dca-449d-bf6e-665f90d0ff77" + }, + "position": { + "longitude": 36.785973759155176, + "latitude": -1.293804960628795 + } + } + }, + { + "resource": { + "resourceType": "Location", + "id": "ac0140bb-2769-4ca4-96d2-462bb54e7996", + "meta": { + "versionId": "1", + "lastUpdated": "2023-02-22T16:03:03.752+00:00", + "source": "#797f2c80a50102e1" + }, + "identifier": [ + { + "use": "official", + "value": "ac0140bb-2769-4ca4-96d2-462bb54e7996" + } + ], + "type": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/v3-RoleCode", + "code": "work", + "display": "Work Site" + }, + { + "system": "http://terminology.hl7.org/CodeSystem/v3-RoleCode", + "code": "ngo_partner", + "display": "NGO Partner" + } + ] + }, + "status": "active", + "name": "French School", + "alias": [ + "French School" + ], + "description": "This is French School in kilimani", + "physicalType": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/location-physical-type", + "code": "bu", + "display": "Building" + } + ] + }, + "partOf": { + "reference": "Location/25c56dd5-4dca-449d-bf6e-665f90d0ff77" + }, + "position": { + "longitude": 36.78900903307933, + "latitude": -1.2937221060000694 + } + } + }, + { + "resource": { + "resourceType": "Location", + "id": "c20fa259-1beb-42ad-b186-b8a7d18fc622", + "meta": { + "versionId": "1", + "lastUpdated": "2023-02-22T16:03:03.752+00:00", + "source": "#797f2c80a50102e1" + }, + "identifier": [ + { + "use": "official", + "value": "c20fa259-1beb-42ad-b186-b8a7d18fc622" + } + ], + "type": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/v3-RoleCode", + "code": "work", + "display": "Work Site" + }, + { + "system": "http://terminology.hl7.org/CodeSystem/v3-RoleCode", + "code": "site_communautaire", + "display": "Site Communautaire" + } + ] + }, + "status": "active", + "name": "Coptic Hospital", + "alias": [ + "Coptic Hospital" + ], + "description": "This is Coptic Hospital in kilimani", + "physicalType": { + "coding": [ + { + "system": "http://terminology.hl7.org/CodeSystem/location-physical-type", + "code": "bu", + "display": "Building" + } + ] + }, + "partOf": { + "reference": "Location/25c56dd5-4dca-449d-bf6e-665f90d0ff77" + }, + "position": { + "longitude": 36.797607567050235, + "latitude": -1.29757628715006 + } + } + } + ] +} \ No newline at end of file diff --git a/android/engine/src/test/java/org/smartregister/fhircore/engine/sync/SyncBroadcasterTest.kt b/android/engine/src/test/java/org/smartregister/fhircore/engine/sync/SyncBroadcasterTest.kt index 8fa8c4ebcdf..215f716330b 100644 --- a/android/engine/src/test/java/org/smartregister/fhircore/engine/sync/SyncBroadcasterTest.kt +++ b/android/engine/src/test/java/org/smartregister/fhircore/engine/sync/SyncBroadcasterTest.kt @@ -26,7 +26,6 @@ import io.mockk.mockk import io.mockk.spyk import javax.inject.Inject import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.runTest import org.hl7.fhir.r4.model.ResourceType import org.junit.Assert import org.junit.Before @@ -37,7 +36,7 @@ import org.smartregister.fhircore.engine.configuration.ConfigurationRegistry import org.smartregister.fhircore.engine.configuration.app.ConfigService import org.smartregister.fhircore.engine.robolectric.RobolectricTest import org.smartregister.fhircore.engine.rule.CoroutineTestRule -import org.smartregister.fhircore.engine.util.DispatcherProvider +import org.smartregister.fhircore.engine.util.DefaultDispatcherProvider import org.smartregister.fhircore.engine.util.SharedPreferenceKey import org.smartregister.fhircore.engine.util.SharedPreferencesHelper import org.smartregister.fhircore.engine.util.extension.isIn @@ -54,7 +53,7 @@ class SyncBroadcasterTest : RobolectricTest() { @Inject lateinit var configService: ConfigService - @Inject lateinit var dispatcherProvider: DispatcherProvider + @Inject lateinit var dispatcherProvider: DefaultDispatcherProvider private val configurationRegistry: ConfigurationRegistry = Faker.buildTestConfigurationRegistry() private val fhirEngine = mockk() private lateinit var syncListenerManager: SyncListenerManager @@ -68,8 +67,10 @@ class SyncBroadcasterTest : RobolectricTest() { syncListenerManager = SyncListenerManager( configService = configService, - sharedPreferencesHelper = sharedPreferencesHelper, configurationRegistry = configurationRegistry, + sharedPreferencesHelper = sharedPreferencesHelper, + context = ApplicationProvider.getApplicationContext(), + dispatcherProvider = dispatcherProvider, ) syncBroadcaster = @@ -84,8 +85,6 @@ class SyncBroadcasterTest : RobolectricTest() { ) } - @Test fun testRunSyncWorksAsExpected() = runTest {} - @Test fun testLoadSyncParamsShouldLoadFromConfiguration() { sharedPreferencesHelper.write(ResourceType.CareTeam.name, listOf("1")) diff --git a/android/engine/src/test/java/org/smartregister/fhircore/engine/sync/SyncListenerManagerTest.kt b/android/engine/src/test/java/org/smartregister/fhircore/engine/sync/SyncListenerManagerTest.kt index edb0f3a6708..60f41ecef1f 100644 --- a/android/engine/src/test/java/org/smartregister/fhircore/engine/sync/SyncListenerManagerTest.kt +++ b/android/engine/src/test/java/org/smartregister/fhircore/engine/sync/SyncListenerManagerTest.kt @@ -32,6 +32,7 @@ import org.smartregister.fhircore.engine.app.fakes.Faker import org.smartregister.fhircore.engine.configuration.ConfigurationRegistry import org.smartregister.fhircore.engine.configuration.app.ConfigService import org.smartregister.fhircore.engine.robolectric.RobolectricTest +import org.smartregister.fhircore.engine.util.DefaultDispatcherProvider import org.smartregister.fhircore.engine.util.SharedPreferencesHelper import org.smartregister.fhircore.engine.util.test.HiltActivityForTest @@ -40,17 +41,16 @@ class SyncListenerManagerTest : RobolectricTest() { @get:Rule(order = 0) val hiltAndroidRule = HiltAndroidRule(this) - private lateinit var syncListenerManager: SyncListenerManager - - private val configurationRegistry: ConfigurationRegistry = Faker.buildTestConfigurationRegistry() - @Inject lateinit var sharedPreferencesHelper: SharedPreferencesHelper @Inject lateinit var configService: ConfigService - private val activityController = Robolectric.buildActivity(HiltActivityForTest::class.java) + @Inject lateinit var dispatcherProvider: DefaultDispatcherProvider + private lateinit var syncListenerManager: SyncListenerManager private lateinit var hiltActivityForTest: HiltActivityForTest + private val configurationRegistry: ConfigurationRegistry = Faker.buildTestConfigurationRegistry() + private val activityController = Robolectric.buildActivity(HiltActivityForTest::class.java) @Before fun setUp() { @@ -62,6 +62,8 @@ class SyncListenerManagerTest : RobolectricTest() { configService = configService, sharedPreferencesHelper = sharedPreferencesHelper, configurationRegistry = configurationRegistry, + context = ApplicationProvider.getApplicationContext(), + dispatcherProvider = dispatcherProvider, ) } diff --git a/android/engine/src/test/java/org/smartregister/fhircore/engine/ui/multiselect/TreeBuilderTest.kt b/android/engine/src/test/java/org/smartregister/fhircore/engine/ui/multiselect/TreeBuilderTest.kt new file mode 100644 index 00000000000..ed724f31016 --- /dev/null +++ b/android/engine/src/test/java/org/smartregister/fhircore/engine/ui/multiselect/TreeBuilderTest.kt @@ -0,0 +1,103 @@ +/* + * Copyright 2021-2024 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.ui.multiselect + +import android.app.Application +import androidx.test.core.app.ApplicationProvider +import ca.uhn.fhir.context.FhirContext +import com.google.android.fhir.logicalId +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import javax.inject.Inject +import org.hl7.fhir.r4.model.Bundle +import org.hl7.fhir.r4.model.Location +import org.junit.Assert +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.smartregister.fhircore.engine.robolectric.RobolectricTest +import org.smartregister.fhircore.engine.util.extension.extractId + +@HiltAndroidTest +class TreeBuilderTest : RobolectricTest() { + + @get:Rule val hiltAndroidRule = HiltAndroidRule(this) + + @Inject lateinit var fhirContext: FhirContext + + private lateinit var locationTreeNodes: List> + + @Before + fun setUp() { + hiltAndroidRule.inject() + val locationsJson: String = + ApplicationProvider.getApplicationContext() + .assets + .open("locations.json") + .bufferedReader() + .use { it.readText() } + + val treeNodeMap: Map = + fhirContext + .newJsonParser() + .parseResource(Bundle::class.java, locationsJson) + .entry + .map { it.resource as Location } + .associateBy { it.logicalId } + + locationTreeNodes = + treeNodeMap.values.mapNotNull { location -> + if (location.hasPartOf()) { + val parentId = location.partOf.extractId() + val parent = treeNodeMap[parentId] + TreeNode( + id = location.logicalId, + parent = + if (parent != null) { + TreeNode(id = parentId, parent = null, data = parent) + } else { + null + }, + data = location, + ) + } else { + null + } + } + } + + @Test + fun testPopulateLookupMapShouldReturnMapOfTreeNodes() { + val rootTreeNodes: List> = + TreeBuilder.buildTrees(locationTreeNodes, setOf("eff94f33-c356-4634-8795-d52340706ba9")) + Assert.assertTrue(rootTreeNodes.isNotEmpty()) + + val rootLocation = rootTreeNodes.first() + Assert.assertNull(rootLocation.parent) + Assert.assertEquals("eff94f33-c356-4634-8795-d52340706ba9", rootLocation.data.logicalId) + + val locationWithChildren = + rootLocation.children.find { it.id == "25c56dd5-4dca-449d-bf6e-665f90d0ff77" } + Assert.assertNotNull(locationWithChildren) + Assert.assertEquals(10, locationWithChildren?.children?.size) + + // Assert that each child location references the parent; all have same parent id + locationWithChildren?.children?.forEach { + Assert.assertEquals(locationWithChildren.id, it.parent?.id) + } + } +} diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml index e4c504441f2..b27bcf9bd00 100644 --- a/android/gradle/libs.versions.toml +++ b/android/gradle/libs.versions.toml @@ -213,7 +213,7 @@ lifecycle = ["lifecycle-viewmodel-ktx", "lifecycle-viewmodel-compose", "lifecycl okhttp3 = ["okhttp-logging-interceptor", "okhttp"] accompanist = ["accompanist-placeholder", "accompanist-flowlayout"] klint = ["ktlint-cli-ruleset", "ktlint-rule-engine-core"] -compose = ["activity-compose","activity-ktx", "material3", "ui", "ui-tooling-preview", "constraintlayout-compose", "foundation","runtime-livedata"] +compose = ["activity-compose","activity-ktx", "material", "ui", "ui-tooling-preview", "constraintlayout-compose", "foundation","runtime-livedata"] navigation = ["navigation-compose", "navigation-fragment-ktx", "navigation-ui-ktx"] junit-test = ["junit-ktx", "junit"] coroutines = ["kotlinx-coroutines-core", "kotlinx-coroutines-android"] diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/navigation/MainNavigationScreen.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/navigation/MainNavigationScreen.kt index b01bb1d9316..785c9826936 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/navigation/MainNavigationScreen.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/navigation/MainNavigationScreen.kt @@ -24,7 +24,7 @@ sealed class MainNavigationScreen( val route: Int, val showInBottomNav: Boolean = false, ) { - object Home : + data object Home : MainNavigationScreen( R.string.clients, org.smartregister.fhircore.quest.R.drawable.ic_home, @@ -32,7 +32,7 @@ sealed class MainNavigationScreen( true, ) - object Reports : + data object Reports : MainNavigationScreen( R.string.reports, R.drawable.ic_reports, @@ -40,7 +40,7 @@ sealed class MainNavigationScreen( true, ) - object Settings : + data object Settings : MainNavigationScreen( R.string.settings, R.drawable.ic_settings, @@ -48,17 +48,22 @@ sealed class MainNavigationScreen( true, ) - object Profile : + data object Profile : MainNavigationScreen( titleResource = R.string.profile, route = org.smartregister.fhircore.quest.R.id.profileFragment, ) - object GeoWidget : + data object GeoWidget : MainNavigationScreen(route = org.smartregister.fhircore.geowidget.R.id.geoWidgetFragment) - object Insight : + data object Insight : MainNavigationScreen(route = org.smartregister.fhircore.quest.R.id.userInsightScreenFragment) + data object LocationSelector : + MainNavigationScreen( + route = org.smartregister.fhircore.quest.R.id.multiSelectBottomSheetFragment, + ) + fun eventId(id: String) = route.toString() + "_" + id } diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/navigation/NavigationArg.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/navigation/NavigationArg.kt index 47359d0cf67..577dd1dc7a5 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/navigation/NavigationArg.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/navigation/NavigationArg.kt @@ -22,8 +22,8 @@ object NavigationArg { const val PROFILE_ID = "profileId" const val SCREEN_TITLE = "screenTitle" const val RESOURCE_ID = "resourceId" - const val QUESTIONNAIRE_CONFIG = "questionnaireConfig" const val RESOURCE_CONFIG = "resourceConfig" + const val MULTI_SELECT_VIEW_CONFIG = "multiSelectViewConfig" const val CONFIG_ID = "configId" const val REPORT_ID = "reportId" const val PARAMS = "params" 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 26eaee7db8b..e691d6ec116 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 @@ -32,12 +32,15 @@ import com.google.android.fhir.sync.CurrentSyncJobStatus import dagger.hilt.android.AndroidEntryPoint import io.sentry.android.navigation.SentryNavigationListener import javax.inject.Inject +import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.launch import org.hl7.fhir.r4.model.IdType import org.hl7.fhir.r4.model.QuestionnaireResponse import org.smartregister.fhircore.engine.configuration.QuestionnaireConfig import org.smartregister.fhircore.engine.configuration.app.ConfigService +import org.smartregister.fhircore.engine.configuration.app.SyncStrategy import org.smartregister.fhircore.engine.configuration.workflow.ActionTrigger +import org.smartregister.fhircore.engine.datastore.syncLocationIdsProtoStore import org.smartregister.fhircore.engine.sync.OnSyncListener import org.smartregister.fhircore.engine.sync.SyncBroadcaster import org.smartregister.fhircore.engine.sync.SyncListenerManager @@ -132,7 +135,19 @@ open class AppMainActivity : BaseMultiLanguageActivity(), QuestionnaireHandler, lifecycleScope.launch { retrieveAppMainUiState() if (isDeviceOnline()) { - syncBroadcaster.schedulePeriodicSync(applicationConfiguration.syncInterval) + // Do not schedule sync until location selected when strategy is RelatedEntityLocation + // Use applicationConfiguration.usePractitionerAssignedLocationOnSync to identify + // if we need to trigger sync based on assigned locations or not + if (applicationConfiguration.syncStrategy.contains(SyncStrategy.RelatedEntityLocation)) { + if ( + applicationConfiguration.usePractitionerAssignedLocationOnSync || + syncLocationIdsProtoStore.data.firstOrNull()?.isNotEmpty() == true + ) { + triggerSync() + } + } else { + triggerSync() + } } else { showToast( getString(org.smartregister.fhircore.engine.R.string.sync_failed), diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/main/AppMainViewModel.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/main/AppMainViewModel.kt index 9b782be9d2e..e93465fdd99 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/main/AppMainViewModel.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/main/AppMainViewModel.kt @@ -336,6 +336,12 @@ constructor( } } + fun triggerSync() { + viewModelScope.launch { + syncBroadcaster.schedulePeriodicSync(applicationConfiguration.syncInterval) + } + } + suspend fun onQuestionnaireSubmission(questionnaireSubmission: QuestionnaireSubmission) { questionnaireSubmission.questionnaireConfig.taskId?.let { taskId -> val status: Task.TaskStatus = diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/multiselect/MultiSelectBottomSheetFragment.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/multiselect/MultiSelectBottomSheetFragment.kt new file mode 100644 index 00000000000..ab2928ff864 --- /dev/null +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/multiselect/MultiSelectBottomSheetFragment.kt @@ -0,0 +1,90 @@ +/* + * Copyright 2021-2024 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.multiselect + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.Toast +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.ui.platform.ComposeView +import androidx.fragment.app.activityViewModels +import androidx.fragment.app.viewModels +import androidx.navigation.fragment.navArgs +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import dagger.hilt.android.AndroidEntryPoint +import org.smartregister.fhircore.engine.ui.theme.AppTheme +import org.smartregister.fhircore.engine.util.extension.isDeviceOnline +import org.smartregister.fhircore.engine.util.extension.showToast +import org.smartregister.fhircore.quest.ui.main.AppMainViewModel + +@AndroidEntryPoint +class MultiSelectBottomSheetFragment() : BottomSheetDialogFragment() { + + val bottomSheetArgs by navArgs() + val multiSelectViewModel by viewModels() + private val appMainViewModel by activityViewModels() + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + isCancelable = false + val multiSelectViewConfig = bottomSheetArgs.multiSelectViewConfig + if (multiSelectViewConfig != null) { + multiSelectViewModel.populateLookupMap(requireContext(), multiSelectViewConfig) + } + } + + private fun onSelectionDone() { + multiSelectViewModel.saveSelectedLocations(requireContext()) + appMainViewModel.run { + if (requireContext().isDeviceOnline()) { + triggerSync() + } else { + requireContext() + .showToast( + getString(org.smartregister.fhircore.engine.R.string.sync_failed), + Toast.LENGTH_LONG, + ) + } + } + dismiss() + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View { + return ComposeView(requireContext()).apply { + setContent { + AppTheme { + MultiSelectBottomSheetView( + rootTreeNodes = multiSelectViewModel.rootTreeNodes, + selectedNodes = multiSelectViewModel.selectedNodes, + title = bottomSheetArgs.screenTitle, + onDismiss = { dismiss() }, + searchTextState = multiSelectViewModel.searchTextState, + onSearchTextChanged = multiSelectViewModel::onTextChanged, + onSelectionDone = ::onSelectionDone, + search = multiSelectViewModel::search, + isLoading = multiSelectViewModel.flag.observeAsState(), + ) + } + } + } + } +} diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/multiselect/MultiSelectBottomSheetView.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/multiselect/MultiSelectBottomSheetView.kt new file mode 100644 index 00000000000..f2cafb92468 --- /dev/null +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/multiselect/MultiSelectBottomSheetView.kt @@ -0,0 +1,202 @@ +/* + * Copyright 2021-2024 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.multiselect + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.Button +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.Divider +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.MaterialTheme +import androidx.compose.material.OutlinedTextField +import androidx.compose.material.Scaffold +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Clear +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Search +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.State +import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.compose.runtime.snapshots.SnapshotStateMap +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.state.ToggleableState +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.smartregister.fhircore.engine.R +import org.smartregister.fhircore.engine.ui.multiselect.MultiSelectView +import org.smartregister.fhircore.engine.ui.multiselect.TreeNode +import org.smartregister.fhircore.engine.ui.theme.DividerColor + +@Composable +fun MultiSelectBottomSheetView( + rootTreeNodes: SnapshotStateList>, + selectedNodes: SnapshotStateMap, + title: String?, + onDismiss: () -> Unit, + searchTextState: MutableState, + onSearchTextChanged: (String) -> Unit, + onSelectionDone: () -> Unit, + search: () -> Unit, + isLoading: State, +) { + val keyboardController = LocalSoftwareKeyboardController.current + Scaffold( + topBar = { + Column(modifier = Modifier.fillMaxWidth()) { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 16.dp), + ) { + Text( + text = if (title.isNullOrEmpty()) stringResource(R.string.select_location) else title, + textAlign = TextAlign.Start, + fontWeight = FontWeight.Bold, + fontSize = 16.sp, + ) + Icon( + imageVector = Icons.Filled.Clear, + contentDescription = null, + modifier = Modifier.clickable { onDismiss() }, + ) + } + Divider(color = DividerColor, thickness = 1.dp) + OutlinedTextField( + value = searchTextState.value, + onValueChange = { value -> onSearchTextChanged(value) }, + modifier = + Modifier.background(color = Color.Transparent) + .padding(vertical = 16.dp, horizontal = 8.dp) + .fillMaxWidth(), + textStyle = TextStyle(fontSize = 18.sp), + trailingIcon = { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(horizontal = 8.dp), + ) { + if (searchTextState.value.isNotEmpty()) { + IconButton( + onClick = { + keyboardController?.hide() + search() + }, + modifier = Modifier.size(28.dp), + ) { + Icon( + imageVector = Icons.Default.Search, + contentDescription = "", + ) + } + IconButton(onClick = { onSearchTextChanged("") }, modifier = Modifier.size(28.dp)) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = "", + ) + } + } + } + }, + singleLine = true, + placeholder = { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + color = Color(0xff757575), + text = stringResource(id = R.string.search), + ) + } + }, + keyboardActions = + KeyboardActions( + onSearch = { + keyboardController?.hide() + search() + }, + ), + keyboardOptions = + KeyboardOptions(keyboardType = KeyboardType.Text, imeAction = ImeAction.Search), + ) + } + }, + ) { + Box( + modifier = Modifier.fillMaxSize().padding(it), + contentAlignment = Alignment.TopCenter, + ) { + if (isLoading.value == true) { + CircularProgressIndicator( + color = MaterialTheme.colors.primary, + ) + } + if (isLoading.value == false && rootTreeNodes.isEmpty()) { + Text(text = stringResource(R.string.no_results)) + } else { + LazyColumn( + modifier = Modifier.padding(horizontal = 8.dp), + ) { + items(rootTreeNodes, key = { item -> item.id }) { + Column { + MultiSelectView( + rootTreeNode = it, + selectedNodes = selectedNodes, + ) { treeNode -> + Column { Text(text = treeNode.data) } + } + } + } + item { + if (selectedNodes.isNotEmpty() && rootTreeNodes.isNotEmpty()) { + Button( + onClick = { onSelectionDone() }, + modifier = Modifier.fillMaxWidth().padding(vertical = 16.dp, horizontal = 8.dp), + ) { + Text( + text = stringResource(id = R.string.sync_data).uppercase(), + modifier = Modifier.padding(8.dp), + ) + } + } + } + } + } + } + } +} diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/multiselect/MultiSelectViewModel.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/multiselect/MultiSelectViewModel.kt new file mode 100644 index 00000000000..1c2f5a8f956 --- /dev/null +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/multiselect/MultiSelectViewModel.kt @@ -0,0 +1,182 @@ +/* + * Copyright 2021-2024 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.multiselect + +import android.content.Context +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.compose.runtime.snapshots.SnapshotStateMap +import androidx.compose.ui.state.ToggleableState +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.google.android.fhir.logicalId +import dagger.hilt.android.lifecycle.HiltViewModel +import java.util.LinkedList +import javax.inject.Inject +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.launch +import org.smartregister.fhircore.engine.data.local.DefaultRepository +import org.smartregister.fhircore.engine.datastore.syncLocationIdsProtoStore +import org.smartregister.fhircore.engine.domain.model.MultiSelectViewConfig +import org.smartregister.fhircore.engine.domain.model.SyncLocationToggleableState +import org.smartregister.fhircore.engine.ui.multiselect.TreeBuilder +import org.smartregister.fhircore.engine.ui.multiselect.TreeNode +import org.smartregister.fhircore.engine.util.extension.extractLogicalIdUuid +import org.smartregister.fhircore.engine.util.fhirpath.FhirPathDataExtractor + +@HiltViewModel +class MultiSelectViewModel +@Inject +constructor( + val defaultRepository: DefaultRepository, + val fhirPathDataExtractor: FhirPathDataExtractor, +) : ViewModel() { + + val searchTextState: MutableState = mutableStateOf("") + val rootTreeNodes: SnapshotStateList> = SnapshotStateList() + val selectedNodes: SnapshotStateMap = SnapshotStateMap() + val flag = MutableLiveData(false) + private var _rootTreeNodes: List> = mutableListOf() + + fun populateLookupMap(context: Context, multiSelectViewConfig: MultiSelectViewConfig) { + // Mark previously selected nodes + viewModelScope.launch { + flag.postValue(true) + val previouslySelectedNodes = context.syncLocationIdsProtoStore.data.firstOrNull() + if (!previouslySelectedNodes.isNullOrEmpty()) { + previouslySelectedNodes.forEach { selectedNodes[it.locationId] = it.toggleableState } + } + + val resourcesMap = + defaultRepository + .searchResourcesRecursively( + fhirResourceConfig = multiSelectViewConfig.resourceConfig, + filterActiveResources = null, + secondaryResourceConfigs = null, + configRules = null, + ) + .associateByTo(mutableMapOf(), { it.resource.logicalId }, { it.resource }) + val rootNodeIds = mutableSetOf() + + val lookupItems: List> = + resourcesMap.values.map { resource -> + val parentId = + fhirPathDataExtractor + .extractValue( + resource, + multiSelectViewConfig.parentIdFhirPathExpression, + ) + .extractLogicalIdUuid() + val data = + fhirPathDataExtractor + .extractValue( + resource, + multiSelectViewConfig.contentFhirPathExpression, + ) + .extractLogicalIdUuid() + val isRootNode = + fhirPathDataExtractor + .extractValue( + resource, + multiSelectViewConfig.rootNodeFhirPathExpression.key, + ) + .equals(multiSelectViewConfig.rootNodeFhirPathExpression.value, ignoreCase = true) + if (isRootNode) { + rootNodeIds.add(resource.logicalId) + } + + val parentResource = resourcesMap[parentId] + + TreeNode( + id = resource.logicalId, + parent = + if (parentResource != null) { + TreeNode( + id = parentResource.logicalId, + parent = null, + data = + fhirPathDataExtractor + .extractValue( + parentResource, + multiSelectViewConfig.contentFhirPathExpression, + ) + .extractLogicalIdUuid(), + ) + } else { + null + }, + data = data, + ) + } + flag.postValue(false) + _rootTreeNodes = TreeBuilder.buildTrees(lookupItems, rootNodeIds) + rootTreeNodes.addAll(_rootTreeNodes) + } + } + + fun onTextChanged(searchTerm: String) { + searchTextState.value = searchTerm + if (searchTerm.isEmpty() || searchTerm.isBlank()) { + rootTreeNodes.run { + clear() + addAll(_rootTreeNodes) + } + } + } + + fun saveSelectedLocations(context: Context) { + viewModelScope.launch { + context.syncLocationIdsProtoStore.updateData { + selectedNodes.map { SyncLocationToggleableState(it.key, it.value) } + } + } + } + + fun search() { + val searchTerm = searchTextState.value + if (searchTerm.isNotEmpty() && searchTerm.isNotEmpty()) { + val rootTreeNodeMap = mutableMapOf>() + rootTreeNodes.clear() + _rootTreeNodes.forEach { rootTreeNode -> + if ( + rootTreeNode.data.contains(searchTerm, true) && + !rootTreeNodeMap.containsKey(rootTreeNode.id) + ) { + rootTreeNodeMap[rootTreeNode.id] = rootTreeNode + return@forEach + } + val childrenList = LinkedList(rootTreeNode.children) + while (childrenList.isNotEmpty()) { + val currentNode = childrenList.removeFirst() + if (currentNode.data.contains(other = searchTerm, ignoreCase = true)) { + when { + rootTreeNodeMap.containsKey(rootTreeNode.id) -> return@forEach + else -> { + rootTreeNodeMap[rootTreeNode.id] = rootTreeNode + return@forEach + } + } + } + currentNode.children.forEach { childrenList.add(it) } + } + } + rootTreeNodes.addAll(rootTreeNodeMap.values) + } + } +} diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/register/RegisterEvent.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/register/RegisterEvent.kt index 923b4dcec7a..3cf37e27938 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/register/RegisterEvent.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/register/RegisterEvent.kt @@ -19,9 +19,9 @@ package org.smartregister.fhircore.quest.ui.register sealed class RegisterEvent { data class SearchRegister(val searchText: String = "") : RegisterEvent() - object MoveToNextPage : RegisterEvent() + data object MoveToNextPage : RegisterEvent() - object MoveToPreviousPage : RegisterEvent() + data object MoveToPreviousPage : RegisterEvent() - object ResetFilterRecordsCount : RegisterEvent() + data object ResetFilterRecordsCount : RegisterEvent() } diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/register/RegisterScreen.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/register/RegisterScreen.kt index 93e9d5adc18..6d9290df18c 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/register/RegisterScreen.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/register/RegisterScreen.kt @@ -23,12 +23,12 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.Button +import androidx.compose.material.Icon +import androidx.compose.material.Scaffold +import androidx.compose.material.Text import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add -import androidx.compose.material3.Button -import androidx.compose.material3.Icon -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState import androidx.compose.runtime.collectAsState diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/register/RegisterViewModel.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/register/RegisterViewModel.kt index ea5442848e2..1ee778c00f2 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/register/RegisterViewModel.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/register/RegisterViewModel.kt @@ -42,6 +42,7 @@ import org.hl7.fhir.r4.model.Enumerations.DataType import org.hl7.fhir.r4.model.QuestionnaireResponse import org.smartregister.fhircore.engine.configuration.ConfigType import org.smartregister.fhircore.engine.configuration.ConfigurationRegistry +import org.smartregister.fhircore.engine.configuration.app.ApplicationConfiguration import org.smartregister.fhircore.engine.configuration.register.RegisterConfiguration import org.smartregister.fhircore.engine.configuration.register.RegisterFilterField import org.smartregister.fhircore.engine.data.local.register.RegisterRepository @@ -90,6 +91,10 @@ constructor( private val _percentageProgress: MutableSharedFlow = MutableSharedFlow(0) private val _isUploadSync: MutableSharedFlow = MutableSharedFlow(0) + val applicationConfiguration: ApplicationConfiguration by lazy { + configurationRegistry.retrieveConfiguration(ConfigType.Application, paramsMap = emptyMap()) + } + /** * This function paginates the register data. An optional [clearCache] resets the data in the * cache (this is necessary after a questionnaire has been submitted to refresh the register with @@ -449,7 +454,10 @@ constructor( SharedPreferenceKey.LAST_SYNC_TIMESTAMP.name, null, ) - .isNullOrEmpty() && _totalRecordsCount.longValue == 0L, + .isNullOrEmpty() && + _totalRecordsCount.longValue == 0L && + // Do not show progress dialog if initial sync is disabled + applicationConfiguration.usePractitionerAssignedLocationOnSync, registerConfiguration = currentRegisterConfiguration, registerId = registerId, totalRecordsCount = _totalRecordsCount.longValue, diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/shared/components/DividerView.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/shared/components/DividerView.kt index 417ebaf7a5f..22c9a93ac6c 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/shared/components/DividerView.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/shared/components/DividerView.kt @@ -16,7 +16,7 @@ package org.smartregister.fhircore.quest.ui.shared.components -import androidx.compose.material3.Divider +import androidx.compose.material.Divider import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/shared/components/ViewGenerator.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/shared/components/ViewGenerator.kt index 12d1be7f3af..3c5e56f18bd 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/shared/components/ViewGenerator.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/shared/components/ViewGenerator.kt @@ -31,7 +31,7 @@ import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Divider +import androidx.compose.material.Divider import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/usersetting/UserSettingInsightScreen.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/usersetting/UserSettingInsightScreen.kt index 38ea1fbbafc..9c69048c2ce 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/usersetting/UserSettingInsightScreen.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/usersetting/UserSettingInsightScreen.kt @@ -40,12 +40,12 @@ import androidx.compose.material.IconButton import androidx.compose.material.MaterialTheme import androidx.compose.material.OutlinedButton import androidx.compose.material.Scaffold +import androidx.compose.material.Surface import androidx.compose.material.Text import androidx.compose.material.TopAppBar import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.primarySurface -import androidx.compose.material3.Surface import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.ui.Alignment diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/util/extensions/ConfigExtensions.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/util/extensions/ConfigExtensions.kt index c2079d4ffa6..a82dd9f0244 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/util/extensions/ConfigExtensions.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/util/extensions/ConfigExtensions.kt @@ -173,6 +173,14 @@ fun List.handleClickEvent( Toast.LENGTH_LONG, ) } + ApplicationWorkflow.LAUNCH_LOCATION_SELECTOR -> { + val args = + bundleOf( + NavigationArg.SCREEN_TITLE to (actionConfig.display ?: navMenu?.display ?: ""), + NavigationArg.MULTI_SELECT_VIEW_CONFIG to actionConfig.multiSelectViewConfig, + ) + navController.navigate(MainNavigationScreen.LocationSelector.route, args) + } else -> return } } diff --git a/android/quest/src/main/res/navigation/application_nav_graph.xml b/android/quest/src/main/res/navigation/application_nav_graph.xml index eb2eec293d2..84d25c86f63 100644 --- a/android/quest/src/main/res/navigation/application_nav_graph.xml +++ b/android/quest/src/main/res/navigation/application_nav_graph.xml @@ -1,7 +1,6 @@ @@ -87,6 +86,19 @@ + android:label="fragment_user_insight_screen" /> + + + + + diff --git a/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/shared/model/QuestionnaireHandlerTest.kt b/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/shared/model/QuestionnaireHandlerTest.kt index 69143b96744..5c23853489f 100644 --- a/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/shared/model/QuestionnaireHandlerTest.kt +++ b/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/shared/model/QuestionnaireHandlerTest.kt @@ -26,6 +26,7 @@ import io.mockk.coVerify import io.mockk.every import io.mockk.mockk import io.mockk.verify +import kotlin.time.Duration.Companion.seconds import kotlinx.coroutines.test.runTest import org.hl7.fhir.r4.model.Enumerations import org.junit.Before @@ -86,11 +87,12 @@ class QuestionnaireHandlerTest : RobolectricTest() { } @Test - fun testOnSubmitQuestionnaire() = runTest { - val activityResult = mockk(relaxed = true) + fun testOnSubmitQuestionnaire() = + runTest(timeout = 30.seconds) { + val activityResult = mockk(relaxed = true) - (context as QuestionnaireHandler).onSubmitQuestionnaire(activityResult) + (context as QuestionnaireHandler).onSubmitQuestionnaire(activityResult) - coVerify { (context as QuestionnaireHandler).onSubmitQuestionnaire(activityResult) } - } + coVerify { (context as QuestionnaireHandler).onSubmitQuestionnaire(activityResult) } + } }