From a6a62f12d1b5d411a7a789aef1cfbfa3f546cac6 Mon Sep 17 00:00:00 2001 From: Elly Kitoto Date: Wed, 30 Oct 2024 14:58:13 +0300 Subject: [PATCH] Decouple multi select data sync and filter (#3568) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Update top screen menu icons to render multiple Signed-off-by: Elly Kitoto * Decouple syncing and syncing data on multi select view Signed-off-by: Elly Kitoto * Support mutually exclusive select for multi-select widget Signed-off-by: Elly Kitoto * Document class property Signed-off-by: Elly Kitoto * Notify the user of locations missing coordinates Signed-off-by: Elly Kitoto * Improve use experience by notifying the user of locations missing coordinates Signed-off-by: Elly Kitoto * Remove clearing of map on destroy Signed-off-by: Elly Kitoto * Refactor how data is passed between geowidget launcher and the maps fragment Request the data from the map fragment instead of relying on the launcher to publish the data. Previously the launcher could publish data before the subscriber is ready thus leading to data loss. Signed-off-by: Elly Kitoto * Extract constants Signed-off-by: Elly Kitoto * Separate sync and data filter location ids state Signed-off-by: Elly Kitoto * Show no locations selected dialog Signed-off-by: Elly Kitoto * Show no location dialog on missing sync location ids Signed-off-by: Elly Kitoto * ⬆️ Update kujaku dependencies * 🎨 Fix spotless error * Fix refreshing data on map Signed-off-by: Elly Kitoto * Run spotlessApply Signed-off-by: Elly Kitoto * Delete unused method Signed-off-by: Elly Kitoto * Apply default image size Signed-off-by: Elly Kitoto * 🎨 Update the icon arrangement * Fix failing tests Signed-off-by: Elly Kitoto * Run spotlessApply Signed-off-by: Elly Kitoto * Fix icon positioning Signed-off-by: Elly Kitoto --------- Signed-off-by: Elly Kitoto Co-authored-by: Benjamin Mwalimu --- .../configuration/ConfigurationRegistry.kt | 8 +- .../configuration/view/ImageProperties.kt | 2 +- .../engine/data/local/DefaultRepository.kt | 14 +- .../engine/datastore/ProtoDataStore.kt | 8 +- .../domain/model/MultiSelectViewConfig.kt | 10 + .../engine/ui/multiselect/MultiSelectView.kt | 48 ++-- .../util/extension/AndroidExtensions.kt | 29 ++- .../src/main/res/drawable/ic_filter.xml | 9 + .../engine/src/main/res/values/strings.xml | 1 + .../geowidget/screens/GeoJsonDataRequester.kt | 23 ++ .../geowidget/screens/GeoWidgetFragment.kt | 212 ++++++++-------- .../geowidget/screens/GeoWidgetViewModel.kt | 14 -- android/geowidget/src/main/res/values/ids.xml | 1 + .../geowidget/src/main/res/values/strings.xml | 6 - .../screens/GeoWidgetViewModelTest.kt | 4 +- android/gradle/libs.versions.toml | 5 +- android/quest/build.gradle.kts | 2 +- android/quest/src/main/AndroidManifest.xml | 1 + .../fhircore/quest/event/AppEvent.kt | 2 +- .../quest/navigation/MainNavigationScreen.kt | 2 +- .../quest/ui/geowidget/GeoWidgetEvent.kt | 6 +- .../ui/geowidget/GeoWidgetLauncherFragment.kt | 164 +++++-------- .../ui/geowidget/GeoWidgetLauncherScreen.kt | 79 +++--- .../geowidget/GeoWidgetLauncherViewModel.kt | 144 +++++++---- .../quest/ui/main/AppMainViewModel.kt | 8 +- .../ui/main/components/TopScreenSection.kt | 229 +++++++++++------- .../MultiSelectBottomSheetFragment.kt | 82 ++++--- .../multiselect/MultiSelectBottomSheetView.kt | 54 ++++- .../ui/multiselect/MultiSelectViewModel.kt | 41 +++- .../quest/ui/register/RegisterFragment.kt | 2 +- .../quest/ui/shared/components/Image.kt | 7 +- .../quest/util/extensions/ConfigExtensions.kt | 4 +- android/quest/src/main/res/values/strings.xml | 8 +- .../GeoWidgetLauncherViewModelTest.kt | 38 --- .../util/extensions/ConfigExtensionsKtTest.kt | 2 +- 35 files changed, 747 insertions(+), 522 deletions(-) create mode 100644 android/engine/src/main/res/drawable/ic_filter.xml create mode 100644 android/geowidget/src/main/java/org/smartregister/fhircore/geowidget/screens/GeoJsonDataRequester.kt delete mode 100644 android/geowidget/src/main/res/values/strings.xml 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 2c099a831a..f5d22bb5d0 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 @@ -58,6 +58,7 @@ import org.smartregister.fhircore.engine.configuration.app.ApplicationConfigurat import org.smartregister.fhircore.engine.configuration.app.ConfigService import org.smartregister.fhircore.engine.data.remote.fhir.resource.FhirResourceDataSource import org.smartregister.fhircore.engine.di.NetworkModule +import org.smartregister.fhircore.engine.domain.model.MultiSelectViewAction import org.smartregister.fhircore.engine.util.DispatcherProvider import org.smartregister.fhircore.engine.util.SharedPreferenceKey import org.smartregister.fhircore.engine.util.SharedPreferencesHelper @@ -72,7 +73,7 @@ import org.smartregister.fhircore.engine.util.extension.generateMissingId import org.smartregister.fhircore.engine.util.extension.interpolate import org.smartregister.fhircore.engine.util.extension.referenceValue import org.smartregister.fhircore.engine.util.extension.retrieveCompositionSections -import org.smartregister.fhircore.engine.util.extension.retrieveRelatedEntitySyncLocationIds +import org.smartregister.fhircore.engine.util.extension.retrieveRelatedEntitySyncLocationState import org.smartregister.fhircore.engine.util.extension.searchCompositionByIdentifier import org.smartregister.fhircore.engine.util.extension.updateLastUpdated import org.smartregister.fhircore.engine.util.helper.LocalizationHelper @@ -743,7 +744,10 @@ constructor( configService.defineResourceTags().find { it.type == ResourceType.Organization.name } val mandatoryTags = configService.provideResourceTags(sharedPreferencesHelper) - val locationIds = context.retrieveRelatedEntitySyncLocationIds() + val locationIds = + context.retrieveRelatedEntitySyncLocationState(MultiSelectViewAction.SYNC_DATA).map { + it.locationId + } syncConfig.parameter .map { it.resource as SearchParameter } diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/view/ImageProperties.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/view/ImageProperties.kt index 3571c42b58..34e347cc4d 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/view/ImageProperties.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/view/ImageProperties.kt @@ -44,7 +44,7 @@ data class ImageProperties( val tint: String? = null, val text: String? = null, val imageConfig: ImageConfig? = null, - val size: Int? = null, + val size: Int? = 22, val shape: ImageShape? = null, val textColor: String? = null, val actions: List = emptyList(), diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/data/local/DefaultRepository.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/data/local/DefaultRepository.kt index abccc74501..9bad212293 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/data/local/DefaultRepository.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/data/local/DefaultRepository.kt @@ -75,6 +75,7 @@ import org.smartregister.fhircore.engine.configuration.register.ActiveResourceFi import org.smartregister.fhircore.engine.domain.model.Code import org.smartregister.fhircore.engine.domain.model.DataQuery import org.smartregister.fhircore.engine.domain.model.FhirResourceConfig +import org.smartregister.fhircore.engine.domain.model.MultiSelectViewAction import org.smartregister.fhircore.engine.domain.model.RelatedResourceCount import org.smartregister.fhircore.engine.domain.model.RepositoryResourceData import org.smartregister.fhircore.engine.domain.model.ResourceConfig @@ -93,7 +94,7 @@ import org.smartregister.fhircore.engine.util.extension.filterBy import org.smartregister.fhircore.engine.util.extension.filterByResourceTypeId import org.smartregister.fhircore.engine.util.extension.generateMissingId import org.smartregister.fhircore.engine.util.extension.loadResource -import org.smartregister.fhircore.engine.util.extension.retrieveRelatedEntitySyncLocationIds +import org.smartregister.fhircore.engine.util.extension.retrieveRelatedEntitySyncLocationState import org.smartregister.fhircore.engine.util.extension.updateFrom import org.smartregister.fhircore.engine.util.extension.updateLastUpdated import org.smartregister.fhircore.engine.util.fhirpath.FhirPathDataExtractor @@ -934,7 +935,11 @@ constructor( configComputedRuleValues: Map, ) = if (filterByRelatedEntityLocation) { - val syncLocationIds = context.retrieveRelatedEntitySyncLocationIds() + val syncLocationIds = + context.retrieveRelatedEntitySyncLocationState(MultiSelectViewAction.FILTER_DATA).map { + it.locationId + } + val locationIds = syncLocationIds .map { retrieveFlattenedSubLocations(it).map { subLocation -> subLocation.logicalId } } @@ -1017,7 +1022,10 @@ constructor( val configComputedRuleValues = configRules.configRulesComputedValues() if (filterByRelatedEntityLocationMetaTag) { - val syncLocationIds = context.retrieveRelatedEntitySyncLocationIds() + val syncLocationIds = + context.retrieveRelatedEntitySyncLocationState(MultiSelectViewAction.FILTER_DATA).map { + it.locationId + } val locationIds = syncLocationIds .map { retrieveFlattenedSubLocations(it).map { subLocation -> subLocation.logicalId } } 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 1c40595597..bbea209802 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 @@ -36,10 +36,9 @@ 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 LOCATION_COORDINATES_DATASTORE_JSON = "location_coordinates.json" - private const val SYNC_LOCATION_IDS = "sync_location_ids.json" +private const val DATA_FILTER_LOCATION_IDS = "data_filter_location_ids.json" val Context.practitionerProtoStore: DataStore by dataStore( @@ -64,6 +63,11 @@ val Context.syncLocationIdsProtoStore: DataStore> fileName = SYNC_LOCATION_IDS, serializer = SyncLocationIdDataStoreSerializer, ) +val Context.dataFilterLocationIdsProtoStore: DataStore> by + dataStore( + fileName = DATA_FILTER_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/domain/model/MultiSelectViewConfig.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/domain/model/MultiSelectViewConfig.kt index 3da938c5fd..f878256987 100644 --- 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 @@ -29,6 +29,9 @@ import kotlinx.serialization.Serializable * @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. + * @property viewActions The actions to be performed when the multiselect action button is pressed + * @property mutuallyExclusive Setup the multi choice checkbox such that only a single (root level) + * selection can be performed at a time. */ @Serializable @Parcelize @@ -37,4 +40,11 @@ data class MultiSelectViewConfig( val parentIdFhirPathExpression: String, val contentFhirPathExpression: String, val rootNodeFhirPathExpression: KeyValueConfig, + val viewActions: List = listOf(MultiSelectViewAction.FILTER_DATA), + val mutuallyExclusive: Boolean = true, ) : java.io.Serializable, Parcelable + +enum class MultiSelectViewAction { + SYNC_DATA, + FILTER_DATA, +} 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 index 59bb1305d8..0f5bc9a66f 100644 --- 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 @@ -46,6 +46,7 @@ fun ColumnScope.MultiSelectView( rootTreeNode: TreeNode, syncLocationStateMap: MutableMap, depth: Int = 0, + onChecked: () -> Unit, content: @Composable (TreeNode) -> Unit, ) { val collapsedState = remember { mutableStateOf(false) } @@ -55,6 +56,7 @@ fun ColumnScope.MultiSelectView( depth = depth, content = content, collapsedState = collapsedState, + onChecked = onChecked, ) if (collapsedState.value) { rootTreeNode.children.forEach { @@ -63,18 +65,20 @@ fun ColumnScope.MultiSelectView( syncLocationStateMap = syncLocationStateMap, depth = depth + 16, content = content, + onChecked = onChecked, ) } } } @Composable -fun MultiSelectCheckbox( +private fun MultiSelectCheckbox( syncLocationStateMap: MutableMap, currentTreeNode: TreeNode, depth: Int, content: @Composable (TreeNode) -> Unit, collapsedState: MutableState, + onChecked: () -> Unit, ) { val checked = remember { mutableStateOf(false) } Column { @@ -134,19 +138,12 @@ fun MultiSelectCheckbox( parent = parent.parent } - // Select all the nested checkboxes - val treeNodeArrayDeque = ArrayDeque(currentTreeNode.children) - - while (treeNodeArrayDeque.isNotEmpty()) { - val currentNode = treeNodeArrayDeque.removeFirst() - syncLocationStateMap[currentNode.id] = - SyncLocationState( - currentNode.id, - currentNode.parent?.id, - ToggleableState(checked.value), - ) - currentNode.children.forEach { treeNodeArrayDeque.addLast(it) } - } + updateNestedCheckboxState( + currentTreeNode = currentTreeNode, + syncLocationStateMap = syncLocationStateMap, + checked = checked.value, + ) + onChecked() }, modifier = Modifier.padding(0.dp), colors = CheckboxDefaults.colors(checkedColor = MaterialTheme.colors.primary), @@ -156,3 +153,26 @@ fun MultiSelectCheckbox( } } } + +/** + * This function selects/deselects all the children for the [currentTreeNode] based on the value for + * the [checked] parameter. The states for the [MultiSelectCheckbox] is updated in the + * [syncLocationStateMap]. + */ +fun updateNestedCheckboxState( + currentTreeNode: TreeNode, + syncLocationStateMap: MutableMap, + checked: Boolean, +) { + val treeNodeArrayDeque = ArrayDeque(currentTreeNode.children) + while (treeNodeArrayDeque.isNotEmpty()) { + val currentNode = treeNodeArrayDeque.removeFirst() + syncLocationStateMap[currentNode.id] = + SyncLocationState( + locationId = currentNode.id, + parentLocationId = currentNode.parent?.id, + toggleableState = ToggleableState(checked), + ) + currentNode.children.forEach { treeNodeArrayDeque.addLast(it) } + } +} diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/AndroidExtensions.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/AndroidExtensions.kt index 7d873a8d40..6b7156ae96 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/AndroidExtensions.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/AndroidExtensions.kt @@ -39,8 +39,13 @@ import androidx.core.view.WindowInsetsCompat import androidx.core.view.updatePadding import java.io.Serializable import java.util.Locale +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.withContext +import org.smartregister.fhircore.engine.datastore.dataFilterLocationIdsProtoStore import org.smartregister.fhircore.engine.datastore.syncLocationIdsProtoStore +import org.smartregister.fhircore.engine.domain.model.MultiSelectViewAction +import org.smartregister.fhircore.engine.domain.model.SyncLocationState import org.smartregister.fhircore.engine.ui.theme.DangerColor import org.smartregister.fhircore.engine.ui.theme.DefaultColor import org.smartregister.fhircore.engine.ui.theme.InfoColor @@ -221,13 +226,25 @@ inline fun Intent.parcelableArrayList(key: String): Arr else -> @Suppress("DEPRECATION") getParcelableArrayListExtra(key) } -suspend fun Context.retrieveRelatedEntitySyncLocationIds(): List { - val selectedLocationStateMap = this.syncLocationIdsProtoStore.data.firstOrNull() - return selectedLocationStateMap - ?.values - ?.filter { +suspend fun Context.retrieveRelatedEntitySyncLocationState( + multiSelectViewAction: MultiSelectViewAction, + filterToggleableStateOn: Boolean = true, +): List { + val selectedLocationStateMap = + withContext(Dispatchers.IO) { + val context = this@retrieveRelatedEntitySyncLocationState + when (multiSelectViewAction) { + MultiSelectViewAction.SYNC_DATA -> context.syncLocationIdsProtoStore.data.firstOrNull() + MultiSelectViewAction.FILTER_DATA -> + context.dataFilterLocationIdsProtoStore.data.firstOrNull() + } + } + return if (filterToggleableStateOn) { + selectedLocationStateMap?.values?.filter { it.toggleableState == ToggleableState.On && selectedLocationStateMap[it.parentLocationId]?.toggleableState != ToggleableState.On } - ?.map { it.locationId } ?: emptyList() + } else { + selectedLocationStateMap?.values?.toList() + } ?: emptyList() } diff --git a/android/engine/src/main/res/drawable/ic_filter.xml b/android/engine/src/main/res/drawable/ic_filter.xml new file mode 100644 index 0000000000..435835305e --- /dev/null +++ b/android/engine/src/main/res/drawable/ic_filter.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/engine/src/main/res/values/strings.xml b/android/engine/src/main/res/values/strings.xml index 7f112e57f0..cb1930e273 100644 --- a/android/engine/src/main/res/values/strings.xml +++ b/android/engine/src/main/res/values/strings.xml @@ -197,4 +197,5 @@ RETRY There\'s some un-synced data Supervisor contact missing or the provided phone number is invalid + APPLY FILTER diff --git a/android/geowidget/src/main/java/org/smartregister/fhircore/geowidget/screens/GeoJsonDataRequester.kt b/android/geowidget/src/main/java/org/smartregister/fhircore/geowidget/screens/GeoJsonDataRequester.kt new file mode 100644 index 0000000000..ce36c09808 --- /dev/null +++ b/android/geowidget/src/main/java/org/smartregister/fhircore/geowidget/screens/GeoJsonDataRequester.kt @@ -0,0 +1,23 @@ +/* + * 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.geowidget.screens + +import org.smartregister.fhircore.geowidget.model.GeoJsonFeature + +interface GeoJsonDataRequester { + fun requestData(onReceiveData: (List) -> Unit) +} diff --git a/android/geowidget/src/main/java/org/smartregister/fhircore/geowidget/screens/GeoWidgetFragment.kt b/android/geowidget/src/main/java/org/smartregister/fhircore/geowidget/screens/GeoWidgetFragment.kt index f8b49da345..96d69fbf51 100644 --- a/android/geowidget/src/main/java/org/smartregister/fhircore/geowidget/screens/GeoWidgetFragment.kt +++ b/android/geowidget/src/main/java/org/smartregister/fhircore/geowidget/screens/GeoWidgetFragment.kt @@ -26,8 +26,10 @@ import androidx.core.content.ContextCompat import androidx.core.content.res.ResourcesCompat import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentManager -import androidx.fragment.app.viewModels +import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import com.mapbox.geojson.FeatureCollection import com.mapbox.geojson.MultiPoint import com.mapbox.geojson.Point @@ -46,6 +48,7 @@ import io.ona.kujaku.plugin.switcher.BaseLayerSwitcherPlugin import io.ona.kujaku.plugin.switcher.layer.StreetsBaseLayer import io.ona.kujaku.utils.CoordinateUtils import io.ona.kujaku.views.KujakuMapView +import kotlinx.coroutines.launch import org.jetbrains.annotations.VisibleForTesting import org.json.JSONObject import org.smartregister.fhircore.engine.configuration.geowidget.MapLayer @@ -68,11 +71,11 @@ class GeoWidgetFragment : Fragment() { internal var onClickLocationCallback: (GeoJsonFeature, FragmentManager) -> Unit = { _: GeoJsonFeature, _: FragmentManager -> } - internal var useGpsOnAddingLocation: Boolean = false - internal var mapLayers: List = ArrayList() - internal var showCurrentLocationButton: Boolean = true - internal var showPlaneSwitcherButton: Boolean = true - internal var showAddLocationButton: Boolean = true + private var useGpsOnAddingLocation: Boolean = false + private var mapLayers: List = ArrayList() + private var showCurrentLocationButton: Boolean = true + private var showPlaneSwitcherButton: Boolean = true + private var showAddLocationButton: Boolean = true private var mapView: KujakuMapView? = null private lateinit var geoWidgetViewModel: GeoWidgetViewModel @@ -81,7 +84,8 @@ class GeoWidgetFragment : Fragment() { container: ViewGroup?, savedInstanceState: Bundle?, ): View { - setUpMapView(savedInstanceState) + Mapbox.getInstance(requireActivity(), BuildConfig.MAPBOX_SDK_TOKEN) + mapView = setUpMapView() return LinearLayout(requireContext()).apply { orientation = LinearLayout.VERTICAL addView(mapView) @@ -89,13 +93,8 @@ class GeoWidgetFragment : Fragment() { } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + mapView?.onCreate(savedInstanceState) geoWidgetViewModel = ViewModelProvider(this)[GeoWidgetViewModel::class.java] - geoWidgetViewModel.features.observe(viewLifecycleOwner) { result -> - if (result.isNotEmpty()) { - geoWidgetViewModel.updateMapFeatures(result) - zoomMapWithFeatures() - } - } } override fun onStart() { @@ -119,9 +118,9 @@ class GeoWidgetFragment : Fragment() { } override fun onDestroy() { - super.onDestroy() - geoWidgetViewModel.clearMapFeatures() mapView?.onDestroy() + super.onDestroy() + mapView = null } override fun onLowMemory() { @@ -134,31 +133,27 @@ class GeoWidgetFragment : Fragment() { mapView?.onSaveInstanceState(outState) } - private fun setUpMapView(savedInstanceState: Bundle?) { - geoWidgetViewModel = viewModels().value - Mapbox.getInstance(requireContext(), BuildConfig.MAPBOX_SDK_TOKEN) - mapView = - try { - KujakuMapView(requireActivity()).apply { - id = R.id.kujaku_widget - val builder = Style.Builder().fromUri(context.getString(R.string.style_map_fhir_core)) - getMapAsync { mapboxMap -> - mapboxMap.setStyle(builder) { style -> - addIconsLayer(style) - addMapStyle(style) - } + private fun setUpMapView(): KujakuMapView? { + return try { + KujakuMapView(requireContext()).apply { + id = R.id.kujaku_widget + val builder = Style.Builder().fromUri(MAP_STYLE) + getMapAsync { mapboxMap -> + mapboxMap.setStyle(builder) { style -> + addIconsLayer(style) + addMapStyle(style) } + } - if (showAddLocationButton) { - setOnAddLocationListener(this) - } - setOnClickLocationListener(this) + if (showAddLocationButton) { + setOnAddLocationListener(this) } - } catch (mapboxConfigurationException: MapboxConfigurationException) { - Timber.e(mapboxConfigurationException) - null + setOnClickLocationListener(this) } - mapView?.onCreate(savedInstanceState) + } catch (mapboxConfigurationException: MapboxConfigurationException) { + Timber.e(mapboxConfigurationException) + null + } } private fun addIconsLayer(mMapboxMapStyle: Style) { @@ -193,11 +188,7 @@ class GeoWidgetFragment : Fragment() { ) icon?.let { mMapboxMapStyle.addImage(key, icon) - val symbolLayer = - SymbolLayer( - String.format("%s.layer", key), - getString(R.string.data_set_quest), - ) + val symbolLayer = SymbolLayer(String.format("%s.layer", key), DATA_SET) symbolLayer.setProperties( PropertyFactory.iconImage(key), PropertyFactory.iconSize(dynamicIconSize), @@ -237,11 +228,7 @@ class GeoWidgetFragment : Fragment() { val baseKey = "base-image" mMapboxMapStyle.addImage(baseKey, it) - val symbolLayer = - SymbolLayer( - String.format("%s.layer", baseKey), - getString(R.string.data_set_quest), - ) + val symbolLayer = SymbolLayer(String.format("%s.layer", baseKey), DATA_SET) symbolLayer.setProperties( PropertyFactory.iconImage(baseKey), PropertyFactory.iconSize(dynamicBaseIconSize), @@ -249,7 +236,7 @@ class GeoWidgetFragment : Fragment() { PropertyFactory.symbolSortKey(1f), PropertyFactory.iconOffset(arrayOf(0f, 8.5f)), ) - mMapboxMapStyle.addLayerBelow(symbolLayer, getString(R.string.quest_data_points)) + mMapboxMapStyle.addLayerBelow(symbolLayer, DATA_POINTS) } } @@ -313,93 +300,90 @@ class GeoWidgetFragment : Fragment() { private fun zoomMapWithFeatures() { mapView?.getMapAsync { mapboxMap -> - val featureCollection = - FeatureCollection.fromFeatures(geoWidgetViewModel.mapFeatures.toList()) - val locationPoints = - featureCollection - .features() - ?.asSequence() - ?.filter { it.geometry() is Point } - ?.map { it.geometry() as Point } - ?.toMutableList() ?: emptyList() - - val bbox = TurfMeasurement.bbox(MultiPoint.fromLngLats(locationPoints)) - val paddedBbox = CoordinateUtils.getPaddedBbox(bbox, 1000.0) - val bounds = LatLngBounds.from(paddedBbox[3], paddedBbox[2], paddedBbox[1], paddedBbox[0]) - val finalCameraPosition = CameraUpdateFactory.newLatLngBounds(bounds, 50) - - with(mapboxMap) { - (style?.getSourceAs(requireContext().getString(R.string.data_set_quest)) as GeoJsonSource?) - ?.apply { setGeoJson(featureCollection) } - easeCamera(finalCameraPosition) + val features = geoWidgetViewModel.mapFeatures.toList() + if (features.isNotEmpty()) { + val featureCollection = FeatureCollection.fromFeatures(features) + val locationPoints = + featureCollection + .features() + ?.asSequence() + ?.filter { it.geometry() is Point } + ?.map { it.geometry() as Point } + ?.toMutableList() ?: emptyList() + mapboxMap.getStyle { style -> + style.getSourceAs(DATA_SET)?.setGeoJson(featureCollection) + } + val bbox = TurfMeasurement.bbox(MultiPoint.fromLngLats(locationPoints)) + val paddedBbox = CoordinateUtils.getPaddedBbox(bbox, PADDING_IN_METRES) + val bounds = LatLngBounds.from(paddedBbox[3], paddedBbox[2], paddedBbox[1], paddedBbox[0]) + val finalCameraPosition = + CameraUpdateFactory.newLatLngBounds(bounds, CAMERA_POSITION_PADDING) + mapboxMap.easeCamera(finalCameraPosition) } } } - class Builder { - - private var onAddLocationCallback: (GeoJsonFeature) -> Unit = {} - private var onCancelAddingLocationCallback: () -> Unit = {} - private var onClickLocationCallback: (GeoJsonFeature, FragmentManager) -> Unit = - { _: GeoJsonFeature, _: FragmentManager -> - } - private var useGpsOnAddingLocation: Boolean = false - private var mapLayers: List = ArrayList() - private var showCurrentLocationButton: Boolean = true - private var showPlaneSwitcherButton: Boolean = true - private var showAddLocationButton: Boolean = true - - fun setOnAddLocationListener(onAddLocationCallback: (GeoJsonFeature) -> Unit) = apply { - this.onAddLocationCallback = onAddLocationCallback - } + fun setOnAddLocationListener(onAddLocationCallback: (GeoJsonFeature) -> Unit) = apply { + this.onAddLocationCallback = onAddLocationCallback + } - fun setOnCancelAddingLocationListener(onCancelAddingLocationCallback: () -> Unit) = apply { - this.onCancelAddingLocationCallback = onCancelAddingLocationCallback - } + fun setOnCancelAddingLocationListener(onCancelAddingLocationCallback: () -> Unit) = apply { + this.onCancelAddingLocationCallback = onCancelAddingLocationCallback + } - fun setOnClickLocationListener( - onClickLocationCallback: (GeoJsonFeature, FragmentManager) -> Unit, - ) = apply { this.onClickLocationCallback = onClickLocationCallback } + fun setOnClickLocationListener( + onClickLocationCallback: (GeoJsonFeature, FragmentManager) -> Unit, + ) = apply { this.onClickLocationCallback = onClickLocationCallback } - fun setUseGpsOnAddingLocation(value: Boolean) = apply { this.useGpsOnAddingLocation = value } + fun setUseGpsOnAddingLocation(value: Boolean) = apply { this.useGpsOnAddingLocation = value } - fun setMapLayers(list: List) = apply { this.mapLayers = list } + fun setMapLayers(list: List) = apply { this.mapLayers = list } - fun showCurrentLocationButtonVisibility(show: Boolean) = apply { - this.showCurrentLocationButton = show - } + fun showCurrentLocationButtonVisibility(show: Boolean) = apply { + this.showCurrentLocationButton = show + } - fun setAddLocationButtonVisibility(show: Boolean) = apply { this.showAddLocationButton = show } + fun setAddLocationButtonVisibility(show: Boolean) = apply { this.showAddLocationButton = show } - fun setPlaneSwitcherButtonVisibility(show: Boolean) = apply { - this.showPlaneSwitcherButton = show - } + fun setPlaneSwitcherButtonVisibility(show: Boolean) = apply { + this.showPlaneSwitcherButton = show + } - fun build(): GeoWidgetFragment { - return GeoWidgetFragment().apply { - this.onAddLocationCallback = this@Builder.onAddLocationCallback - this.onCancelAddingLocationCallback = this@Builder.onCancelAddingLocationCallback - this.onClickLocationCallback = this@Builder.onClickLocationCallback - this.useGpsOnAddingLocation = this@Builder.useGpsOnAddingLocation - this.mapLayers = this@Builder.mapLayers - this.showCurrentLocationButton = this@Builder.showCurrentLocationButton - this.showPlaneSwitcherButton = this@Builder.showPlaneSwitcherButton - this.showAddLocationButton = this@Builder.showAddLocationButton + fun observerGeoJsonFeatures(mutableLiveData: MutableLiveData>) { + with(viewLifecycleOwner) { + lifecycleScope.launch { + repeatOnLifecycle(androidx.lifecycle.Lifecycle.State.CREATED) { + mutableLiveData.observe(this@with) { geoJsonFeatures -> + if (geoJsonFeatures.isNotEmpty()) { + geoWidgetViewModel.updateMapFeatures(geoJsonFeatures) + zoomMapWithFeatures() + } + } + } } } } - fun submitFeatures(geoJsonFeatures: List) { - if (this::geoWidgetViewModel.isInitialized) { - geoWidgetViewModel.submitFeatures(geoJsonFeatures) + fun observerMapReset(clearMapLiveData: MutableLiveData) { + with(viewLifecycleOwner) { + lifecycleScope.launch { + repeatOnLifecycle(androidx.lifecycle.Lifecycle.State.CREATED) { + clearMapLiveData.observe(this@with) { reset -> + if (reset) { + geoWidgetViewModel.clearMapFeatures() + } + } + } + } } } - fun clearMapFeatures() = geoWidgetViewModel.clearMapFeatures() - companion object { const val MAP_FEATURES_LIMIT = 1000 - - fun builder() = Builder() + const val PADDING_IN_METRES = 1000.0 + const val CAMERA_POSITION_PADDING = 50 + const val MAP_STYLE = "asset://fhircore_style.json" + const val DATA_SET = "quest-data-set" + const val DATA_POINTS = "quest-data-points" } } diff --git a/android/geowidget/src/main/java/org/smartregister/fhircore/geowidget/screens/GeoWidgetViewModel.kt b/android/geowidget/src/main/java/org/smartregister/fhircore/geowidget/screens/GeoWidgetViewModel.kt index ac36ca170c..87520d5630 100644 --- a/android/geowidget/src/main/java/org/smartregister/fhircore/geowidget/screens/GeoWidgetViewModel.kt +++ b/android/geowidget/src/main/java/org/smartregister/fhircore/geowidget/screens/GeoWidgetViewModel.kt @@ -16,8 +16,6 @@ package org.smartregister.fhircore.geowidget.screens -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import com.mapbox.geojson.Feature import org.smartregister.fhircore.geowidget.model.GeoJsonFeature @@ -27,13 +25,6 @@ import org.smartregister.fhircore.geowidget.screens.GeoWidgetFragment.Companion. class GeoWidgetViewModel : ViewModel() { val mapFeatures = ArrayDeque() - private val _features = MutableLiveData>(mutableListOf()) - val features: LiveData> - get() = _features - - fun submitFeatures(geoJsonFeatures: List) { - _features.postValue(geoJsonFeatures) - } fun updateMapFeatures(geoJsonFeatures: List) { if (mapFeatures.size <= MAP_FEATURES_LIMIT) { @@ -77,9 +68,4 @@ class GeoWidgetViewModel : ViewModel() { map[ServicePointType.LYCÉE.name.lowercase()] = ServicePointType.LYCÉE return map } - - override fun onCleared() { - super.onCleared() - clearMapFeatures() - } } diff --git a/android/geowidget/src/main/res/values/ids.xml b/android/geowidget/src/main/res/values/ids.xml index 420f4fae43..1bc2412530 100644 --- a/android/geowidget/src/main/res/values/ids.xml +++ b/android/geowidget/src/main/res/values/ids.xml @@ -1,4 +1,5 @@ + diff --git a/android/geowidget/src/main/res/values/strings.xml b/android/geowidget/src/main/res/values/strings.xml deleted file mode 100644 index 26b969e8e6..0000000000 --- a/android/geowidget/src/main/res/values/strings.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - asset://fhircore_style.json - quest-data-set - quest-data-points - diff --git a/android/geowidget/src/test/java/org/smartregister/fhircore/geowidget/screens/GeoWidgetViewModelTest.kt b/android/geowidget/src/test/java/org/smartregister/fhircore/geowidget/screens/GeoWidgetViewModelTest.kt index 2bc05b9705..b7e325c273 100644 --- a/android/geowidget/src/test/java/org/smartregister/fhircore/geowidget/screens/GeoWidgetViewModelTest.kt +++ b/android/geowidget/src/test/java/org/smartregister/fhircore/geowidget/screens/GeoWidgetViewModelTest.kt @@ -143,9 +143,9 @@ class GeoWidgetViewModelTest { serverVersion = serverVersion, ), ) - geoWidgetViewModel.submitFeatures(geoJsonFeatures) + geoWidgetViewModel.updateMapFeatures(geoJsonFeatures) - Assert.assertEquals(geoWidgetViewModel.features.value!!.size, geoJsonFeatures.size) + Assert.assertEquals(geoWidgetViewModel.mapFeatures.size, geoJsonFeatures.size) } @Test diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml index 0cc0cac3ef..0e32299cd4 100644 --- a/android/gradle/libs.versions.toml +++ b/android/gradle/libs.versions.toml @@ -49,7 +49,7 @@ kotlinx-coroutines = "1.9.0" kotlinx-serialization-json = "1.6.0" kt3k-coveralls-ver="2.12.0" ktlint = "0.50.0" -kujaku-library = "0.10.7-SNAPSHOT" +kujaku-library = "0.10.8-SNAPSHOT" kujaku-mapbox-sdk-turf = "7.2.0" leakcanary-android = "2.10" lifecycle= "2.8.5" @@ -82,6 +82,7 @@ timber = "5.0.1" uiautomator = "2.3.0" work = "2.9.1" xercesImpl = "2.12.2" +androidFragmentCompose = "1.8.4" [libraries] accompanist-flowlayout = { group = "com.google.accompanist", name = "accompanist-flowlayout", version.ref = "accompanist" } @@ -196,6 +197,8 @@ work-runtime-ktx = { group = "androidx.work", name = "work-runtime-ktx", version work-testing = { group = "androidx.work", name = "work-testing", version.ref = "work" } workflow = { group = "org.smartregister", name = "workflow", version.ref = "fhir-sdk-workflow" } xercesImpl = { group = "xerces", name = "xercesImpl", version.ref = "xercesImpl" } +androidx-fragment-compose = { group = "androidx.fragment", name = "fragment-compose", version.ref = "androidFragmentCompose" } + [plugins] android-junit5 = {id="de.mannodermaus.android-junit5", version.ref="androidJunit5"} diff --git a/android/quest/build.gradle.kts b/android/quest/build.gradle.kts index 1d22c6e27b..b7855080e3 100644 --- a/android/quest/build.gradle.kts +++ b/android/quest/build.gradle.kts @@ -444,7 +444,7 @@ dependencies { implementation(libs.dagger.hilt.android) implementation(libs.hilt.work) implementation(libs.mlkit.barcode.scanning) - + implementation(libs.androidx.fragment.compose) implementation(libs.bundles.cameraX) // Annotation processors diff --git a/android/quest/src/main/AndroidManifest.xml b/android/quest/src/main/AndroidManifest.xml index 1c69856d16..46fe396c60 100644 --- a/android/quest/src/main/AndroidManifest.xml +++ b/android/quest/src/main/AndroidManifest.xml @@ -4,6 +4,7 @@ + diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/event/AppEvent.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/event/AppEvent.kt index ef04e7acac..68ea1fc31a 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/event/AppEvent.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/event/AppEvent.kt @@ -22,5 +22,5 @@ sealed class AppEvent { data class OnSubmitQuestionnaire(val questionnaireSubmission: QuestionnaireSubmission) : AppEvent() - data object RefreshRegisterData : AppEvent() + data object RefreshData : AppEvent() } 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 71d500c8b8..a3ff11c5cc 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 @@ -54,7 +54,7 @@ sealed class MainNavigationScreen( route = org.smartregister.fhircore.quest.R.id.profileFragment, ) - object GeoWidgetLauncher : + data object GeoWidgetLauncher : MainNavigationScreen(route = org.smartregister.fhircore.quest.R.id.geoWidgetLauncherFragment) data object Insight : diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/geowidget/GeoWidgetEvent.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/geowidget/GeoWidgetEvent.kt index 0038324b78..195c11e822 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/geowidget/GeoWidgetEvent.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/geowidget/GeoWidgetEvent.kt @@ -20,8 +20,10 @@ import org.smartregister.fhircore.engine.configuration.geowidget.GeoWidgetConfig import org.smartregister.fhircore.quest.ui.shared.models.SearchQuery sealed class GeoWidgetEvent { - data class SearchFeatures( - val searchQuery: SearchQuery = SearchQuery.emptyText, + data object ClearMap : GeoWidgetEvent() + + data class RetrieveFeatures( val geoWidgetConfig: GeoWidgetConfiguration, + val searchQuery: SearchQuery = SearchQuery.emptyText, ) : GeoWidgetEvent() } diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/geowidget/GeoWidgetLauncherFragment.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/geowidget/GeoWidgetLauncherFragment.kt index 348231fcf0..2b4d302b1e 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/geowidget/GeoWidgetLauncherFragment.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/geowidget/GeoWidgetLauncherFragment.kt @@ -26,13 +26,11 @@ import androidx.compose.foundation.layout.padding import androidx.compose.material.Scaffold import androidx.compose.material.rememberScaffoldState import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.fragment.app.Fragment -import androidx.fragment.app.FragmentManager import androidx.fragment.app.activityViewModels import androidx.fragment.app.viewModels import androidx.lifecycle.Lifecycle @@ -53,21 +51,18 @@ import org.hl7.fhir.r4.model.ResourceType import org.smartregister.fhircore.engine.configuration.ConfigType import org.smartregister.fhircore.engine.configuration.ConfigurationRegistry import org.smartregister.fhircore.engine.configuration.geowidget.GeoWidgetConfiguration -import org.smartregister.fhircore.engine.domain.model.ResourceData import org.smartregister.fhircore.engine.sync.OnSyncListener import org.smartregister.fhircore.engine.sync.SyncListenerManager import org.smartregister.fhircore.engine.ui.base.AlertDialogue import org.smartregister.fhircore.engine.ui.base.AlertIntent import org.smartregister.fhircore.engine.ui.theme.AppTheme import org.smartregister.fhircore.engine.util.DispatcherProvider +import org.smartregister.fhircore.engine.util.extension.getActivity import org.smartregister.fhircore.engine.util.extension.showToast -import org.smartregister.fhircore.geowidget.model.GeoJsonFeature -import org.smartregister.fhircore.geowidget.screens.GeoWidgetFragment import org.smartregister.fhircore.quest.R import org.smartregister.fhircore.quest.event.AppEvent import org.smartregister.fhircore.quest.event.EventBus import org.smartregister.fhircore.quest.navigation.MainNavigationScreen -import org.smartregister.fhircore.quest.ui.bottomsheet.SummaryBottomSheetFragment import org.smartregister.fhircore.quest.ui.main.AppMainEvent import org.smartregister.fhircore.quest.ui.main.AppMainUiState import org.smartregister.fhircore.quest.ui.main.AppMainViewModel @@ -92,7 +87,6 @@ class GeoWidgetLauncherFragment : Fragment(), OnSyncListener { @Inject lateinit var dispatcherProvider: DispatcherProvider - private lateinit var geoWidgetFragment: GeoWidgetFragment private lateinit var geoWidgetConfiguration: GeoWidgetConfiguration private val navArgs by navArgs() private val geoWidgetLauncherViewModel by viewModels() @@ -104,11 +98,18 @@ class GeoWidgetLauncherFragment : Fragment(), OnSyncListener { container: ViewGroup?, savedInstanceState: Bundle?, ): View { - buildGeoWidgetFragment() - geoWidgetLauncherViewModel.retrieveLocations( - geoWidgetConfig = geoWidgetConfiguration, - searchText = searchViewModel.searchQuery.value.query, - ) + geoWidgetConfiguration = + configurationRegistry.retrieveConfiguration( + configType = ConfigType.GeoWidget, + configId = navArgs.geoWidgetId, + ) + if (geoWidgetConfiguration.resourceConfig.baseResource.resource != ResourceType.Location) { + val message = getString(R.string.invalid_base_resource) + requireContext().showToast(message) + Timber.e(message, geoWidgetConfiguration.toString()) + requireContext().getActivity()?.finish() + } + return ComposeView(requireContext()).apply { setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) setContent { @@ -168,29 +169,31 @@ class GeoWidgetLauncherFragment : Fragment(), OnSyncListener { }, ) { innerPadding -> Box(modifier = Modifier.padding(innerPadding)) { - val geoWidgetFragment = remember { geoWidgetFragment } GeoWidgetLauncherScreen( modifier = Modifier.fillMaxSize(), openDrawer = openDrawer, navController = findNavController(), toolBarHomeNavigation = navArgs.toolBarHomeNavigation, - fragmentManager = childFragmentManager, - geoWidgetFragment = geoWidgetFragment, geoWidgetConfiguration = geoWidgetConfiguration, searchQuery = searchViewModel.searchQuery, search = { searchText -> - geoWidgetFragment.clearMapFeatures() - geoWidgetLauncherViewModel.onEvent( - GeoWidgetEvent.SearchFeatures( - searchQuery = SearchQuery(searchText, SearchMode.KeyboardInput), - geoWidgetConfig = geoWidgetConfiguration, - ), - ) + geoWidgetLauncherViewModel.run { + onEvent(GeoWidgetEvent.ClearMap) + onEvent( + GeoWidgetEvent.RetrieveFeatures( + searchQuery = SearchQuery(searchText, SearchMode.KeyboardInput), + geoWidgetConfig = geoWidgetConfiguration, + ), + ) + } }, isFirstTimeSync = geoWidgetLauncherViewModel.isFirstTime(), appDrawerUIState = appDrawerUIState, + clearMapLiveData = geoWidgetLauncherViewModel.clearMapLiveData, + geoJsonFeatures = geoWidgetLauncherViewModel.geoJsonFeatures, + launchQuestionnaire = geoWidgetLauncherViewModel::launchQuestionnaire, + decodeImage = geoWidgetLauncherViewModel::getImageBitmap, onAppMainEvent = appMainViewModel::onEvent, - decodeImage = { TODO("Return bitmap") }, ) } } @@ -220,14 +223,16 @@ class GeoWidgetLauncherFragment : Fragment(), OnSyncListener { } is CurrentSyncJobStatus.Succeeded, is CurrentSyncJobStatus.Failed, -> { + appMainViewModel.updateAppDrawerUIState(currentSyncJobStatus = syncJobStatus) if (syncJobStatus is CurrentSyncJobStatus.Succeeded) { - geoWidgetFragment.clearMapFeatures() + geoWidgetLauncherViewModel.onEvent(GeoWidgetEvent.ClearMap) } - geoWidgetLauncherViewModel.retrieveLocations( - geoWidgetConfig = geoWidgetConfiguration, - searchText = searchViewModel.searchQuery.value.query, + geoWidgetLauncherViewModel.onEvent( + GeoWidgetEvent.RetrieveFeatures( + geoWidgetConfig = geoWidgetConfiguration, + searchQuery = searchViewModel.searchQuery.value, + ), ) - appMainViewModel.updateAppDrawerUIState(currentSyncJobStatus = syncJobStatus) } else -> appMainViewModel.updateAppDrawerUIState(currentSyncJobStatus = syncJobStatus) } @@ -235,6 +240,30 @@ class GeoWidgetLauncherFragment : Fragment(), OnSyncListener { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.CREATED) { + eventBus.events + .getFor(MainNavigationScreen.GeoWidgetLauncher.eventId(navArgs.geoWidgetId)) + .onEach { appEvent -> + when (appEvent) { + is AppEvent.RefreshData, + is AppEvent.OnSubmitQuestionnaire, -> { + appMainViewModel.countRegisterData() + geoWidgetLauncherViewModel.run { + onEvent(GeoWidgetEvent.ClearMap) + onEvent( + GeoWidgetEvent.RetrieveFeatures( + geoWidgetConfig = geoWidgetConfiguration, + searchQuery = searchViewModel.searchQuery.value, + ), + ) + } + } + } + } + .launchIn(lifecycleScope) + } + } geoWidgetLauncherViewModel.noLocationFoundDialog.observe(viewLifecycleOwner) { show -> if (show) { AlertDialogue.showAlert( @@ -254,8 +283,12 @@ class GeoWidgetLauncherFragment : Fragment(), OnSyncListener { ) } } - - setOnQuestionnaireSubmissionListener { geoWidgetFragment.submitFeatures(listOf(it)) } + geoWidgetLauncherViewModel.onEvent( + GeoWidgetEvent.RetrieveFeatures( + geoWidgetConfig = geoWidgetConfiguration, + searchQuery = searchViewModel.searchQuery.value, + ), + ) } override fun onPause() { @@ -267,75 +300,4 @@ class GeoWidgetLauncherFragment : Fragment(), OnSyncListener { super.onDestroy() appMainViewModel.updateAppDrawerUIState(false, null, 0) } - - private fun buildGeoWidgetFragment() { - geoWidgetConfiguration = - configurationRegistry.retrieveConfiguration( - configType = ConfigType.GeoWidget, - configId = navArgs.geoWidgetId, - ) - - if (geoWidgetConfiguration.resourceConfig.baseResource.resource != ResourceType.Location) { - val message = getString(R.string.invalid_base_resource) - requireContext().showToast(message) - Timber.e(message, geoWidgetConfiguration.toString()) - } - - geoWidgetFragment = - GeoWidgetFragment.builder() - .setUseGpsOnAddingLocation(false) - .setAddLocationButtonVisibility(geoWidgetConfiguration.showAddLocation) - .setOnAddLocationListener { feature: GeoJsonFeature -> - if (feature.geometry?.coordinates == null) return@setOnAddLocationListener - geoWidgetLauncherViewModel.launchQuestionnaire( - geoWidgetConfiguration.registrationQuestionnaire, - feature, - requireContext(), - ) - } - .setOnCancelAddingLocationListener { - requireContext().showToast("on cancel adding location") - } - .setOnClickLocationListener { - feature: GeoJsonFeature, - parentFragmentManager: FragmentManager, - -> - SummaryBottomSheetFragment( - geoWidgetConfiguration.summaryBottomSheetConfig!!, - ResourceData( - baseResourceId = feature.id, - baseResourceType = ResourceType.Location, - computedValuesMap = feature.properties.mapValues { it.value.content }, - ), - ) - .run { show(parentFragmentManager, SummaryBottomSheetFragment.TAG) } - } - .setMapLayers(geoWidgetConfiguration.mapLayers) - .showCurrentLocationButtonVisibility(geoWidgetConfiguration.showLocation) - .setPlaneSwitcherButtonVisibility(geoWidgetConfiguration.showPlaneSwitcher) - .build() - - lifecycleScope.launch { - geoWidgetLauncherViewModel.geoJsonFeatures.collect { geoWidgetFragment.submitFeatures(it) } - } - } - - private fun setOnQuestionnaireSubmissionListener(emitFeature: (GeoJsonFeature) -> Unit) { - viewLifecycleOwner.lifecycleScope.launch { - repeatOnLifecycle(Lifecycle.State.STARTED) { - eventBus.events - .getFor(MainNavigationScreen.GeoWidgetLauncher.eventId(geoWidgetConfiguration.id)) - .onEach { appEvent -> - if (appEvent is AppEvent.OnSubmitQuestionnaire) { - val extractedResourceIds = appEvent.questionnaireSubmission.extractedResourceIds - geoWidgetLauncherViewModel.onQuestionnaireSubmission( - extractedResourceIds = extractedResourceIds, - emitFeature = emitFeature, - ) - } - } - .launchIn(lifecycleScope) - } - } - } } diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/geowidget/GeoWidgetLauncherScreen.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/geowidget/GeoWidgetLauncherScreen.kt index 0c91a2b3c8..86dc61b6bc 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/geowidget/GeoWidgetLauncherScreen.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/geowidget/GeoWidgetLauncherScreen.kt @@ -16,29 +16,32 @@ package org.smartregister.fhircore.quest.ui.geowidget +import android.content.Context import android.graphics.Bitmap -import android.view.View -import android.widget.FrameLayout import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding import androidx.compose.material.Scaffold import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.MutableState -import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.viewinterop.AndroidView -import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentManager +import androidx.fragment.compose.AndroidFragment +import androidx.fragment.compose.rememberFragmentState +import androidx.lifecycle.MutableLiveData import androidx.navigation.NavController +import org.hl7.fhir.r4.model.ResourceType +import org.smartregister.fhircore.engine.configuration.QuestionnaireConfig import org.smartregister.fhircore.engine.configuration.geowidget.GeoWidgetConfiguration +import org.smartregister.fhircore.engine.domain.model.ResourceData import org.smartregister.fhircore.engine.domain.model.ToolBarHomeNavigation import org.smartregister.fhircore.engine.util.extension.showToast +import org.smartregister.fhircore.geowidget.model.GeoJsonFeature import org.smartregister.fhircore.geowidget.screens.GeoWidgetFragment import org.smartregister.fhircore.quest.R import org.smartregister.fhircore.quest.event.ToolbarClickEvent +import org.smartregister.fhircore.quest.ui.bottomsheet.SummaryBottomSheetFragment import org.smartregister.fhircore.quest.ui.main.AppMainEvent import org.smartregister.fhircore.quest.ui.main.components.TopScreenSection import org.smartregister.fhircore.quest.ui.shared.components.SyncBottomBar @@ -52,13 +55,14 @@ fun GeoWidgetLauncherScreen( openDrawer: (Boolean) -> Unit, navController: NavController, toolBarHomeNavigation: ToolBarHomeNavigation = ToolBarHomeNavigation.OPEN_DRAWER, - fragmentManager: FragmentManager, - geoWidgetFragment: GeoWidgetFragment, geoWidgetConfiguration: GeoWidgetConfiguration, searchQuery: MutableState, search: (String) -> Unit, isFirstTimeSync: Boolean, appDrawerUIState: AppDrawerUIState, + clearMapLiveData: MutableLiveData, + geoJsonFeatures: MutableLiveData>, + launchQuestionnaire: (QuestionnaireConfig, GeoJsonFeature, Context) -> Unit, decodeImage: ((String) -> Bitmap?)?, onAppMainEvent: (AppMainEvent) -> Unit, ) { @@ -113,33 +117,42 @@ fun GeoWidgetLauncherScreen( ) }, ) { innerPadding -> + val fragmentState = rememberFragmentState() Box(modifier = modifier.padding(innerPadding)) { - GeoWidgetFragmentView( - modifier = modifier, - fragmentManager = fragmentManager, - fragment = geoWidgetFragment, - ) - } - } -} - -@Composable -fun GeoWidgetFragmentView( - modifier: Modifier = Modifier, - fragmentManager: FragmentManager, - fragment: Fragment, -) { - val viewId = rememberSaveable { View.generateViewId() } + AndroidFragment(fragmentState = fragmentState) { fragment -> + fragment + .setUseGpsOnAddingLocation(false) + .setAddLocationButtonVisibility(geoWidgetConfiguration.showAddLocation) + .setOnAddLocationListener { feature: GeoJsonFeature -> + if (feature.geometry?.coordinates == null) return@setOnAddLocationListener + launchQuestionnaire(geoWidgetConfiguration.registrationQuestionnaire, feature, context) + } + .setOnCancelAddingLocationListener { + context.showToast(context.getString(R.string.on_cancel_adding_location)) + } + .setOnClickLocationListener { + feature: GeoJsonFeature, + parentFragmentManager: FragmentManager, + -> + SummaryBottomSheetFragment( + geoWidgetConfiguration.summaryBottomSheetConfig!!, + ResourceData( + baseResourceId = feature.id, + baseResourceType = ResourceType.Location, + computedValuesMap = feature.properties.mapValues { it.value.content }, + ), + ) + .run { show(parentFragmentManager, SummaryBottomSheetFragment.TAG) } + } + .setMapLayers(geoWidgetConfiguration.mapLayers) + .showCurrentLocationButtonVisibility(geoWidgetConfiguration.showLocation) + .setPlaneSwitcherButtonVisibility(geoWidgetConfiguration.showPlaneSwitcher) - AndroidView( - modifier = modifier, - factory = { context -> FrameLayout(context).apply { id = viewId } }, - ) - DisposableEffect(fragmentManager, fragment) { - fragmentManager.beginTransaction().run { - replace(viewId, fragment) - commitNow() + fragment.apply { + observerMapReset(clearMapLiveData) + observerGeoJsonFeatures(geoJsonFeatures) + } + } } - onDispose { fragmentManager.beginTransaction().remove(fragment).commitNowAllowingStateLoss() } } } diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/geowidget/GeoWidgetLauncherViewModel.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/geowidget/GeoWidgetLauncherViewModel.kt index 3d34c607e5..5c9eef34ff 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/geowidget/GeoWidgetLauncherViewModel.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/geowidget/GeoWidgetLauncherViewModel.kt @@ -18,6 +18,7 @@ package org.smartregister.fhircore.quest.ui.geowidget import android.content.Context import android.graphics.Bitmap +import androidx.compose.material.SnackbarDuration import androidx.compose.runtime.mutableStateMapOf import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData @@ -28,14 +29,12 @@ import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext import kotlinx.serialization.json.JsonPrimitive import org.hl7.fhir.r4.model.Enumerations -import org.hl7.fhir.r4.model.IdType import org.hl7.fhir.r4.model.Location import org.hl7.fhir.r4.model.ResourceType import org.smartregister.fhircore.engine.configuration.ConfigType @@ -47,18 +46,20 @@ import org.smartregister.fhircore.engine.configuration.register.ActiveResourceFi import org.smartregister.fhircore.engine.data.local.DefaultRepository import org.smartregister.fhircore.engine.domain.model.ActionParameter import org.smartregister.fhircore.engine.domain.model.ActionParameterType +import org.smartregister.fhircore.engine.domain.model.MultiSelectViewAction import org.smartregister.fhircore.engine.domain.model.SnackBarMessageConfig import org.smartregister.fhircore.engine.rulesengine.ResourceDataRulesExecutor import org.smartregister.fhircore.engine.util.DispatcherProvider import org.smartregister.fhircore.engine.util.SharedPreferenceKey import org.smartregister.fhircore.engine.util.SharedPreferencesHelper -import org.smartregister.fhircore.engine.util.extension.extractLogicalIdUuid import org.smartregister.fhircore.engine.util.extension.interpolate -import org.smartregister.fhircore.engine.util.extension.retrieveRelatedEntitySyncLocationIds +import org.smartregister.fhircore.engine.util.extension.retrieveRelatedEntitySyncLocationState import org.smartregister.fhircore.geowidget.model.GeoJsonFeature import org.smartregister.fhircore.geowidget.model.Geometry +import org.smartregister.fhircore.quest.R import org.smartregister.fhircore.quest.ui.shared.QuestionnaireHandler import org.smartregister.fhircore.quest.util.extensions.referenceToBitmap +import timber.log.Timber @HiltViewModel class GeoWidgetLauncherViewModel @@ -71,6 +72,9 @@ constructor( val configurationRegistry: ConfigurationRegistry, @ApplicationContext val context: Context, ) : ViewModel() { + val clearMapLiveData: MutableLiveData = MutableLiveData() + val geoJsonFeatures: MutableLiveData> = MutableLiveData() + private val _snackBarStateFlow = MutableSharedFlow() val snackBarStateFlow = _snackBarStateFlow.asSharedFlow() @@ -82,11 +86,20 @@ constructor( configurationRegistry.retrieveConfiguration(ConfigType.Application) } - val geoJsonFeatures: MutableStateFlow> = MutableStateFlow(emptyList()) - private val decodedImageMap = mutableStateMapOf() - fun retrieveLocations(geoWidgetConfig: GeoWidgetConfiguration, searchText: String?) { + fun onEvent(geoWidgetEvent: GeoWidgetEvent) { + when (geoWidgetEvent) { + is GeoWidgetEvent.RetrieveFeatures -> + retrieveLocations(geoWidgetEvent.geoWidgetConfig, geoWidgetEvent.searchQuery.query) + GeoWidgetEvent.ClearMap -> clearMapLiveData.postValue(true) + } + } + + private fun retrieveLocations( + geoWidgetConfig: GeoWidgetConfiguration, + searchText: String?, + ) { viewModelScope.launch { val totalCount = withContext(dispatcherProvider.io()) { @@ -114,8 +127,10 @@ constructor( } var count = 0 var pageNumber = 0 + var locationsWithoutCoordinatesCount = 0L + var registerDataCount = 0L while (count < totalCount) { - val registerData = + val (locationsWithCoordinates, locationsWithoutCoordinates) = defaultRepository .searchResourcesRecursively( filterActiveResources = null, @@ -129,8 +144,13 @@ constructor( ) .asSequence() .filter { it.resource is Location } - .filter { (it.resource as Location).hasPosition() } - .filter { with((it.resource as Location).position) { hasLongitude() && hasLatitude() } } + .partition { + with((it.resource as Location).position) { hasLongitude() && hasLatitude() } + } + + val registerData = + locationsWithCoordinates + .asSequence() .map { Pair( it.resource as Location, @@ -159,7 +179,7 @@ constructor( ) } .toList() - geoJsonFeatures.value = + val features = if (searchText.isNullOrBlank()) { registerData } else { @@ -171,48 +191,86 @@ constructor( } == true } } + + geoJsonFeatures.postValue(features) + + Timber.w( + locationsWithoutCoordinates.joinToString("\n") { + val position = (it.resource as Location).position + "Location id ${it.resource.logicalId} coordinates (${position.longitude},${position.latitude}) invalid." + }, + ) pageNumber++ count += DefaultRepository.DEFAULT_BATCH_SIZE + registerDataCount += features.size + locationsWithoutCoordinatesCount += locationsWithoutCoordinates.size } - } - } - fun onEvent(geoWidgetEvent: GeoWidgetEvent) { - when (geoWidgetEvent) { - is GeoWidgetEvent.SearchFeatures -> - retrieveLocations(geoWidgetEvent.geoWidgetConfig, geoWidgetEvent.searchQuery.query) + val locationsCount = if (searchText.isNullOrBlank()) totalCount else registerDataCount + + // Account for locations without coordinates + if (locationsWithoutCoordinatesCount in 1..locationsCount) { + val message = + context.getString( + R.string.locations_without_coordinates, + locationsWithoutCoordinatesCount, + locationsCount, + ) + Timber.w(message) + emitSnackBarState( + SnackBarMessageConfig( + message = message, + actionLabel = context.getString(org.smartregister.fhircore.engine.R.string.ok), + duration = SnackbarDuration.Long, + ), + ) + } else { + val message = + if (searchText.isNullOrBlank()) { + context.getString(R.string.all_locations_rendered) + } else context.getString(R.string.all_matching_locations_rendered, locationsCount) + emitSnackBarState( + SnackBarMessageConfig( + message = message, + actionLabel = context.getString(org.smartregister.fhircore.engine.R.string.ok), + duration = SnackbarDuration.Short, + ), + ) + } + + // Account for missing locations + if (locationsCount == 0L) { + if (!searchText.isNullOrBlank()) { + val message = + context.getString( + R.string.no_found_locations_matching_text, + searchText, + ) + Timber.w(message) + emitSnackBarState( + SnackBarMessageConfig( + message = message, + actionLabel = context.getString(org.smartregister.fhircore.engine.R.string.ok), + duration = SnackbarDuration.Long, + ), + ) + } else { + SnackBarMessageConfig( + message = context.getString(R.string.no_locations_to_render), + actionLabel = context.getString(org.smartregister.fhircore.engine.R.string.ok), + duration = SnackbarDuration.Long, + ) + } + } } } suspend fun showNoLocationDialog(geoWidgetConfiguration: GeoWidgetConfiguration) { geoWidgetConfiguration.noResults?.let { - _noLocationFoundDialog.postValue(context.retrieveRelatedEntitySyncLocationIds().isEmpty()) - } - } - - suspend fun onQuestionnaireSubmission( - extractedResourceIds: List, - emitFeature: (GeoJsonFeature) -> Unit, - ) { - val locationId = - extractedResourceIds.firstOrNull { it.resourceType == ResourceType.Location.name } ?: return - val location = - defaultRepository.loadResource(locationId.valueAsString.extractLogicalIdUuid()) - ?: return - - val feature = - GeoJsonFeature( - id = location.id, - geometry = - Geometry( - coordinates = // MapBox coordinates are represented as Long,Lat (NOT Lat,Long) - listOf( - location.position.longitude.toDouble(), - location.position.latitude.toDouble(), - ), - ), + _noLocationFoundDialog.postValue( + context.retrieveRelatedEntitySyncLocationState(MultiSelectViewAction.SYNC_DATA).isEmpty(), ) - emitFeature(feature) + } } fun launchQuestionnaire( 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 63dea06500..ed921891e8 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 @@ -42,7 +42,6 @@ import java.util.TimeZone import javax.inject.Inject import kotlin.time.Duration import kotlinx.coroutines.async -import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.hl7.fhir.r4.model.QuestionnaireResponse @@ -57,8 +56,8 @@ import org.smartregister.fhircore.engine.configuration.navigation.NavigationMenu import org.smartregister.fhircore.engine.configuration.report.measure.MeasureReportConfiguration import org.smartregister.fhircore.engine.configuration.workflow.ActionTrigger import org.smartregister.fhircore.engine.data.local.register.RegisterRepository -import org.smartregister.fhircore.engine.datastore.syncLocationIdsProtoStore import org.smartregister.fhircore.engine.domain.model.LauncherType +import org.smartregister.fhircore.engine.domain.model.MultiSelectViewAction import org.smartregister.fhircore.engine.sync.CustomSyncWorker import org.smartregister.fhircore.engine.sync.SyncBroadcaster import org.smartregister.fhircore.engine.task.FhirCarePlanGenerator @@ -78,6 +77,7 @@ import org.smartregister.fhircore.engine.util.extension.getActivity import org.smartregister.fhircore.engine.util.extension.isDeviceOnline import org.smartregister.fhircore.engine.util.extension.reformatDate import org.smartregister.fhircore.engine.util.extension.refresh +import org.smartregister.fhircore.engine.util.extension.retrieveRelatedEntitySyncLocationState import org.smartregister.fhircore.engine.util.extension.setAppLocale import org.smartregister.fhircore.engine.util.extension.showToast import org.smartregister.fhircore.engine.util.extension.tryParse @@ -409,7 +409,9 @@ constructor( if (applicationConfiguration.syncStrategy.contains(SyncStrategy.RelatedEntityLocation)) { if ( applicationConfiguration.usePractitionerAssignedLocationOnSync || - context.syncLocationIdsProtoStore.data.firstOrNull()?.isNotEmpty() == true + context + .retrieveRelatedEntitySyncLocationState(MultiSelectViewAction.SYNC_DATA) + .isNotEmpty() ) { schedulePeriodicSync() } diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/main/components/TopScreenSection.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/main/components/TopScreenSection.kt index c56728f026..199dce8412 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/main/components/TopScreenSection.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/main/components/TopScreenSection.kt @@ -23,10 +23,9 @@ 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.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape @@ -34,6 +33,8 @@ import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.Badge import androidx.compose.material.BadgedBox +import androidx.compose.material.DropdownMenu +import androidx.compose.material.DropdownMenuItem import androidx.compose.material.Icon import androidx.compose.material.IconButton import androidx.compose.material.MaterialTheme @@ -43,11 +44,16 @@ import androidx.compose.material.TextFieldDefaults import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Clear -import androidx.compose.material.icons.filled.FilterAlt import androidx.compose.material.icons.filled.Menu import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.outlined.FilterAlt +import androidx.compose.material.icons.outlined.MoreVert import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -63,15 +69,18 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.navigation.NavController import androidx.navigation.compose.rememberNavController +import kotlin.math.min import org.smartregister.fhircore.engine.R import org.smartregister.fhircore.engine.configuration.navigation.ICON_TYPE_LOCAL import org.smartregister.fhircore.engine.configuration.navigation.ImageConfig import org.smartregister.fhircore.engine.configuration.view.ImageProperties import org.smartregister.fhircore.engine.domain.model.ToolBarHomeNavigation import org.smartregister.fhircore.engine.domain.model.TopScreenSectionConfig +import org.smartregister.fhircore.engine.ui.theme.DefaultColor import org.smartregister.fhircore.engine.ui.theme.GreyTextColor import org.smartregister.fhircore.engine.util.annotation.PreviewWithBackgroundExcludeGenerated import org.smartregister.fhircore.engine.util.extension.getActivity +import org.smartregister.fhircore.engine.util.extension.parseColor import org.smartregister.fhircore.quest.event.ToolbarClickEvent import org.smartregister.fhircore.quest.ui.shared.components.Image import org.smartregister.fhircore.quest.ui.shared.models.SearchMode @@ -134,62 +143,42 @@ fun TopScreenSection( TITLE_ROW_TEST_TAG, ), verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, ) { - Icon( - when (toolBarHomeNavigation) { - ToolBarHomeNavigation.OPEN_DRAWER -> Icons.Filled.Menu - ToolBarHomeNavigation.NAVIGATE_BACK -> Icons.AutoMirrored.Filled.ArrowBack - }, - contentDescription = DRAWER_MENU, - tint = Color.White, - modifier = - modifier.clickable { onClick(ToolbarClickEvent.Navigate) }.testTag(TOP_ROW_ICON_TEST_TAG), - ) - Text( - text = title, - fontSize = 20.sp, - color = Color.White, - modifier = modifier.padding(start = 8.dp).weight(1f).testTag(TOP_ROW_TEXT_TEST_TAG), - ) - - // if menu icons are more than two then we will add a overflow menu for other menu icons - // to support m3 guidelines - // https://m3.material.io/components/top-app-bar/guidelines#b1b64842-7d88-4c3f-8ffb-4183fe648c9e - SetupToolbarIcons( - menuIcons = topScreenSection?.menuIcons, - navController = navController, - modifier = modifier, - onClick = onClick, - decodeImage = decodeImage, - ) - - if (isFilterIconEnabled) Spacer(modifier = Modifier.width(24.dp)) - - if (isFilterIconEnabled) { - BadgedBox( - modifier = Modifier.padding(end = 8.dp), - badge = { - if (filteredRecordsCount != null && filteredRecordsCount > -1) { - Badge { - Text( - text = if (filteredRecordsCount > 99) "99+" else filteredRecordsCount.toString(), - overflow = TextOverflow.Clip, - maxLines = 1, - ) - } - } + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + when (toolBarHomeNavigation) { + ToolBarHomeNavigation.OPEN_DRAWER -> Icons.Filled.Menu + ToolBarHomeNavigation.NAVIGATE_BACK -> Icons.AutoMirrored.Filled.ArrowBack }, - ) { - Icon( - imageVector = Icons.Default.FilterAlt, - contentDescription = FILTER, - tint = Color.White, - modifier = - modifier - .clickable { onClick(ToolbarClickEvent.FilterData) } - .testTag(TOP_ROW_FILTER_ICON_TEST_TAG), - ) - } + contentDescription = DRAWER_MENU, + tint = Color.White, + modifier = + modifier + .clickable { onClick(ToolbarClickEvent.Navigate) } + .testTag(TOP_ROW_ICON_TEST_TAG), + ) + Text( + text = title, + fontSize = 20.sp, + color = Color.White, + modifier = modifier.padding(start = 16.dp).testTag(TOP_ROW_TEXT_TEST_TAG), + ) + } + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(start = 8.dp), + ) { + SetupToolbarIcons( + menuIcons = topScreenSection?.menuIcons, + isFilterIconEnabled = isFilterIconEnabled, + filteredRecordsCount = filteredRecordsCount, + navController = navController, + modifier = modifier, + onClick = onClick, + decodeImage = decodeImage, + ) } } if (isSearchBarVisible) { @@ -258,13 +247,19 @@ fun TopScreenSection( currentContext.getActivity()?.let { QrCodeScanUtils.scanQrCode(it) { code -> onSearchTextChanged( - SearchQuery(code ?: "", mode = SearchMode.QrCodeScan), + SearchQuery( + code ?: "", + mode = SearchMode.QrCodeScan, + ), performSearchOnValueChanged, ) } } }, - modifier = modifier.testTag(TRAILING_QR_SCAN_ICON_BUTTON_TEST_TAG), + modifier = + modifier.testTag( + TRAILING_QR_SCAN_ICON_BUTTON_TEST_TAG, + ), ) { Icon( painter = @@ -288,46 +283,118 @@ fun TopScreenSection( @Composable fun SetupToolbarIcons( menuIcons: List?, + isFilterIconEnabled: Boolean, + filteredRecordsCount: Long? = null, navController: NavController, modifier: Modifier, onClick: (ToolbarClickEvent) -> Unit, decodeImage: ((String) -> Bitmap?)?, ) { - if (menuIcons?.isNotEmpty() == true && menuIcons.size > 2) { - Row { - RenderMenuIcons( - menuIcons = menuIcons.take(2), - navController = navController, - modifier = modifier, - onClick = onClick, - decodeImage = decodeImage, - ) - } - } else { - menuIcons?.let { - RenderMenuIcons( - menuIcons = it, + var showOverflowMenu by remember { mutableStateOf(false) } + if (!menuIcons.isNullOrEmpty()) { + val iconsCount = remember { if (isFilterIconEnabled) 1 else 2 } + if (menuIcons.size <= iconsCount) { + RenderMenuIcon( + menuIcons = menuIcons.subList(0, min(iconsCount, menuIcons.size)), + isFilterIconEnabled = isFilterIconEnabled, + filteredRecordsCount = filteredRecordsCount, navController = navController, modifier = modifier, onClick = onClick, decodeImage = decodeImage, ) + } else { + Row(verticalAlignment = Alignment.CenterVertically) { + RenderMenuIcon( + menuIcons = menuIcons.subList(0, iconsCount), + isFilterIconEnabled = false, + filteredRecordsCount = null, + navController = navController, + modifier = modifier, + onClick = onClick, + decodeImage = decodeImage, + ) + Icon( + imageVector = Icons.Outlined.MoreVert, + contentDescription = null, + tint = Color.White, + modifier = + Modifier.padding(start = 8.dp).size(22.dp).clickable { + showOverflowMenu = !showOverflowMenu + }, + ) + DropdownMenu( + expanded = showOverflowMenu, + onDismissRequest = { showOverflowMenu = false }, + ) { + menuIcons.subList(iconsCount, menuIcons.size).forEach { + DropdownMenuItem( + onClick = { + onClick(ToolbarClickEvent.Actions(it.actions)) + showOverflowMenu = !showOverflowMenu + }, + ) { + Image( + imageProperties = it, + navController = navController, + tint = it.tint?.parseColor() ?: DefaultColor, + modifier = + modifier + .clickable { onClick(ToolbarClickEvent.Actions(it.actions)) } + .testTag(TOP_ROW_TOGGLE_ICON_TEST_tAG), + decodeImage = decodeImage, + ) + } + } + } + } } } } @Composable -fun RenderMenuIcons( +private fun RenderMenuIcon( menuIcons: List, + isFilterIconEnabled: Boolean, + filteredRecordsCount: Long? = null, navController: NavController, modifier: Modifier, onClick: (ToolbarClickEvent) -> Unit, decodeImage: ((String) -> Bitmap?)?, ) { - LazyRow(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + LazyRow(horizontalArrangement = Arrangement.spacedBy(14.dp)) { + item { + if (isFilterIconEnabled) { + BadgedBox( + modifier = Modifier.padding(end = 8.dp), + badge = { + if (filteredRecordsCount != null && filteredRecordsCount > -1) { + Badge { + Text( + text = if (filteredRecordsCount > 99) "99+" else filteredRecordsCount.toString(), + overflow = TextOverflow.Clip, + maxLines = 1, + ) + } + } + }, + ) { + Icon( + imageVector = Icons.Outlined.FilterAlt, + contentDescription = FILTER, + tint = Color.White, + modifier = + modifier + .size(22.dp) + .clickable { onClick(ToolbarClickEvent.FilterData) } + .testTag(TOP_ROW_FILTER_ICON_TEST_TAG), + ) + } + } + } items(menuIcons) { Image( - imageProperties = ImageProperties(imageConfig = it.imageConfig), + imageProperties = it, navController = navController, tint = Color.White, modifier = @@ -359,7 +426,7 @@ fun TopScreenSectionWithFilterItemOverNinetyNinePreview() { listOf( ImageProperties( imageConfig = ImageConfig(ICON_TYPE_LOCAL, "ic_toggle_map_view"), - backgroundColor = Color.White.toString(), + backgroundColor = "#FFFFFF", size = 10, ), ), @@ -403,7 +470,7 @@ fun TopScreenSectionNoFilterIconPreview() { searchBar = null, title = "Service Point", menuIcons = - arrayListOf( + listOf( ImageProperties(imageConfig = ImageConfig(reference = "ic_service_points")), ), ), @@ -429,7 +496,7 @@ fun TopScreenSectionWithFilterIconAndToggleIconPreview() { searchBar = null, title = "Service Point", menuIcons = - arrayListOf( + listOf( ImageProperties(imageConfig = ImageConfig(reference = "ic_service_points")), ), ), @@ -439,13 +506,13 @@ fun TopScreenSectionWithFilterIconAndToggleIconPreview() { @PreviewWithBackgroundExcludeGenerated @Composable -fun TopScreenSectionWithToggleIconPreview() { +fun TopScreenSectionWithOpenDrawerIconPreview() { TopScreenSection( title = "All Clients", searchQuery = SearchQuery("Eddy"), filteredRecordsCount = 120, onSearchTextChanged = { _, _ -> }, - toolBarHomeNavigation = ToolBarHomeNavigation.NAVIGATE_BACK, + toolBarHomeNavigation = ToolBarHomeNavigation.OPEN_DRAWER, isFilterIconEnabled = false, onClick = {}, isSearchBarVisible = true, 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 index 8538bc6561..7afb0d5a4f 100644 --- 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 @@ -20,58 +20,65 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import android.widget.Toast +import androidx.compose.foundation.layout.Box +import androidx.compose.material.Text import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.ui.Alignment import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.res.stringResource import androidx.fragment.app.activityViewModels import androidx.fragment.app.viewModels import androidx.lifecycle.lifecycleScope -import androidx.lifecycle.viewModelScope import androidx.navigation.fragment.navArgs import com.google.android.material.bottomsheet.BottomSheetDialogFragment import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject import kotlinx.coroutines.launch +import org.smartregister.fhircore.engine.domain.model.MultiSelectViewAction 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.engine.util.DefaultDispatcherProvider +import org.smartregister.fhircore.quest.R import org.smartregister.fhircore.quest.event.AppEvent import org.smartregister.fhircore.quest.event.EventBus +import org.smartregister.fhircore.quest.ui.main.AppMainEvent import org.smartregister.fhircore.quest.ui.main.AppMainViewModel @AndroidEntryPoint -class MultiSelectBottomSheetFragment() : BottomSheetDialogFragment() { +class MultiSelectBottomSheetFragment : BottomSheetDialogFragment() { + + @Inject lateinit var dispatcherProvider: DefaultDispatcherProvider @Inject lateinit var eventBus: EventBus - val bottomSheetArgs by navArgs() - val multiSelectViewModel by viewModels() + private val bottomSheetArgs by navArgs() + private val multiSelectViewModel by viewModels() private val appMainViewModel by activityViewModels() override fun onViewCreated(view: View, savedInstanceState: Bundle?) { isCancelable = false - val multiSelectViewConfig = bottomSheetArgs?.multiSelectViewConfig + val multiSelectViewConfig = bottomSheetArgs.multiSelectViewConfig if (multiSelectViewConfig != null) { multiSelectViewModel.populateLookupMap(requireContext(), multiSelectViewConfig) } } - private fun onSelectionDone() { + private fun onSelectionDone(viewActions: List) { + val context = requireContext() lifecycleScope.launch { - multiSelectViewModel.saveSelectedLocations(requireContext()) - appMainViewModel.run { - if (requireContext().isDeviceOnline()) { - viewModelScope.launch { syncBroadcaster.runOneTimeSync() } - schedulePeriodicSync() - } else { - requireContext() - .showToast( - getString(org.smartregister.fhircore.engine.R.string.sync_failed), - Toast.LENGTH_LONG, - ) - eventBus.triggerEvent(AppEvent.RefreshRegisterData) + multiSelectViewModel.saveSelectedLocations(context, viewActions) { + viewActions.distinct().forEach { viewAction -> + when (viewAction) { + MultiSelectViewAction.SYNC_DATA -> + appMainViewModel.onEvent(AppMainEvent.SyncData(context)) + MultiSelectViewAction.FILTER_DATA -> + lifecycleScope.launch { + eventBus.triggerEvent( + AppEvent.RefreshData, + ) + } + } } + dismiss() } - dismiss() } } @@ -83,17 +90,26 @@ class MultiSelectBottomSheetFragment() : BottomSheetDialogFragment() { return ComposeView(requireContext()).apply { setContent { AppTheme { - MultiSelectBottomSheetView( - rootTreeNodes = multiSelectViewModel.rootTreeNodes, - syncLocationStateMap = multiSelectViewModel.selectedNodes, - title = bottomSheetArgs.screenTitle, - onDismiss = { dismiss() }, - searchTextState = multiSelectViewModel.searchTextState, - onSearchTextChanged = multiSelectViewModel::onTextChanged, - onSelectionDone = ::onSelectionDone, - search = multiSelectViewModel::search, - isLoading = multiSelectViewModel.isLoading.observeAsState(), - ) + val multiSelectViewConfig = bottomSheetArgs.multiSelectViewConfig + if (multiSelectViewConfig != null) { + MultiSelectBottomSheetView( + rootTreeNodes = multiSelectViewModel.rootTreeNodes, + syncLocationStateMap = multiSelectViewModel.selectedNodes, + title = bottomSheetArgs.screenTitle, + onDismiss = { dismiss() }, + searchTextState = multiSelectViewModel.searchTextState, + onSearchTextChanged = multiSelectViewModel::onTextChanged, + onSelectionDone = ::onSelectionDone, + search = multiSelectViewModel::search, + isLoading = multiSelectViewModel.isLoading.observeAsState(), + multiSelectViewAction = multiSelectViewConfig.viewActions, + mutuallyExclusive = multiSelectViewConfig.mutuallyExclusive, + ) + } else { + Box(contentAlignment = Alignment.Center) { + Text(text = stringResource(R.string.missing_multi_select_view_configs)) + } + } } } } 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 index 4e399b75fa..4c8a451baa 100644 --- 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 @@ -52,6 +52,7 @@ 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 @@ -60,10 +61,13 @@ 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.domain.model.MultiSelectViewAction import org.smartregister.fhircore.engine.domain.model.SyncLocationState import org.smartregister.fhircore.engine.ui.multiselect.MultiSelectView import org.smartregister.fhircore.engine.ui.multiselect.TreeNode +import org.smartregister.fhircore.engine.ui.multiselect.updateNestedCheckboxState import org.smartregister.fhircore.engine.ui.theme.DividerColor +import org.smartregister.fhircore.engine.util.extension.isIn @Composable fun MultiSelectBottomSheetView( @@ -73,9 +77,11 @@ fun MultiSelectBottomSheetView( onDismiss: () -> Unit, searchTextState: MutableState, onSearchTextChanged: (String) -> Unit, - onSelectionDone: () -> Unit, + onSelectionDone: (List) -> Unit, search: () -> Unit, isLoading: State, + multiSelectViewAction: List, + mutuallyExclusive: Boolean, ) { val keyboardController = LocalSoftwareKeyboardController.current Scaffold( @@ -171,11 +177,41 @@ fun MultiSelectBottomSheetView( LazyColumn( modifier = Modifier.padding(horizontal = 8.dp), ) { - items(rootTreeNodes, key = { item -> item.id }) { + items(rootTreeNodes, key = { item -> item.id }) { rootTreeNode -> Column { MultiSelectView( - rootTreeNode = it, + rootTreeNode = rootTreeNode, syncLocationStateMap = syncLocationStateMap, + onChecked = { + if (mutuallyExclusive) { + rootTreeNodes.forEach { currentNode -> + val currentNodeToggleableState = + syncLocationStateMap[currentNode.id]?.toggleableState + if ( + currentNode.id != rootTreeNode.id && + currentNodeToggleableState != null && + currentNodeToggleableState.isIn( + ToggleableState.On, + ToggleableState.Indeterminate, + ) + ) { + // De-select the root and its children + syncLocationStateMap[currentNode.id] = + SyncLocationState( + locationId = currentNode.id, + parentLocationId = currentNode.parent?.id, + toggleableState = ToggleableState.Off, + ) + + updateNestedCheckboxState( + currentTreeNode = currentNode, + syncLocationStateMap = syncLocationStateMap, + checked = false, + ) + } + } + } + }, ) { treeNode -> Column { Text(text = treeNode.data) } } @@ -184,11 +220,19 @@ fun MultiSelectBottomSheetView( item { if (syncLocationStateMap.isNotEmpty() && rootTreeNodes.isNotEmpty()) { Button( - onClick = { onSelectionDone() }, + onClick = { onSelectionDone(multiSelectViewAction) }, modifier = Modifier.fillMaxWidth().padding(vertical = 16.dp, horizontal = 8.dp), ) { Text( - text = stringResource(id = R.string.sync_data).uppercase(), + text = + stringResource( + id = + when (multiSelectViewAction.first()) { + MultiSelectViewAction.SYNC_DATA -> R.string.sync_data + MultiSelectViewAction.FILTER_DATA -> R.string.apply_filter + }, + ) + .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 index 885990d23f..c7e7bb1b3b 100644 --- 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 @@ -26,17 +26,21 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.google.android.fhir.datacapture.extensions.logicalId import dagger.hilt.android.lifecycle.HiltViewModel +import java.io.IOException 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.dataFilterLocationIdsProtoStore import org.smartregister.fhircore.engine.datastore.syncLocationIdsProtoStore +import org.smartregister.fhircore.engine.domain.model.MultiSelectViewAction import org.smartregister.fhircore.engine.domain.model.MultiSelectViewConfig import org.smartregister.fhircore.engine.domain.model.SyncLocationState 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.extension.retrieveRelatedEntitySyncLocationState import org.smartregister.fhircore.engine.util.fhirpath.FhirPathDataExtractor +import timber.log.Timber @HiltViewModel class MultiSelectViewModel @@ -55,10 +59,16 @@ constructor( fun populateLookupMap(context: Context, multiSelectViewConfig: MultiSelectViewConfig) { viewModelScope.launch { isLoading.postValue(true) - // Mark previously selected nodes - val previouslySelectedNodes = context.syncLocationIdsProtoStore.data.firstOrNull() - if (!previouslySelectedNodes.isNullOrEmpty()) { - previouslySelectedNodes.values.forEach { selectedNodes[it.locationId] = it } + // Populate previously selected nodes for every Multi-Select view action + multiSelectViewConfig.viewActions.forEach { + val previouslySelectedNodes = + context.retrieveRelatedEntitySyncLocationState( + multiSelectViewAction = it, + filterToggleableStateOn = false, + ) + previouslySelectedNodes.forEach { syncLocationState -> + selectedNodes[syncLocationState.locationId] = syncLocationState + } } val repositoryResourceData = @@ -143,8 +153,25 @@ constructor( } } - suspend fun saveSelectedLocations(context: Context) { - context.syncLocationIdsProtoStore.updateData { selectedNodes } + suspend fun saveSelectedLocations( + context: Context, + viewActions: List, + onSaveDone: () -> Unit, + ) { + try { + viewActions.forEach { + when (it) { + MultiSelectViewAction.SYNC_DATA -> + context.syncLocationIdsProtoStore.updateData { selectedNodes } + MultiSelectViewAction.FILTER_DATA -> + context.dataFilterLocationIdsProtoStore.updateData { selectedNodes } + } + } + + onSaveDone() + } catch (ioException: IOException) { + Timber.e("Error saving selected locations", ioException) + } } fun search() { diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/register/RegisterFragment.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/register/RegisterFragment.kt index 032a90cf1e..9d31df58c9 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/register/RegisterFragment.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/register/RegisterFragment.kt @@ -252,7 +252,7 @@ class RegisterFragment : Fragment(), OnSyncListener { when (appEvent) { is AppEvent.OnSubmitQuestionnaire -> handleQuestionnaireSubmission(appEvent.questionnaireSubmission) - is AppEvent.RefreshRegisterData -> { + is AppEvent.RefreshData -> { appMainViewModel.countRegisterData() refreshRegisterData() } diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/shared/components/Image.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/shared/components/Image.kt index 4e628518b3..0d3a0cc71d 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/shared/components/Image.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/shared/components/Image.kt @@ -56,6 +56,7 @@ import org.smartregister.fhircore.engine.configuration.view.ImageShape import org.smartregister.fhircore.engine.domain.model.ResourceData import org.smartregister.fhircore.engine.domain.model.ViewType import org.smartregister.fhircore.engine.ui.theme.DangerColor +import org.smartregister.fhircore.engine.ui.theme.SideMenuTopItemDarkColor import org.smartregister.fhircore.engine.util.annotation.PreviewWithBackgroundExcludeGenerated import org.smartregister.fhircore.engine.util.extension.extractLogicalIdUuid import org.smartregister.fhircore.engine.util.extension.parseColor @@ -90,7 +91,7 @@ fun Image( text = imageProperties.text!!, textAlign = TextAlign.Center, modifier = Modifier.padding(end = 8.dp), - color = imageProperties.textColor?.parseColor() ?: Color.Gray, + color = imageProperties.textColor?.parseColor() ?: SideMenuTopItemDarkColor, ) ClickableImageIcon( imageProperties = imageProperties, @@ -140,8 +141,8 @@ fun ClickableImageIcon( ) .conditional( imageProperties.size != null, - { size(imageProperties.size!!.dp) }, - { size(24.dp) }, + { size(if (imageProperties.size!! >= 22) imageProperties.size!!.dp else 16.dp) }, + { size(20.dp) }, ) .conditional( !imageProperties.backgroundColor.isNullOrEmpty(), 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 3e2c6b58e2..19e1e44dbf 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 @@ -164,7 +164,7 @@ fun ActionConfig.handleClickEvent( args = args, navOptions = navController.currentDestination?.id?.let { - navOptions(resId = it, inclusive = actionConfig.popNavigationBackStack == true) + navOptions(resId = it, inclusive = actionConfig.popNavigationBackStack != false) }, ) } @@ -199,7 +199,7 @@ fun ActionConfig.handleClickEvent( args = args, navOptions = navController.currentDestination?.id?.let { - navOptions(resId = it, inclusive = actionConfig.popNavigationBackStack == true) + navOptions(resId = it, inclusive = actionConfig.popNavigationBackStack != false) }, ) } diff --git a/android/quest/src/main/res/values/strings.xml b/android/quest/src/main/res/values/strings.xml index 77345f7495..113c8e6d31 100644 --- a/android/quest/src/main/res/values/strings.xml +++ b/android/quest/src/main/res/values/strings.xml @@ -87,7 +87,8 @@ Record as ANC Pregnancy Outcome Registers - No Location Set + No location Set + No locations to render on map Set location to sync data and load service points Set location @@ -135,4 +136,9 @@ \u0020\u002a Exit App Are you sure you want to exit from the app? + Missing required multi select view configs. Please provide the configurations for the view to render. + "%1$d out of %2$d Location(s) have no coordinates" + All the locations rendered successfully" + %1$d matching location(s) rendered successfully" + Cancel adding location diff --git a/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/geowidget/GeoWidgetLauncherViewModelTest.kt b/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/geowidget/GeoWidgetLauncherViewModelTest.kt index 446b1552b4..dcfa53e5ea 100644 --- a/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/geowidget/GeoWidgetLauncherViewModelTest.kt +++ b/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/geowidget/GeoWidgetLauncherViewModelTest.kt @@ -18,13 +18,9 @@ package org.smartregister.fhircore.quest.ui.geowidget import android.content.Context import androidx.test.core.app.ApplicationProvider -import com.google.android.fhir.datacapture.extensions.logicalId import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidTest import dagger.hilt.android.testing.HiltTestApplication -import io.mockk.slot -import io.mockk.spyk -import io.mockk.verify import javax.inject.Inject import kotlin.test.assertTrue import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -33,7 +29,6 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.runTest import org.hl7.fhir.r4.model.DecimalType -import org.hl7.fhir.r4.model.IdType import org.hl7.fhir.r4.model.Location import org.hl7.fhir.r4.model.ResourceType import org.junit.Assert.assertEquals @@ -53,7 +48,6 @@ import org.smartregister.fhircore.engine.domain.model.SnackBarMessageConfig import org.smartregister.fhircore.engine.rulesengine.ResourceDataRulesExecutor import org.smartregister.fhircore.engine.util.DefaultDispatcherProvider import org.smartregister.fhircore.engine.util.SharedPreferencesHelper -import org.smartregister.fhircore.geowidget.model.GeoJsonFeature import org.smartregister.fhircore.quest.app.fakes.Faker import org.smartregister.fhircore.quest.robolectric.RobolectricTest @@ -145,38 +139,6 @@ class GeoWidgetLauncherViewModelTest : RobolectricTest() { assertTrue(value!!) } - @Test - @Ignore("Tech debt : Tracked by issue https://github.com/opensrp/fhircore/issues/3514") - fun testRetrieveLocationsShouldReturnGeoJsonFeatureList() = runTest { - viewModel.retrieveLocations(geoWidgetConfiguration, null) - assertTrue(viewModel.geoJsonFeatures.value.isNotEmpty()) - assertEquals("loc1", viewModel.geoJsonFeatures.value.first().id) - } - - @Test - @Ignore("Investigate why this test is not running") - fun testOnQuestionnaireSubmission() = runTest { - val emitFeature: (GeoJsonFeature) -> Unit = spyk({}) - val extractedResourceIds = listOf(IdType(ResourceType.Location.name, location.logicalId)) - - viewModel.onQuestionnaireSubmission( - extractedResourceIds = extractedResourceIds, - emitFeature = emitFeature, - ) - val geoJsonFeatureSlot = slot() - verify { emitFeature(capture(geoJsonFeatureSlot)) } - - val geoJsonFeature = geoJsonFeatureSlot.captured - assertEquals( - location.position.longitude.toDouble(), - geoJsonFeature.geometry?.coordinates?.first(), - ) - assertEquals( - location.position.latitude.toDouble(), - geoJsonFeature.geometry?.coordinates?.last(), - ) - } - @Test @Ignore("Fix kotlinx.coroutines.test.UncompletedCoroutinesError") fun testEmitSnackBarState() { diff --git a/android/quest/src/test/java/org/smartregister/fhircore/quest/util/extensions/ConfigExtensionsKtTest.kt b/android/quest/src/test/java/org/smartregister/fhircore/quest/util/extensions/ConfigExtensionsKtTest.kt index df659b410d..3e225ecddf 100644 --- a/android/quest/src/test/java/org/smartregister/fhircore/quest/util/extensions/ConfigExtensionsKtTest.kt +++ b/android/quest/src/test/java/org/smartregister/fhircore/quest/util/extensions/ConfigExtensionsKtTest.kt @@ -351,7 +351,7 @@ class ConfigExtensionsKtTest : RobolectricTest() { ToolBarHomeNavigation.NAVIGATE_BACK, slotBundle.captured.getSerializable(NavigationArg.TOOL_BAR_HOME_NAVIGATION), ) - Assert.assertFalse(navOptions.captured.isPopUpToInclusive()) + Assert.assertTrue(navOptions.captured.isPopUpToInclusive()) Assert.assertTrue(navOptions.captured.shouldLaunchSingleTop()) }