diff --git a/android/geowidget/src/main/java/org/smartregister/fhircore/geowidget/model/GeoWidgetLocation.kt b/android/geowidget/src/main/java/org/smartregister/fhircore/geowidget/model/GeoWidgetLocation.kt new file mode 100644 index 00000000000..e68bbf8956a --- /dev/null +++ b/android/geowidget/src/main/java/org/smartregister/fhircore/geowidget/model/GeoWidgetLocation.kt @@ -0,0 +1,36 @@ +/* + * 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.model + +import java.math.BigDecimal + +data class GeoWidgetLocation( + val id: String = "", + val name: String = "", + val position: Position? = null, + val context: Context? = null, +) + +data class Position( + val latitude: BigDecimal = BigDecimal(0), + val longitude: BigDecimal = BigDecimal(0), +) + +data class Context( + val type: String = "", // Group, Patient, Healthcare Service + val reference: String = "", // the reference of 'type' +) diff --git a/android/geowidget/src/main/java/org/smartregister/fhircore/geowidget/screens/NewGeoWidgetFragment.kt b/android/geowidget/src/main/java/org/smartregister/fhircore/geowidget/screens/NewGeoWidgetFragment.kt new file mode 100644 index 00000000000..f57878a9bfc --- /dev/null +++ b/android/geowidget/src/main/java/org/smartregister/fhircore/geowidget/screens/NewGeoWidgetFragment.kt @@ -0,0 +1,212 @@ +/* + * 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 android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.LinearLayout +import androidx.appcompat.widget.Toolbar +import androidx.core.content.ContextCompat +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.lifecycle.lifecycleScope +import androidx.navigation.fragment.findNavController +import com.mapbox.geojson.FeatureCollection +import com.mapbox.geojson.MultiPoint +import com.mapbox.geojson.Point +import com.mapbox.mapboxsdk.Mapbox +import com.mapbox.mapboxsdk.camera.CameraUpdateFactory +import com.mapbox.mapboxsdk.geometry.LatLngBounds +import com.mapbox.mapboxsdk.maps.MapView +import com.mapbox.mapboxsdk.maps.Style +import com.mapbox.mapboxsdk.style.sources.GeoJsonSource +import com.mapbox.turf.TurfMeasurement +import io.ona.kujaku.utils.CoordinateUtils +import java.util.LinkedList +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import org.smartregister.fhircore.geowidget.BuildConfig +import org.smartregister.fhircore.geowidget.R +import org.smartregister.fhircore.geowidget.model.GeoWidgetLocation +import timber.log.Timber + +private const val ARG_PARAM1 = "param1" +private const val ARG_PARAM2 = "param2" + +class NewGeoWidgetFragment : Fragment() { + private var param1: String? = null + private var param2: String? = null + + val geoWidgetViewModel by activityViewModels<NewGeoWidgetViewModel>() + + private lateinit var mapView: MapView + private var geoJsonSource: GeoJsonSource? = null + private var featureCollection: FeatureCollection? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + arguments?.let { + param1 = it.getString(ARG_PARAM1) + param2 = it.getString(ARG_PARAM2) + } + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View { + Mapbox.getInstance(requireContext(), BuildConfig.MAPBOX_SDK_TOKEN) + return setupViews() + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) { + geoWidgetViewModel.featureCollectionFlow.collect { featureList -> + val featureCollection = FeatureCollection.fromFeatures(featureList) + this@NewGeoWidgetFragment.featureCollection = featureCollection + zoomToPointsOnMap(featureCollection) + } + } + } + + private fun setupViews(): LinearLayout { + val toolbar = setUpToolbar() + mapView = setUpMapView() + + return LinearLayout(requireContext()).apply { + orientation = LinearLayout.VERTICAL + addView(toolbar) + addView(mapView) + } + } + + private fun setUpMapView(): MapView { + return MapView(requireActivity()).apply { + id = R.id.kujaku_widget + val builder = Style.Builder().fromUri("asset://fhircore_style.json") + getMapAsync { mapboxMap -> + Timber.i("Get Map async finished") + mapboxMap.setStyle(builder) { style -> + Timber.i("Finished setting the style") + geoJsonSource = style.getSourceAs("quest-data-set") + if (geoJsonSource != null && featureCollection != null) { + Timber.i("Setting the feature collection") + geoJsonSource!!.setGeoJson(featureCollection) + } + } + } + } + } + + private fun setUpToolbar(): View { + return Toolbar(requireContext()).apply { + popupTheme = R.style.AppTheme + visibility = View.VISIBLE + navigationIcon = + ContextCompat.getDrawable(context, androidx.appcompat.R.drawable.abc_ic_ab_back_material) + layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, 168) + setBackgroundColor(ContextCompat.getColor(requireContext(), R.color.colorPrimary)) + setNavigationOnClickListener { findNavController().popBackStack() } + } + } + + private fun zoomToPointsOnMap(featureCollection: FeatureCollection?) { + featureCollection ?: return + + val points = LinkedList<Point>() + featureCollection.features()?.forEach { feature -> + val geometry = feature.geometry() + if (geometry is Point) { + points.add(geometry) + } + } + + if ((featureCollection.features()?.size ?: 0) == 0) return + + val bbox = TurfMeasurement.bbox(MultiPoint.fromLngLats(points)) + 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) + + mapView.getMapAsync { mapboxMap -> mapboxMap.easeCamera(finalCameraPosition) } + } + + override fun onStart() { + super.onStart() + mapView.onStart() + } + + override fun onResume() { + super.onResume() + mapView.onResume() + } + + override fun onPause() { + super.onPause() + mapView.onPause() + } + + override fun onStop() { + super.onStop() + mapView.onStop() + } + + override fun onDestroy() { + super.onDestroy() + mapView.onDestroy() + } + + override fun onLowMemory() { + super.onLowMemory() + mapView.onLowMemory() + } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + mapView.onSaveInstanceState(outState) + } + + fun addLocation(location: GeoWidgetLocation) { + geoWidgetViewModel.addLocation(location) + } + + companion object { + @JvmStatic + fun newInstance(param1: String, param2: String) = + NewGeoWidgetFragment().apply { + arguments = + Bundle().apply { + putString(ARG_PARAM1, param1) + putString(ARG_PARAM2, param2) + } + } + + fun builder() = Builder() + } +} + +class Builder { + + // todo: might be a good place to add callbacks + + fun build(): NewGeoWidgetFragment { + return NewGeoWidgetFragment() + } +} diff --git a/android/geowidget/src/main/java/org/smartregister/fhircore/geowidget/screens/NewGeoWidgetViewModel.kt b/android/geowidget/src/main/java/org/smartregister/fhircore/geowidget/screens/NewGeoWidgetViewModel.kt new file mode 100644 index 00000000000..ef3d67b06d7 --- /dev/null +++ b/android/geowidget/src/main/java/org/smartregister/fhircore/geowidget/screens/NewGeoWidgetViewModel.kt @@ -0,0 +1,53 @@ +/* + * 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 androidx.lifecycle.ViewModel +import com.mapbox.geojson.Feature +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import org.json.JSONObject +import org.smartregister.fhircore.engine.util.DispatcherProvider +import org.smartregister.fhircore.geowidget.model.GeoWidgetLocation +import org.smartregister.fhircore.geowidget.util.extensions.getGeoJsonGeometry + +@HiltViewModel +class NewGeoWidgetViewModel @Inject constructor(val dispatcherProvider: DispatcherProvider) : + ViewModel() { + + private val _featureCollectionFlow: MutableStateFlow<MutableList<Feature>> = + MutableStateFlow(mutableListOf()) + val featureCollectionFlow: StateFlow<List<Feature>> = _featureCollectionFlow + + fun addLocation(location: GeoWidgetLocation) { + val jsonFeature = + JSONObject().apply { + put("type", "Feature") + put("properties", JSONObject().apply { put("name", location.name) }) + put("geometry", location.getGeoJsonGeometry()) + } + val feature = Feature.fromJson(jsonFeature.toString()) + + _featureCollectionFlow.update { + it.add(feature) + it + } + } +} diff --git a/android/geowidget/src/main/java/org/smartregister/fhircore/geowidget/util/extensions/GeoWidgetExtensions.kt b/android/geowidget/src/main/java/org/smartregister/fhircore/geowidget/util/extensions/GeoWidgetExtensions.kt index 78a3148a818..c5930de0431 100644 --- a/android/geowidget/src/main/java/org/smartregister/fhircore/geowidget/util/extensions/GeoWidgetExtensions.kt +++ b/android/geowidget/src/main/java/org/smartregister/fhircore/geowidget/util/extensions/GeoWidgetExtensions.kt @@ -25,6 +25,7 @@ import org.hl7.fhir.r4.model.Location import org.json.JSONArray import org.json.JSONObject import org.smartregister.fhircore.geowidget.KujakuFhirCoreConverter +import org.smartregister.fhircore.geowidget.model.GeoWidgetLocation typealias Coordinate = Pair<Double, Double> @@ -86,6 +87,24 @@ fun Location.getGeoJsonGeometry(): JSONObject { return geometry } +fun GeoWidgetLocation.getGeoJsonGeometry(): JSONObject { + position ?: return JSONObject() + + val geometry = JSONObject() + + geometry.put("type", "Point") + geometry.put("coordinates", JSONArray(arrayOf(position.longitude, position.latitude))) + + // Boundary GeoJson Extension geometry overrides any lat, long declared in the Location.lat + // & Location.long + // if (hasBoundaryGeoJsonExt) { + // val featureFromExt = + // Base64.decodeBase64(boundaryGeoJsonExtAttachment!!.data).run { JSONObject(String(this)) } + // return featureFromExt.getJSONObject("geometry") + // } + return geometry +} + fun generateLocation(featureJSONObject: JSONObject, coordinates: Coordinate): Location { val (longitude, latitude) = coordinates 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 b75c1e3c7ba..f04295e5c65 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 @@ -36,7 +36,7 @@ sealed class MainNavigationScreen( object Profile : MainNavigationScreen(titleResource = R.string.profile, route = R.id.profileFragment) - object GeoWidget : MainNavigationScreen(route = R.id.geoWidgetFragment) + object GeoWidgetLauncher : MainNavigationScreen(route = R.id.geoWidgetLauncherFragment) object Insight : MainNavigationScreen(route = R.id.userInsightScreenFragment) diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/launcher/GeoWidgetLauncherFragment.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/launcher/GeoWidgetLauncherFragment.kt new file mode 100644 index 00000000000..aa95191401a --- /dev/null +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/launcher/GeoWidgetLauncherFragment.kt @@ -0,0 +1,198 @@ +/* + * Copyright 2021-2024 Ona Systems, Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.smartregister.fhircore.quest.ui.launcher + +import android.os.Bundle +import android.view.View +import androidx.fragment.app.Fragment +import androidx.fragment.app.commit +import androidx.lifecycle.lifecycleScope +import androidx.navigation.fragment.navArgs +import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import org.hl7.fhir.r4.model.CodeableConcept +import org.hl7.fhir.r4.model.Group +import org.hl7.fhir.r4.model.HealthcareService +import org.hl7.fhir.r4.model.Location +import org.hl7.fhir.r4.model.Patient +import org.hl7.fhir.r4.model.Reference +import org.hl7.fhir.r4.model.Resource +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.util.extension.referenceValue +import org.smartregister.fhircore.geowidget.model.Context +import org.smartregister.fhircore.geowidget.model.GeoWidgetLocation +import org.smartregister.fhircore.geowidget.model.Position +import org.smartregister.fhircore.geowidget.screens.GeoWidgetFragmentArgs +import org.smartregister.fhircore.geowidget.screens.NewGeoWidgetFragment +import org.smartregister.fhircore.quest.R + +private const val ARG_PARAM1 = "param1" +private const val ARG_PARAM2 = "param2" + +@AndroidEntryPoint +class GeoWidgetLauncherFragment : Fragment(R.layout.fragment_geo_widget_launcher) { + private var param1: String? = null + private var param2: String? = null + + @Inject lateinit var configurationRegistry: ConfigurationRegistry + private lateinit var geoWidgetConfiguration: GeoWidgetConfiguration + val geoWidgetActivityArgs by navArgs<GeoWidgetFragmentArgs>() + + private lateinit var contextResources: MutableList<Resource> + private lateinit var locationResources: MutableList<Location> + + private lateinit var geoWidgetFragment: NewGeoWidgetFragment + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + arguments?.let { + param1 = it.getString(ARG_PARAM1) + param2 = it.getString(ARG_PARAM2) + } + geoWidgetConfiguration = geoWidgetConfiguration() + contextResources = setupContextualData() + locationResources = setupLocationData() + } + + private fun geoWidgetConfiguration(): GeoWidgetConfiguration = + configurationRegistry.retrieveConfiguration( + ConfigType.GeoWidget, + geoWidgetActivityArgs.configId, + ) + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + geoWidgetFragment = NewGeoWidgetFragment() + if (savedInstanceState == null) { + addGeoWidgetFragment() + } + + // todo: retrieve resources from DB + viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) { + contextResources + .zip(locationResources) + .map { (fhirContextResource, fhirLocation) -> + GeoWidgetLocation( + id = fhirLocation.id, + name = fhirLocation.name, + position = Position(fhirLocation.position.latitude, fhirLocation.position.longitude), + context = + Context( + type = fhirContextResource.resourceType.name, + reference = fhirContextResource.referenceValue() + ), + ) + } + .forEach { geoWidgetLocation -> geoWidgetFragment.addLocation(geoWidgetLocation) } + } + } + + private fun addGeoWidgetFragment() { + childFragmentManager.commit { + add( + R.id.add_geo_widget_container, + geoWidgetFragment, + GEO_WIDGET_FRAGMENT_TAG, + ) + } + } + + private fun setupLocationData(): MutableList<Location> { + return mutableListOf<Location>().apply { + val locationG = + Location().apply { + id = "L12345" + name = "House of Group" + position = + Location.LocationPositionComponent().apply { + setLatitude(-7.818858188884883) + setLongitude(110.35671754065798) + } + } + add(locationG) + + val locationP = + Location().apply { + id = "L98765" + name = "House of Patient" + position = + Location.LocationPositionComponent().apply { + setLatitude(-7.82109457825887) + setLongitude(110.37188125807708) + } + } + add(locationP) + + val locationH = + Location().apply { + id = "L56789" + name = "House of Healthcare Service" + position = + Location.LocationPositionComponent().apply { + setLatitude(-7.815406346975602) + setLongitude(110.36236099536087) + } + } + add(locationH) + } + } + + private fun setupContextualData(): MutableList<Resource> { + return mutableListOf<Resource>().apply { + val group = + Group().apply { + id = "G12345" + addCharacteristic().apply { + value = CodeableConcept().apply { value = Reference("Location/L12345") } + } + } + add(group) + + val patient = + Patient().apply { + id = "P12345" + addExtension("location", Reference("Location/L98765")) + } + add(patient) + + val healthcareService = + HealthcareService().apply { + id = "H12345" + addLocation(Reference("Location/L56789")) + } + add(healthcareService) + } + } + + companion object { + const val GEO_WIDGET_FRAGMENT_TAG = "geo-widget-fragment-tag" + + @JvmStatic + fun newInstance(param1: String, param2: String) = + GeoWidgetLauncherFragment().apply { + arguments = + Bundle().apply { + putString(ARG_PARAM1, param1) + putString(ARG_PARAM2, param2) + } + } + } +} 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 e8e83a1b4c0..afd0c19c331 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 @@ -144,7 +144,7 @@ fun List<ActionConfig>.handleClickEvent( ApplicationWorkflow.DEVICE_TO_DEVICE_SYNC -> startP2PScreen(navController.context) ApplicationWorkflow.LAUNCH_MAP -> navController.navigate( - MainNavigationScreen.GeoWidget.route, + MainNavigationScreen.GeoWidgetLauncher.route, bundleOf(NavigationArg.CONFIG_ID to actionConfig.id), ) ApplicationWorkflow.LAUNCH_DIALLER -> { diff --git a/android/quest/src/main/res/layout/fragment_geo_widget_launcher.xml b/android/quest/src/main/res/layout/fragment_geo_widget_launcher.xml new file mode 100644 index 00000000000..ef02a4594cc --- /dev/null +++ b/android/quest/src/main/res/layout/fragment_geo_widget_launcher.xml @@ -0,0 +1,16 @@ +<?xml version="1.0" encoding="utf-8" ?> +<FrameLayout + xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:tools="http://schemas.android.com/tools" + android:layout_width="match_parent" + android:layout_height="match_parent" + tools:context=".ui.launcher.GeoWidgetLauncherFragment" + > + + <androidx.fragment.app.FragmentContainerView + android:id="@+id/add_geo_widget_container" + android:layout_width="match_parent" + android:layout_height="match_parent" + /> + +</FrameLayout> diff --git a/android/quest/src/main/res/navigation/application_nav_graph.xml b/android/quest/src/main/res/navigation/application_nav_graph.xml index eb2eec293d2..b3cc94ec185 100644 --- a/android/quest/src/main/res/navigation/application_nav_graph.xml +++ b/android/quest/src/main/res/navigation/application_nav_graph.xml @@ -5,16 +5,13 @@ android:id="@+id/nav_graph" app:startDestination="@id/registerFragment"> - <include app:graph="@navigation/geowidget_nav_graph" /> - <fragment - android:id="@+id/geoWidgetFragment" - android:name="org.smartregister.fhircore.geowidget.screens.GeoWidgetFragment"> - - <action - android:id="@+id/action_fragment_to_second_graph" - app:destination="@id/geo_widget_nav_graph" /> - + android:id="@+id/geoWidgetLauncherFragment" + android:name="org.smartregister.fhircore.quest.ui.launcher.GeoWidgetLauncherFragment"> + <argument + android:name="configId" + app:argType="string" + app:nullable="false" /> </fragment> <fragment diff --git a/android/quest/src/test/java/org/smartregister/fhircore/quest/util/extensions/ConfigExtensionsTest.kt b/android/quest/src/test/java/org/smartregister/fhircore/quest/util/extensions/ConfigExtensionsTest.kt index 0ea861177db..c51f8ee8987 100644 --- a/android/quest/src/test/java/org/smartregister/fhircore/quest/util/extensions/ConfigExtensionsTest.kt +++ b/android/quest/src/test/java/org/smartregister/fhircore/quest/util/extensions/ConfigExtensionsTest.kt @@ -20,7 +20,6 @@ import android.content.Context import android.content.Intent import android.net.Uri import android.os.Bundle -import androidx.core.content.ContextCompat.startActivity import androidx.navigation.NavController import androidx.navigation.NavDestination import androidx.navigation.NavOptions @@ -280,7 +279,7 @@ class ConfigExtensionsTest : RobolectricTest() { val slotInt = slot<Int>() val slotBundle = slot<Bundle>() verify { navController.navigate(capture(slotInt), capture(slotBundle)) } - Assert.assertEquals(MainNavigationScreen.GeoWidget.route, slotInt.captured) + Assert.assertEquals(MainNavigationScreen.GeoWidgetLauncher.route, slotInt.captured) verify { navController.navigate(capture(slotInt), capture(slotBundle)) } Assert.assertEquals(1, slotBundle.captured.size()) Assert.assertEquals("geoWidgetId", slotBundle.captured.getString(NavigationArg.CONFIG_ID))