Repository: dhis2/dhis2-android-capture-app
Pull request #4020: feat: [ANDROAPP-6832] Implement multiselect value type
Author: login: Balcan
Created: 2025-03-06T11:39:50Z | Merged: 2025-03-12T12:02:01Z

## Description
Please include a summary of the change and include the related jira issue if it exists.

[ jira issue ](https://dhis2.atlassian.net/browse/ANDROAPP-

## Solution description
If this PR is a fix include a brief description on how the issue is solved.
## Covered unit test cases
Describe the tests that you ran to verify your changes.
## Where did you test this issue?
- [ ] Smartphone Emulator
- [ ] Tablet Emulator
- [ ] Smartphone
- [ ] Tablet
## Which Android versions did you test this issue?
- [ ] 4.4
- [ ] 5.X - 6.X
- [ ] 7.X
- [ ] 8.X
- [ ] 9.X - 10.X
- [ ] 11.X - 13.X
- [ ] Other
## Checklist
- [ ] My code follows the style guidelines of this project
- [ ] I have performed a self-review of my own code



Diff:
diff --git a/aggregates/src/androidMain/kotlin/org/dhis2/mobile/aggregates/data/DataSetInstanceRepositoryImpl.kt b/aggregates/src/androidMain/kotlin/org/dhis2/mobile/aggregates/data/DataSetInstanceRepositoryImpl.kt
index 4abff6d0b24..0e5705a9e7c 100644
--- a/aggregates/src/androidMain/kotlin/org/dhis2/mobile/aggregates/data/DataSetInstanceRepositoryImpl.kt
+++ b/aggregates/src/androidMain/kotlin/org/dhis2/mobile/aggregates/data/DataSetInstanceRepositoryImpl.kt
@@ -11,6 +11,7 @@ import org.dhis2.mobile.aggregates.model.DataSetInstanceConfiguration
 import org.dhis2.mobile.aggregates.model.DataSetInstanceSectionConfiguration
 import org.dhis2.mobile.aggregates.model.DataSetRenderingConfig
 import org.dhis2.mobile.aggregates.model.DataToReview
+import org.dhis2.mobile.aggregates.model.InputType
 import org.dhis2.mobile.aggregates.model.MandatoryCellElements
 import org.dhis2.mobile.aggregates.model.PivoteMode
 import org.dhis2.mobile.aggregates.model.TableGroup
@@ -568,9 +569,10 @@ internal class DataSetInstanceRepositoryImpl(
                 attributeOptionCombo = attrOptionComboUid,
             )
 
-        val validator = d2.dataElementModule().dataElements()
+        val dataElement = d2.dataElementModule().dataElements()
             .uid(dataElementUid).blockingGet()
-            ?.valueType()?.validator
+
+        val validator = dataElement?.valueType()?.validator
 
         return try {
             if (value.isNullOrEmpty()) {
@@ -580,8 +582,8 @@ internal class DataSetInstanceRepositoryImpl(
                 valueRepository.blockingSet(validValue)
             }
             Result.success(Unit)
-        } catch (e: Exception) {
-            Result.failure(e)
+        } catch (t: Throwable) {
+            Result.failure(t)
         }
     }
 
@@ -604,13 +606,15 @@ internal class DataSetInstanceRepositoryImpl(
                 it.dataElement()?.uid() == dataElementUid &&
                     it.categoryOptionCombo()?.uid() == categoryOptionComboUid
             } != null
-
-        val inputType = requireNotNull(dataElement?.valueType()?.toInputType())
+        val dataElementValueType = dataElement?.valueType()?.toInputType()
+        val inputType = requireNotNull(dataElementValueType).takeIf {
+            (it !is InputType.MultiText) && dataElement.optionSet()?.uid() == null
+        } ?: if (dataElementValueType is InputType.MultiText) InputType.MultiText else InputType.OptionSet
 
         return DataElementInfo(
-            label = "${dataElement?.displayFormName()}/${categoryOptionCombo?.displayName()}",
+            label = "${dataElement.displayFormName()}/${categoryOptionCombo?.displayName()}",
             inputType = inputType,
-            description = dataElement?.displayDescription(),
+            description = dataElement.displayDescription(),
             isRequired = isMandatory,
         )
     }
diff --git a/aggregates/src/androidMain/kotlin/org/dhis2/mobile/aggregates/data/OptionRepositoryImpl.kt b/aggregates/src/androidMain/kotlin/org/dhis2/mobile/aggregates/data/OptionRepositoryImpl.kt
new file mode 100644
index 00000000000..adb649188f1
--- /dev/null
+++ b/aggregates/src/androidMain/kotlin/org/dhis2/mobile/aggregates/data/OptionRepositoryImpl.kt
@@ -0,0 +1,33 @@
+package org.dhis2.mobile.aggregates.data
+
+import androidx.paging.map
+import kotlinx.coroutines.flow.map
+import org.dhis2.commons.bindings.dataElement
+import org.dhis2.mobile.aggregates.model.OptionData
+import org.hisp.dhis.android.core.D2
+
+class OptionRepositoryImpl(private val d2: D2) : OptionRepository {
+    override suspend fun optionCount(dataElementUid: String): Int {
+        return d2.dataElement(dataElementUid)?.optionSet()?.uid()?.let { optionSetUid ->
+            d2.optionModule().options()
+                .byOptionSetUid().eq(optionSetUid)
+                .blockingCount()
+        } ?: 0
+    }
+
+    override suspend fun options(dataElementUid: String): List<OptionData> {
+        val optionSetUid = d2.dataElement(dataElementUid)?.optionSet()?.uid() ?: return emptyList()
+        val optionFlow = d2.optionModule().options()
+            .byOptionSetUid().eq(optionSetUid)
+            .blockingGet()
+            .map { option ->
+                OptionData(
+                    uid = option.code() ?: option.uid(),
+                    code = option.code(),
+                    label = option.displayName() ?: option.uid(),
+                )
+            }
+
+        return optionFlow
+    }
+}
diff --git a/aggregates/src/androidMain/kotlin/org/dhis2/mobile/aggregates/di/AggregateModule.android.kt b/aggregates/src/androidMain/kotlin/org/dhis2/mobile/aggregates/di/AggregateModule.android.kt
index 77a8433693b..a0e8bac34e2 100644
--- a/aggregates/src/androidMain/kotlin/org/dhis2/mobile/aggregates/di/AggregateModule.android.kt
+++ b/aggregates/src/androidMain/kotlin/org/dhis2/mobile/aggregates/di/AggregateModule.android.kt
@@ -2,6 +2,8 @@ package org.dhis2.mobile.aggregates.di
 
 import org.dhis2.mobile.aggregates.data.DataSetInstanceRepository
 import org.dhis2.mobile.aggregates.data.DataSetInstanceRepositoryImpl
+import org.dhis2.mobile.aggregates.data.OptionRepository
+import org.dhis2.mobile.aggregates.data.OptionRepositoryImpl
 import org.dhis2.mobile.aggregates.ui.UIActionHandler
 import org.dhis2.mobile.aggregates.ui.UIActionHandlerImpl
 import org.koin.core.module.Module
@@ -10,5 +12,6 @@ import org.koin.dsl.module
 actual val platformModule: Module
     get() = module {
         single<DataSetInstanceRepository> { DataSetInstanceRepositoryImpl(get()) }
+        single<OptionRepository> { OptionRepositoryImpl(get()) }
         single<UIActionHandler> { params -> UIActionHandlerImpl(params.get(), params.get()) }
     }
diff --git a/aggregates/src/androidMain/kotlin/org/dhis2/mobile/aggregates/ui/UIActionHandlerImpl.kt b/aggregates/src/androidMain/kotlin/org/dhis2/mobile/aggregates/ui/UIActionHandlerImpl.kt
index 39a6fb5bbc9..5f2296e07ed 100644
--- a/aggregates/src/androidMain/kotlin/org/dhis2/mobile/aggregates/ui/UIActionHandlerImpl.kt
+++ b/aggregates/src/androidMain/kotlin/org/dhis2/mobile/aggregates/ui/UIActionHandlerImpl.kt
@@ -1,7 +1,9 @@
 package org.dhis2.mobile.aggregates.ui
 
 import android.app.Activity
+import android.content.ActivityNotFoundException
 import android.content.Intent
+import android.net.Uri
 import androidx.activity.result.contract.ActivityResultContracts
 import androidx.fragment.app.FragmentActivity
 import org.dhis2.commons.orgunitselector.OUTreeFragment
@@ -14,6 +16,7 @@ import org.dhis2.maps.views.MapSelectorActivity.Companion.INITIAL_GEOMETRY_COORD
 import org.dhis2.maps.views.MapSelectorActivity.Companion.LOCATION_TYPE_EXTRA
 import org.dhis2.maps.views.MapSelectorActivity.Companion.PROGRAM_UID
 import org.dhis2.maps.views.MapSelectorActivity.Companion.SCOPE
+import org.dhis2.mobile.aggregates.R
 
 internal class UIActionHandlerImpl(
     private val context: FragmentActivity,
@@ -70,4 +73,41 @@ internal class UIActionHandlerImpl(
             .build()
             .show(context.supportFragmentManager, dataSetUid)
     }
+
+    override fun onCall(phoneNumber: String, onActivityNotFound: () -> Unit) {
+        val phoneCallIntent = Intent(Intent.ACTION_DIAL).apply {
+            data = Uri.parse("tel:$phoneNumber")
+        }
+        launchIntentChooser(phoneCallIntent, onActivityNotFound)
+    }
+
+    override fun onSendEmail(email: String, onActivityNotFound: () -> Unit) {
+        val phoneCallIntent = Intent(Intent.ACTION_SENDTO).apply {
+            data = Uri.parse("mailto:$email")
+        }
+        launchIntentChooser(phoneCallIntent, onActivityNotFound)
+    }
+
+    override fun onOpenLink(url: String, onActivityNotFound: () -> Unit) {
+        val phoneCallIntent = Intent(Intent.ACTION_VIEW).apply {
+            data =
+                if (!url.startsWith("http://") && !url.startsWith("https://")) {
+                    Uri.parse("http://$url")
+                } else {
+                    Uri.parse(url)
+                }
+        }
+        launchIntentChooser(phoneCallIntent, onActivityNotFound)
+    }
+
+    private fun launchIntentChooser(intent: Intent, onActivityNotFound: () -> Unit) {
+        val title = context.getString(R.string.open_with)
+        val chooser = Intent.createChooser(intent, title)
+
+        try {
+            context.startActivity(chooser)
+        } catch (e: ActivityNotFoundException) {
+            onActivityNotFound()
+        }
+    }
 }
diff --git a/aggregates/src/commonMain/composeResources/values/strings.xml b/aggregates/src/commonMain/composeResources/values/strings.xml
index d43125fed03..52d8de95327 100644
--- a/aggregates/src/commonMain/composeResources/values/strings.xml
+++ b/aggregates/src/commonMain/composeResources/values/strings.xml
@@ -36,5 +36,5 @@
     <string name="complete_anyway">Complete anyway</string>
     <string name="review">Review</string>
     <string name="validation_rules_data_to_review">Data to review</string>
-
+    <string name="action_can_not_be_performed">This action can't be performed</string>
 </resources>
\ No newline at end of file
diff --git a/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/data/OptionRepository.kt b/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/data/OptionRepository.kt
new file mode 100644
index 00000000000..e12428206b8
--- /dev/null
+++ b/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/data/OptionRepository.kt
@@ -0,0 +1,8 @@
+package org.dhis2.mobile.aggregates.data
+
+import org.dhis2.mobile.aggregates.model.OptionData
+
+interface OptionRepository {
+    suspend fun optionCount(dataElementUid: String): Int
+    suspend fun options(dataElementUid: String): List<OptionData>
+}
diff --git a/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/di/AggregateModule.kt b/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/di/AggregateModule.kt
index 1095f243797..3b7e94a324c 100644
--- a/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/di/AggregateModule.kt
+++ b/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/di/AggregateModule.kt
@@ -78,6 +78,7 @@ internal val featureModule = module {
             orgUnitUid = params.get(),
             attrOptionComboUid = params.get(),
             repository = get(),
+            optionRepository = get(),
         )
     }
 
diff --git a/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/domain/GetDataValueInput.kt b/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/domain/GetDataValueInput.kt
index 2807fffde46..a7c27d2e605 100644
--- a/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/domain/GetDataValueInput.kt
+++ b/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/domain/GetDataValueInput.kt
@@ -1,17 +1,13 @@
 package org.dhis2.mobile.aggregates.domain
 
+import kotlinx.coroutines.supervisorScope
 import org.dhis2.mobile.aggregates.data.DataSetInstanceRepository
+import org.dhis2.mobile.aggregates.data.OptionRepository
 import org.dhis2.mobile.aggregates.model.CellInfo
+import org.dhis2.mobile.aggregates.model.CellValueExtra
 import org.dhis2.mobile.aggregates.model.InputType
-import org.dhis2.mobile.aggregates.ui.inputs.TableId
-import org.dhis2.mobile.aggregates.ui.states.InputExtra
-import org.dhis2.mobile.commons.extensions.getFormattedFileSize
 import org.dhis2.mobile.commons.extensions.userFriendlyValue
 import org.hisp.dhis.mobile.ui.designsystem.component.Coordinates
-import org.hisp.dhis.mobile.ui.designsystem.component.SelectableDates
-import org.hisp.dhis.mobile.ui.designsystem.component.model.DateTimeTransformation
-import org.hisp.dhis.mobile.ui.designsystem.component.model.DateTransformation
-import org.hisp.dhis.mobile.ui.designsystem.component.model.TimeTransformation
 
 internal class GetDataValueInput(
     private val dataSetUid: String,
@@ -19,13 +15,18 @@ internal class GetDataValueInput(
     private val orgUnitUid: String,
     private val attrOptionComboUid: String,
     private val repository: DataSetInstanceRepository,
+    private val optionRepository: OptionRepository,
 ) : ValueValidator(repository) {
     suspend operator fun invoke(
-        rowIds: List<TableId>,
-        columnIds: List<TableId>,
-    ): CellInfo {
-        val dataElementUid = checkOnlyOneDataElementIsProvided(rowIds, columnIds)
-        val categoryOptionComboUid = checkedCategoryOptionCombos(rowIds, columnIds)
+        dataElementUid: String,
+        categoryOptionComboUidData: Pair<String?, List<String>>,
+        fetchOptions: Boolean = false,
+    ): CellInfo = supervisorScope {
+        val categoryOptionComboUid =
+            categoryOptionComboUidData.first
+                ?: repository.categoryOptionComboFromCategoryOptions(
+                    categoryOptionComboUidData.second,
+                )
 
         val dataElementInfo = repository.dataElementInfo(
             dataSetUid = dataSetUid,
@@ -57,45 +58,34 @@ internal class GetDataValueInput(
             attrOptionComboUid = attrOptionComboUid,
         )
 
-        return CellInfo(
+        CellInfo(
             label = dataElementInfo.label,
             value = value,
             displayValue = value?.userFriendlyValue(dataElementUid),
             inputType = dataElementInfo.inputType,
             inputExtra = when (dataElementInfo.inputType) {
-                InputType.Age -> InputExtra.Age(
-                    selectableDates = SelectableDates("01011940", "12312300"),
-                )
+                InputType.Coordinates -> value?.let {
+                    val (lat, long) = repository.getCoordinatesFrom(it)
+                    CellValueExtra.Coordinates(
+                        lat = lat,
+                        lon = long,
+                    )
+                }
 
-                InputType.Date, InputType.Time, InputType.DateTime ->
-                    InputExtra.Date(
-                        allowManualInput = true,
-                        is24HourFormat = true,
-                        visualTransformation = when (dataElementInfo.inputType) {
-                            InputType.Date -> DateTransformation()
-                            InputType.DateTime -> DateTimeTransformation()
-                            InputType.Time -> TimeTransformation()
-                            else -> throw IllegalArgumentException("Invalid input type")
+                InputType.MultiText, InputType.OptionSet -> {
+                    val optionCount = optionRepository.optionCount(dataElementUid)
+                    CellValueExtra.Options(
+                        optionCount = optionCount,
+                        options = if (fetchOptions || optionCount < 7) {
+                            optionRepository.options(dataElementUid)
+                        } else {
+                            emptyList()
                         },
-                        selectableDates = SelectableDates("01011940", "12312300"),
-                        yearRange = IntRange(1940, 2300),
+                        optionsFetched = fetchOptions || optionCount < 7,
                     )
+                }
 
-                InputType.Coordinates -> InputExtra.Coordinate(
-                    coordinateValue = value?.let {
-                        val (lat, long) = repository.getCoordinatesFrom(it)
-                        Coordinates(
-                            latitude = lat,
-                            longitude = long,
-                        )
-                    },
-                )
-
-                InputType.FileResource -> InputExtra.File(
-                    fileWeight = value?.let { getFormattedFileSize(value) },
-                )
-
-                else -> InputExtra.None
+                else -> null
             },
             supportingText = dataElementInfo.description?.let { listOf(it) } ?: emptyList(),
             errors = conflicts.first,
diff --git a/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/model/CellInfo.kt b/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/model/CellInfo.kt
index 327e9b12aae..fb9f4dc711c 100644
--- a/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/model/CellInfo.kt
+++ b/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/model/CellInfo.kt
@@ -1,13 +1,11 @@
 package org.dhis2.mobile.aggregates.model
 
-import org.dhis2.mobile.aggregates.ui.states.InputExtra
-
 internal data class CellInfo(
     val label: String,
     val value: String?,
     val displayValue: String?,
     val inputType: InputType,
-    val inputExtra: InputExtra,
+    val inputExtra: CellValueExtra?,
     val supportingText: List<String>,
     val errors: List<String>,
     val warnings: List<String>,
@@ -15,3 +13,12 @@ internal data class CellInfo(
     val legendLabel: String?,
     val isRequired: Boolean,
 )
+
+internal sealed class CellValueExtra {
+    data class Coordinates(val lat: Double, val lon: Double) : CellValueExtra()
+    data class Options(
+        val optionCount: Int,
+        val options: List<OptionData>,
+        val optionsFetched: Boolean,
+    ) : CellValueExtra()
+}
diff --git a/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/model/InputType.kt b/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/model/InputType.kt
index b43fef3ec69..a1b91487fb4 100644
--- a/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/model/InputType.kt
+++ b/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/model/InputType.kt
@@ -1,6 +1,7 @@
 package org.dhis2.mobile.aggregates.model
 
 internal sealed class InputType {
+    data object OptionSet : InputType()
     data object Text : InputType()
     data object LongText : InputType()
     data object Letter : InputType()
diff --git a/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/model/OptionData.kt b/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/model/OptionData.kt
new file mode 100644
index 00000000000..4300aa31798
--- /dev/null
+++ b/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/model/OptionData.kt
@@ -0,0 +1,7 @@
+package org.dhis2.mobile.aggregates.model
+
+data class OptionData(
+    val uid: String,
+    val code: String?,
+    val label: String,
+)
diff --git a/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/model/mapper/CellInfoToInputData.kt b/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/model/mapper/CellInfoToInputData.kt
index 72b55c0defa..1a8b40a6a7d 100644
--- a/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/model/mapper/CellInfoToInputData.kt
+++ b/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/model/mapper/CellInfoToInputData.kt
@@ -1,15 +1,29 @@
 package org.dhis2.mobile.aggregates.model.mapper
 
 import androidx.compose.ui.graphics.Color
+import kotlinx.coroutines.async
+import kotlinx.coroutines.awaitAll
+import kotlinx.coroutines.supervisorScope
 import org.dhis2.mobile.aggregates.model.CellInfo
+import org.dhis2.mobile.aggregates.model.CellValueExtra
+import org.dhis2.mobile.aggregates.model.InputType
 import org.dhis2.mobile.aggregates.ui.states.InputData
+import org.dhis2.mobile.aggregates.ui.states.InputExtra
+import org.dhis2.mobile.commons.extensions.getFormattedFileSize
 import org.dhis2.mobile.commons.extensions.toColor
+import org.hisp.dhis.mobile.ui.designsystem.component.CheckBoxData
+import org.hisp.dhis.mobile.ui.designsystem.component.Coordinates
 import org.hisp.dhis.mobile.ui.designsystem.component.InputShellState
 import org.hisp.dhis.mobile.ui.designsystem.component.LegendData
+import org.hisp.dhis.mobile.ui.designsystem.component.RadioButtonData
+import org.hisp.dhis.mobile.ui.designsystem.component.SelectableDates
 import org.hisp.dhis.mobile.ui.designsystem.component.SupportingTextData
 import org.hisp.dhis.mobile.ui.designsystem.component.SupportingTextState
+import org.hisp.dhis.mobile.ui.designsystem.component.model.DateTimeTransformation
+import org.hisp.dhis.mobile.ui.designsystem.component.model.DateTransformation
+import org.hisp.dhis.mobile.ui.designsystem.component.model.TimeTransformation
 
-internal fun CellInfo.toInputData(cellId: String) =
+internal suspend fun CellInfo.toInputData(cellId: String) = supervisorScope {
     InputData(
         id = cellId,
         label = label,
@@ -17,7 +31,79 @@ internal fun CellInfo.toInputData(cellId: String) =
         displayValue = displayValue,
         inputType = inputType,
         inputShellState = InputShellState.UNFOCUSED,
-        inputExtra = inputExtra,
+        inputExtra = when (inputType) {
+            InputType.Age -> InputExtra.Age(
+                selectableDates = SelectableDates("01011940", "12312300"),
+            )
+
+            InputType.Date, InputType.Time, InputType.DateTime ->
+                InputExtra.Date(
+                    allowManualInput = true,
+                    is24HourFormat = true,
+                    visualTransformation = when (inputType) {
+                        InputType.Date -> DateTransformation()
+                        InputType.DateTime -> DateTimeTransformation()
+                        InputType.Time -> TimeTransformation()
+                        else -> throw IllegalArgumentException("Invalid input type")
+                    },
+                    selectableDates = SelectableDates("01011940", "12312300"),
+                    yearRange = IntRange(1940, 2300),
+                )
+
+            InputType.Coordinates -> InputExtra.Coordinate(
+                coordinateValue = (inputExtra as? CellValueExtra.Coordinates)?.let {
+                    Coordinates(
+                        latitude = it.lat,
+                        longitude = it.lon,
+                    )
+                },
+            )
+
+            InputType.FileResource -> InputExtra.File(
+                fileWeight = value?.let { getFormattedFileSize(value) },
+            )
+
+            InputType.MultiText -> (inputExtra as? CellValueExtra.Options)?.let {
+                InputExtra.MultiText(
+                    numberOfOptions = it.optionCount,
+                    options = it.options.map { optionData ->
+                        async {
+                            CheckBoxData(
+                                uid = optionData.code ?: optionData.uid,
+                                checked = optionData.code?.let { code ->
+                                    value?.split(",")
+                                        ?.find { valueCode -> valueCode == code } != null
+                                } ?: false,
+                                enabled = true,
+                                textInput = optionData.label,
+                            )
+                        }
+                    }.awaitAll(),
+                    optionsFetched = it.optionsFetched,
+                )
+            } ?: InputExtra.None
+
+            InputType.OptionSet -> (inputExtra as? CellValueExtra.Options)?.let {
+                InputExtra.OptionSet(
+                    numberOfOptions = it.optionCount,
+                    options = it.options.map { optionData ->
+                        async {
+                            RadioButtonData(
+                                uid = optionData.code ?: optionData.uid,
+                                selected = optionData.code?.let { code ->
+                                    value == code
+                                } ?: false,
+                                enabled = true,
+                                textInput = optionData.label,
+                            )
+                        }
+                    }.awaitAll(),
+                    optionsFetched = it.optionsFetched,
+                )
+            } ?: InputExtra.None
+
+            else -> InputExtra.None
+        },
         supportingText = supportingText.map { text ->
             SupportingTextData(
                 text = text,
@@ -44,3 +130,4 @@ internal fun CellInfo.toInputData(cellId: String) =
         },
         isRequired = isRequired,
     )
+}
diff --git a/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/ui/UIActionHandler.kt b/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/ui/UIActionHandler.kt
index 0f410d95799..399bea56c3d 100644
--- a/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/ui/UIActionHandler.kt
+++ b/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/ui/UIActionHandler.kt
@@ -12,4 +12,8 @@ internal interface UIActionHandler {
         preselectedOrgUnits: List<String>,
         callback: (result: String?) -> Unit,
     )
+
+    fun onCall(phoneNumber: String, onActivityNotFound: () -> Unit)
+    fun onSendEmail(email: String, onActivityNotFound: () -> Unit)
+    fun onOpenLink(url: String, onActivityNotFound: () -> Unit)
 }
diff --git a/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/ui/inputs/InputProvider.kt b/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/ui/inputs/InputProvider.kt
index 4398462116f..e43edd08f96 100644
--- a/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/ui/inputs/InputProvider.kt
+++ b/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/ui/inputs/InputProvider.kt
@@ -1,15 +1,28 @@
 package org.dhis2.mobile.aggregates.ui.inputs
 
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.shape.RoundedCornerShape
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.derivedStateOf
 import androidx.compose.runtime.getValue
 import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
 import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
 import androidx.compose.ui.text.TextRange
 import androidx.compose.ui.text.input.ImeAction
 import androidx.compose.ui.text.input.TextFieldValue
+import kotlinx.coroutines.async
+import kotlinx.coroutines.awaitAll
+import kotlinx.coroutines.launch
 import org.dhis2.mobile.aggregates.model.InputType
 import org.dhis2.mobile.aggregates.resources.Res
 import org.dhis2.mobile.aggregates.resources.action_done
@@ -31,9 +44,12 @@ import org.dhis2.mobile.commons.extensions.getDateFromAge
 import org.hisp.dhis.mobile.ui.designsystem.component.AgeInputType
 import org.hisp.dhis.mobile.ui.designsystem.component.CheckBoxData
 import org.hisp.dhis.mobile.ui.designsystem.component.DateTimeActionType
+import org.hisp.dhis.mobile.ui.designsystem.component.DropdownItem
 import org.hisp.dhis.mobile.ui.designsystem.component.InputAge
+import org.hisp.dhis.mobile.ui.designsystem.component.InputCheckBox
 import org.hisp.dhis.mobile.ui.designsystem.component.InputCoordinate
 import org.hisp.dhis.mobile.ui.designsystem.component.InputDateTime
+import org.hisp.dhis.mobile.ui.designsystem.component.InputDropDown
 import org.hisp.dhis.mobile.ui.designsystem.component.InputEmail
 import org.hisp.dhis.mobile.ui.designsystem.component.InputFileResource
 import org.hisp.dhis.mobile.ui.designsystem.component.InputImage
@@ -50,11 +66,15 @@ import org.hisp.dhis.mobile.ui.designsystem.component.InputPercentage
 import org.hisp.dhis.mobile.ui.designsystem.component.InputPhoneNumber
 import org.hisp.dhis.mobile.ui.designsystem.component.InputPositiveInteger
 import org.hisp.dhis.mobile.ui.designsystem.component.InputPositiveIntegerOrZero
+import org.hisp.dhis.mobile.ui.designsystem.component.InputRadioButton
 import org.hisp.dhis.mobile.ui.designsystem.component.InputText
 import org.hisp.dhis.mobile.ui.designsystem.component.InputUnitInterval
 import org.hisp.dhis.mobile.ui.designsystem.component.InputYesNoField
 import org.hisp.dhis.mobile.ui.designsystem.component.InputYesNoFieldValues
 import org.hisp.dhis.mobile.ui.designsystem.component.InputYesOnlyCheckBox
+import org.hisp.dhis.mobile.ui.designsystem.component.Orientation
+import org.hisp.dhis.mobile.ui.designsystem.component.ProgressIndicator
+import org.hisp.dhis.mobile.ui.designsystem.component.ProgressIndicatorType
 import org.hisp.dhis.mobile.ui.designsystem.component.UploadFileState
 import org.hisp.dhis.mobile.ui.designsystem.component.UploadState
 import org.hisp.dhis.mobile.ui.designsystem.component.model.RegExValidations
@@ -62,7 +82,9 @@ import org.hisp.dhis.mobile.ui.designsystem.component.state.InputAgeData
 import org.hisp.dhis.mobile.ui.designsystem.component.state.InputDateTimeData
 import org.hisp.dhis.mobile.ui.designsystem.component.state.rememberInputAgeState
 import org.hisp.dhis.mobile.ui.designsystem.component.state.rememberInputDateTimeState
+import org.hisp.dhis.mobile.ui.designsystem.theme.Radius
 import org.hisp.dhis.mobile.ui.designsystem.theme.Spacing.Spacing0
+import org.hisp.dhis.mobile.ui.designsystem.theme.Spacing.Spacing8
 import org.jetbrains.compose.resources.stringResource
 
 @Composable
@@ -90,6 +112,8 @@ internal fun InputProvider(
         mutableStateOf(ImeAction.Next) // TODO: Update from inputData
     }
 
+    val scope = rememberCoroutineScope()
+
     when (inputData.inputType) {
         InputType.Age -> {
             var inputType by remember {
@@ -477,35 +501,204 @@ internal fun InputProvider(
             )
         }
 
+        InputType.OptionSet -> {
+            val optionSetExtra = inputData.optionSetExtras()
+            var currentSearchQuery by remember(inputData) {
+                mutableStateOf("")
+            }
+            val options by remember(inputData, currentSearchQuery) {
+                derivedStateOf {
+                    optionSetExtra.options.filter {
+                        currentSearchQuery.isEmpty() or (
+                            it.textInput?.text?.contains(
+                                currentSearchQuery,
+                                true,
+                            ) == true
+                            )
+                    }
+                }
+            }
+
+            if (inputData.value == null && !optionSetExtra.optionsFetched) {
+                LaunchedEffect(inputData) {
+                    onAction(UiAction.OnFetchOptions(inputData.id))
+                }
+                Box(
+                    modifier = modifier
+                        .fillMaxWidth()
+                        .background(color = inputData.inputStyle.backGroundColor)
+                        .clip(shape = RoundedCornerShape(Radius.XS, Radius.XS)),
+                    contentAlignment = Alignment.Center,
+                ) {
+                    ProgressIndicator(
+                        modifier = Modifier.padding(Spacing8),
+                        type = ProgressIndicatorType.CIRCULAR_SMALL,
+                    )
+                }
+            } else if (optionSetExtra.numberOfOptions < 7) {
+                InputRadioButton(
+                    title = inputData.label,
+                    radioButtonData = options,
+                    modifier = modifier,
+                    orientation = Orientation.VERTICAL,
+                    state = inputData.inputShellState,
+                    inputStyle = inputData.inputStyle,
+                    supportingText = inputData.supportingText,
+                    legendData = inputData.legendData,
+                    isRequired = inputData.isRequired,
+                    itemSelected = options.find { it.textInput?.text == inputData.displayValue },
+                    onItemChange = { data ->
+                        onAction(UiAction.OnValueChanged(inputData.id, data?.uid))
+                    },
+                )
+            } else {
+                val optionCount by remember(options) {
+                    derivedStateOf {
+                        options.size
+                    }
+                }
+                var selectedItem by remember(inputData) {
+                    mutableStateOf(
+                        inputData.displayValue?.let {
+                            DropdownItem(it)
+                        },
+                    )
+                }
+                InputDropDown(
+                    title = inputData.label,
+                    state = inputData.inputShellState,
+                    inputStyle = inputData.inputStyle,
+                    itemCount = optionCount,
+                    onSearchOption = {
+                        currentSearchQuery = it
+                    },
+                    fetchItem = { index ->
+                        DropdownItem(options[index].textInput?.text!!)
+                    },
+                    selectedItem = selectedItem,
+                    supportingTextData = inputData.supportingText,
+                    legendData = inputData.legendData,
+                    isRequiredField = inputData.isRequired,
+                    modifier = modifier,
+                    onResetButtonClicked = {
+                        onAction(UiAction.OnValueChanged(inputData.id, null))
+                    },
+                    onItemSelected = { index, newSelectedItem ->
+                        selectedItem = newSelectedItem
+                        onAction(UiAction.OnValueChanged(inputData.id, options[index].uid))
+                    },
+                    useDropDown = false,
+                    loadOptions = {
+                        onAction(UiAction.OnFetchOptions(inputData.id))
+                    },
+                    onDismiss = {
+                        currentSearchQuery = ""
+                    },
+                )
+            }
+        }
+
         InputType.MultiText -> {
-            val dataMap = buildMap<String, CheckBoxData> {
-                // TODO: Fetch options with paging-> paging is not available in multiplatform.
+            val multiTextExtras = inputData.multiTextExtras()
+            var data: List<CheckBoxData> by remember(inputData) {
+                mutableStateOf(multiTextExtras.options)
             }
 
-            val (codeList, data) = dataMap.toList().unzip()
+            LaunchedEffect(multiTextExtras.optionsFetched) {
+                if (!multiTextExtras.optionsFetched) {
+                    onAction(UiAction.OnFetchOptions(inputData.id))
+                }
+            }
 
-            InputMultiSelection(
-                items = data,
-                title = inputData.label,
-                state = inputData.inputShellState,
-                bottomSheetLowerPadding = Spacing0,
-                supportingTextData = inputData.supportingText,
-                legendData = inputData.legendData,
-                isRequired = inputData.isRequired,
-                onItemsSelected = { updatedData ->
-                    val selectedCodes =
-                        updatedData.filter { it.checked }.joinToString(",") { it.uid }
-                    onAction(UiAction.OnValueChanged(inputData.id, selectedCodes))
-                },
-                modifier = modifier,
-                noResultsFoundString = stringResource(Res.string.no_results_found),
-                searchToFindMoreString = stringResource(Res.string.search_to_find_more),
-                doneButtonText = stringResource(Res.string.action_done),
-                inputStyle = inputData.inputStyle,
-                onClearItemSelection = {
-                    onAction(UiAction.OnValueChanged(inputData.id, null))
-                },
-            )
+            if (inputData.value == null && !multiTextExtras.optionsFetched) {
+                Box(
+                    modifier = modifier
+                        .fillMaxWidth()
+                        .background(color = inputData.inputStyle.backGroundColor)
+                        .clip(shape = RoundedCornerShape(Radius.XS, Radius.XS)),
+                    contentAlignment = Alignment.Center,
+                ) {
+                    ProgressIndicator(
+                        modifier = Modifier.padding(Spacing8),
+                        type = ProgressIndicatorType.CIRCULAR_SMALL,
+                    )
+                }
+            } else if (multiTextExtras.numberOfOptions < 7) {
+                InputCheckBox(
+                    title = inputData.label,
+                    checkBoxData = data,
+                    modifier = modifier,
+                    orientation = Orientation.VERTICAL,
+                    state = inputData.inputShellState,
+                    supportingText = inputData.supportingText,
+                    legendData = inputData.legendData,
+                    isRequired = inputData.isRequired,
+                    inputStyle = inputData.inputStyle,
+                    onItemChange = { updatedCheckBoxData ->
+                        scope.launch {
+                            data = data.map {
+                                async {
+                                    if (it.uid == updatedCheckBoxData.uid) {
+                                        it.copy(checked = updatedCheckBoxData.checked)
+                                    } else {
+                                        it
+                                    }
+                                }
+                            }.awaitAll()
+                            val selectedData = data.filter { it.checked }
+                            onAction(
+                                UiAction.OnValueChanged(
+                                    inputData.id,
+                                    if (selectedData.isNotEmpty()) {
+                                        selectedData.joinToString(",") { it.uid }
+                                    } else {
+                                        null
+                                    },
+                                ),
+                            )
+                        }
+                    },
+                    onClearSelection = {
+                        onAction(UiAction.OnValueChanged(inputData.id, null))
+                    },
+                )
+            } else {
+                InputMultiSelection(
+                    items = data,
+                    title = inputData.label,
+                    state = inputData.inputShellState,
+                    bottomSheetLowerPadding = Spacing0,
+                    supportingTextData = inputData.supportingText,
+                    legendData = inputData.legendData,
+                    isRequired = inputData.isRequired,
+                    onItemsSelected = { updatedCheckBoxData ->
+                        scope.launch {
+                            data = updatedCheckBoxData
+                            val selectedData = data.filter { it.checked }
+                            onAction(
+                                UiAction.OnValueChanged(
+                                    inputData.id,
+                                    if (selectedData.isNotEmpty()) {
+                                        selectedData.joinToString(",") { it.uid }
+                                    } else {
+                                        null
+                                    },
+                                ),
+                            )
+                        }
+                    },
+                    modifier = modifier,
+                    noResultsFoundString = stringResource(Res.string.no_results_found),
+                    searchToFindMoreString = stringResource(Res.string.search_to_find_more),
+                    doneButtonText = stringResource(Res.string.action_done),
+                    inputStyle = inputData.inputStyle,
+                    onClearItemSelection = {
+                        onAction(UiAction.OnValueChanged(inputData.id, null))
+                    },
+                    bottomSheetExpanded = inputData.value == null,
+                    maxItemsToShow = data.size,
+                )
+            }
         }
 
         InputType.Number -> {
@@ -669,7 +862,14 @@ internal fun InputProvider(
                 onFocusChanged = { onAction.invoke(UiAction.OnFocusChanged(inputData.id, it)) },
                 imeAction = imeAction,
                 modifier = modifier,
-                onLinkActionCLicked = { UiAction.OnLinkClicked(inputData.id, textValue.text) },
+                onLinkActionCLicked = {
+                    onAction(
+                        UiAction.OnLinkClicked(
+                            inputData.id,
+                            textValue.text,
+                        ),
+                    )
+                },
             )
         }
 
diff --git a/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/ui/inputs/UiAction.kt b/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/ui/inputs/UiAction.kt
index 9957eafbd81..a120ed25297 100644
--- a/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/ui/inputs/UiAction.kt
+++ b/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/ui/inputs/UiAction.kt
@@ -61,4 +61,8 @@ sealed class UiAction(open val cellId: String) {
         override val cellId: String,
         val currentOrgUnitUid: String?,
     ) : UiAction(cellId)
+
+    data class OnFetchOptions(
+        override val cellId: String,
+    ) : UiAction(cellId)
 }
diff --git a/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/ui/provider/IdsProvider.kt b/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/ui/provider/IdsProvider.kt
new file mode 100644
index 00000000000..b3b2b89fbd6
--- /dev/null
+++ b/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/ui/provider/IdsProvider.kt
@@ -0,0 +1,39 @@
+package org.dhis2.mobile.aggregates.ui.provider
+
+import org.dhis2.mobile.aggregates.ui.inputs.TableId
+import org.dhis2.mobile.aggregates.ui.inputs.TableIdType
+
+internal object IdsProvider {
+    fun getDataElementUid(rowIds: List<TableId>, columnIds: List<TableId>): String {
+        val dataElementUids = rowIds.filter { it.type is TableIdType.DataElement }.map { it.id } +
+            columnIds.filter { it.type is TableIdType.DataElement }.map { it.id }
+
+        if (dataElementUids.size != 1) throw IllegalStateException("Only one data element can be provided")
+
+        return dataElementUids.first()
+    }
+
+    fun getCategoryOptionCombo(
+        rowIds: List<TableId>,
+        columnIds: List<TableId>,
+    ): Pair<String?, List<String>> {
+        val categoryOptions =
+            rowIds.filter { it.type is TableIdType.CategoryOption }.map { it.id } +
+                columnIds.filter { it.type is TableIdType.CategoryOption }.map { it.id }
+        val categoryOptionCombos =
+            rowIds.filter { it.type is TableIdType.CategoryOptionCombo }.map { it.id } +
+                columnIds.filter { it.type is TableIdType.CategoryOptionCombo }.map { it.id }
+
+        if (categoryOptions.isNotEmpty() && categoryOptionCombos.isNotEmpty()) {
+            throw IllegalStateException(
+                "Category options and category option combos cannot be provided at the same time",
+            )
+        }
+        if (categoryOptionCombos.size > 1) throw IllegalStateException("Only one category option combo can be provided")
+
+        return when {
+            categoryOptionCombos.isNotEmpty() -> Pair(categoryOptionCombos.first(), emptyList())
+            else -> Pair(null, categoryOptions)
+        }
+    }
+}
diff --git a/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/ui/provider/ResourceManager.kt b/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/ui/provider/ResourceManager.kt
index d5ef40ad8b2..9add3d8dc62 100644
--- a/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/ui/provider/ResourceManager.kt
+++ b/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/ui/provider/ResourceManager.kt
@@ -1,6 +1,7 @@
 package org.dhis2.mobile.aggregates.ui.provider
 
 import org.dhis2.mobile.aggregates.resources.Res
+import org.dhis2.mobile.aggregates.resources.action_can_not_be_performed
 import org.dhis2.mobile.aggregates.resources.dataset_saved_completed
 import org.dhis2.mobile.aggregates.resources.default_column_label
 import org.dhis2.mobile.aggregates.resources.error
@@ -45,4 +46,6 @@ internal class ResourceManager {
             getString(Res.string.errors)
         }
     }
+
+    suspend fun actionCantBePerformed(): String = getString(Res.string.action_can_not_be_performed)
 }
diff --git a/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/ui/states/InputData.kt b/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/ui/states/InputData.kt
index 5f952b188cb..6aca4775953 100644
--- a/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/ui/states/InputData.kt
+++ b/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/ui/states/InputData.kt
@@ -2,10 +2,12 @@ package org.dhis2.mobile.aggregates.ui.states
 
 import androidx.compose.runtime.Stable
 import org.dhis2.mobile.aggregates.model.InputType
+import org.hisp.dhis.mobile.ui.designsystem.component.CheckBoxData
 import org.hisp.dhis.mobile.ui.designsystem.component.Coordinates
 import org.hisp.dhis.mobile.ui.designsystem.component.InputShellState
 import org.hisp.dhis.mobile.ui.designsystem.component.InputStyle
 import org.hisp.dhis.mobile.ui.designsystem.component.LegendData
+import org.hisp.dhis.mobile.ui.designsystem.component.RadioButtonData
 import org.hisp.dhis.mobile.ui.designsystem.component.SelectableDates
 import org.hisp.dhis.mobile.ui.designsystem.component.SupportingTextData
 import org.hisp.dhis.mobile.ui.designsystem.component.model.DateTimeVisualTransformation
@@ -28,6 +30,8 @@ internal data class InputData(
     fun fileExtras() = inputExtra as InputExtra.File
     fun coordinateExtras() = inputExtra as InputExtra.Coordinate
     fun ageExtras() = inputExtra as InputExtra.Age
+    fun multiTextExtras() = inputExtra as InputExtra.MultiText
+    fun optionSetExtras() = inputExtra as InputExtra.OptionSet
 }
 
 internal sealed class InputExtra {
@@ -51,5 +55,17 @@ internal sealed class InputExtra {
         val selectableDates: SelectableDates,
     ) : InputExtra()
 
+    data class MultiText(
+        val numberOfOptions: Int,
+        val options: List<CheckBoxData>,
+        val optionsFetched: Boolean,
+    ) : InputExtra()
+
+    data class OptionSet(
+        val numberOfOptions: Int,
+        val options: List<RadioButtonData>,
+        val optionsFetched: Boolean,
+    ) : InputExtra()
+
     data object None : InputExtra()
 }
diff --git a/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/ui/viewModel/DataSetTableViewModel.kt b/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/ui/viewModel/DataSetTableViewModel.kt
index d35e7d6ce54..e9be7d091f7 100644
--- a/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/ui/viewModel/DataSetTableViewModel.kt
+++ b/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/ui/viewModel/DataSetTableViewModel.kt
@@ -29,6 +29,7 @@ import org.dhis2.mobile.aggregates.model.DataSetMandatoryFieldsStatus.ERROR
 import org.dhis2.mobile.aggregates.model.DataSetMandatoryFieldsStatus.MISSING_MANDATORY_FIELDS
 import org.dhis2.mobile.aggregates.model.DataSetMandatoryFieldsStatus.MISSING_MANDATORY_FIELDS_COMBINATION
 import org.dhis2.mobile.aggregates.model.DataSetMandatoryFieldsStatus.SUCCESS
+import org.dhis2.mobile.aggregates.model.InputType
 import org.dhis2.mobile.aggregates.model.ValidationResultStatus
 import org.dhis2.mobile.aggregates.model.ValidationRulesConfiguration.MANDATORY
 import org.dhis2.mobile.aggregates.model.ValidationRulesConfiguration.NONE
@@ -42,10 +43,10 @@ import org.dhis2.mobile.aggregates.ui.UIActionHandler
 import org.dhis2.mobile.aggregates.ui.constants.NO_SECTION_UID
 import org.dhis2.mobile.aggregates.ui.dispatcher.Dispatcher
 import org.dhis2.mobile.aggregates.ui.inputs.CellIdGenerator
-import org.dhis2.mobile.aggregates.ui.inputs.TableId
-import org.dhis2.mobile.aggregates.ui.inputs.TableIdType
 import org.dhis2.mobile.aggregates.ui.inputs.UiAction
 import org.dhis2.mobile.aggregates.ui.provider.DataSetModalDialogProvider
+import org.dhis2.mobile.aggregates.ui.provider.IdsProvider.getCategoryOptionCombo
+import org.dhis2.mobile.aggregates.ui.provider.IdsProvider.getDataElementUid
 import org.dhis2.mobile.aggregates.ui.provider.ResourceManager
 import org.dhis2.mobile.aggregates.ui.snackbar.SnackbarController
 import org.dhis2.mobile.aggregates.ui.snackbar.SnackbarEvent
@@ -147,11 +148,12 @@ internal class DataSetTableViewModel(
                 val sectionData = async { sectionData(sectionUid) }
                 _dataSetScreenState.update {
                     if (it is DataSetScreenState.Loaded) {
-                        val dataSetSectionTitle = if (it.dataSetDetails.customTitle.isConfiguredTitle) {
-                            it.dataSetDetails.customTitle.header
-                        } else {
-                            it.dataSetSections.firstOrNull { section -> section.uid == sectionUid }?.title
-                        }
+                        val dataSetSectionTitle =
+                            if (it.dataSetDetails.customTitle.isConfiguredTitle) {
+                                it.dataSetDetails.customTitle.header
+                            } else {
+                                it.dataSetSections.firstOrNull { section -> section.uid == sectionUid }?.title
+                            }
                         it.copy(
                             dataSetDetails = it.dataSetDetails.copy(
                                 customTitle = DataSetCustomTitle(
@@ -209,11 +211,21 @@ internal class DataSetTableViewModel(
         tables + indicators
     }
 
-    fun updateSelectedCell(cellId: String?) {
+    fun updateSelectedCell(cellId: String?, fetchOptions: Boolean = false) {
         viewModelScope.launch(dispatcher.io()) {
             val inputData = if (cellId != null) {
                 val (rowIds, columnIds) = CellIdGenerator.getIdInfo(cellId)
-                getDataValueInput(rowIds, columnIds).toInputData(cellId)
+                val dataElementUid = getDataElementUid(rowIds, columnIds)
+                val categoryOptionComboUidData = getCategoryOptionCombo(
+                    rowIds,
+                    columnIds,
+                )
+
+                getDataValueInput(
+                    dataElementUid,
+                    categoryOptionComboUidData,
+                    fetchOptions,
+                ).toInputData(cellId)
             } else {
                 null
             }
@@ -240,7 +252,6 @@ internal class DataSetTableViewModel(
         viewModelScope.launch(dispatcher.io()) {
             val (rowIds, columnIds) = CellIdGenerator.getIdInfo(uiAction.cellId)
             val dataElementUid = getDataElementUid(rowIds, columnIds)
-
             when (uiAction) {
                 is UiAction.OnFocusChanged -> {
                 }
@@ -256,16 +267,31 @@ internal class DataSetTableViewModel(
                         value = uiAction.newValue,
                     ).fold(
                         onSuccess = {
-                            updateSelectedCell(uiAction.cellId)
+                            val fetchOptions =
+                                if ((_dataSetScreenState.value as? DataSetScreenState.Loaded)
+                                        ?.selectedCellInfo?.inputType is InputType.MultiText
+                                ) {
+                                    (_dataSetScreenState.value as? DataSetScreenState.Loaded)
+                                        ?.selectedCellInfo?.multiTextExtras()?.optionsFetched != true
+                                } else {
+                                    false
+                                }
+
+                            updateSelectedCell(uiAction.cellId, fetchOptions)
                         },
                         onFailure = {
-                            TODO()
                         },
                     )
                 }
 
                 is UiAction.OnAddImage -> TODO()
-                is UiAction.OnCall -> TODO()
+                is UiAction.OnCall -> {
+                    val actionCanNotBePerformedMsg = resourceManager.actionCantBePerformed()
+                    uiActionHandler.onCall(uiAction.phoneNumber) {
+                        showSnackbar(actionCanNotBePerformedMsg)
+                    }
+                }
+
                 is UiAction.OnCaptureCoordinates -> {
                     uiActionHandler.onCaptureCoordinates(
                         fieldUid = dataElementUid,
@@ -276,8 +302,20 @@ internal class DataSetTableViewModel(
                     }
                 }
                 is UiAction.OnDownloadImage -> TODO()
-                is UiAction.OnEmailAction -> TODO()
-                is UiAction.OnLinkClicked -> TODO()
+                is UiAction.OnEmailAction -> {
+                    val actionCanNotBePerformedMsg = resourceManager.actionCantBePerformed()
+                    uiActionHandler.onSendEmail(uiAction.email) {
+                        showSnackbar(actionCanNotBePerformedMsg)
+                    }
+                }
+
+                is UiAction.OnLinkClicked -> {
+                    val actionCanNotBePerformedMsg = resourceManager.actionCantBePerformed()
+                    uiActionHandler.onOpenLink(uiAction.link) {
+                        showSnackbar(actionCanNotBePerformedMsg)
+                    }
+                }
+
                 is UiAction.OnOpenFile -> TODO()
                 is UiAction.OnSelectFile -> TODO()
                 is UiAction.OnShareImage -> TODO()
@@ -294,18 +332,13 @@ internal class DataSetTableViewModel(
                         )
                     }
                 }
+
+                is UiAction.OnFetchOptions ->
+                    updateSelectedCell(uiAction.cellId, true)
             }
         }
     }
 
-    private fun getDataElementUid(rowIds: List<TableId>, columnIds: List<TableId>): String {
-        val dataElementUids = rowIds.filter { it.type is TableIdType.DataElement }.map { it.id } +
-            columnIds.filter { it.type is TableIdType.DataElement }.map { it.id }
-
-        return dataElementUids.firstOrNull()
-            ?: throw IllegalArgumentException("Only one data element is allowed")
-    }
-
     fun onSaveClicked() {
         viewModelScope.launch {
             CoroutineTracker.increment()
diff --git a/aggregates/src/commonTest/kotlin/org/dhis2/mobile/aggregates/domain/GetDataValueInputTest.kt b/aggregates/src/commonTest/kotlin/org/dhis2/mobile/aggregates/domain/GetDataValueInputTest.kt
index dd84b002686..302c3f050a4 100644
--- a/aggregates/src/commonTest/kotlin/org/dhis2/mobile/aggregates/domain/GetDataValueInputTest.kt
+++ b/aggregates/src/commonTest/kotlin/org/dhis2/mobile/aggregates/domain/GetDataValueInputTest.kt
@@ -2,19 +2,16 @@ package org.dhis2.mobile.aggregates.domain
 
 import kotlinx.coroutines.test.runTest
 import org.dhis2.mobile.aggregates.data.DataSetInstanceRepository
+import org.dhis2.mobile.aggregates.data.OptionRepository
+import org.dhis2.mobile.aggregates.model.CellValueExtra
 import org.dhis2.mobile.aggregates.model.DataElementInfo
 import org.dhis2.mobile.aggregates.model.InputType
 import org.dhis2.mobile.aggregates.onRunBlocking
-import org.dhis2.mobile.aggregates.ui.inputs.TableId
-import org.dhis2.mobile.aggregates.ui.inputs.TableIdType
-import org.dhis2.mobile.aggregates.ui.states.InputExtra
 import org.junit.Test
-import org.junit.jupiter.api.assertThrows
 import org.mockito.kotlin.any
 import org.mockito.kotlin.doReturn
 import org.mockito.kotlin.mock
 import org.mockito.kotlin.whenever
-import kotlin.test.assertEquals
 import kotlin.test.assertTrue
 
 internal class GetDataValueInputTest {
@@ -25,7 +22,7 @@ internal class GetDataValueInputTest {
     }
     private val repository = mock<DataSetInstanceRepository> {
         onRunBlocking { dataElementInfo(any(), any(), any()) } doReturn mockedDataElementInfo
-        onRunBlocking { value(any(), any(), any(), any(), any()) } doReturn null
+        onRunBlocking { value(any(), any(), any(), any(), any()) } doReturn "Current value"
         onRunBlocking { conflicts(any(), any(), any(), any(), any(), any()) } doReturn Pair(
             emptyList(),
             emptyList(),
@@ -33,86 +30,38 @@ internal class GetDataValueInputTest {
         onRunBlocking { getCoordinatesFrom(any()) } doReturn Pair(0.0, 0.0)
     }
 
+    private val optionRepository: OptionRepository = mock()
+
     val getDataValueInputTest = GetDataValueInput(
         dataSetUid = "dataSetUid",
         periodId = "periodId",
         orgUnitUid = "orgUnitUid",
         attrOptionComboUid = "attrOptionComboUid",
         repository = repository,
+        optionRepository = optionRepository,
     )
 
     @Test
     fun `should return cell info`() = runTest {
         inputTypeValues.forEach { inputType ->
             whenever(mockedDataElementInfo.inputType) doReturn inputType
+            whenever(optionRepository.optionCount(any())) doReturn 1
+            whenever(optionRepository.options(any())) doReturn mock()
             val result = getDataValueInputTest(
-                rowIds = listOf(TableId("dataElementId", TableIdType.DataElement)),
-                columnIds = listOf(TableId("catOptionComboUid", TableIdType.CategoryOptionCombo)),
+                dataElementUid = "dataElementId",
+                categoryOptionComboUidData = Pair("catOptionComboUid", emptyList()),
             )
 
             assertTrue(
                 when (result.inputType) {
-                    InputType.Age -> result.inputExtra is InputExtra.Age
-                    InputType.Date, InputType.Time, InputType.DateTime -> result.inputExtra is InputExtra.Date
-                    InputType.Coordinates -> result.inputExtra is InputExtra.Coordinate
-                    InputType.FileResource -> result.inputExtra is InputExtra.File
-                    else -> result.inputExtra is InputExtra.None
+                    InputType.Coordinates -> result.inputExtra is CellValueExtra.Coordinates
+                    InputType.MultiText, InputType.OptionSet -> result.inputExtra is CellValueExtra.Options
+                    else -> result.inputExtra == null
                 },
             )
         }
     }
 
-    @Test
-    fun `should throw error if more than one data element is provided`() = runTest {
-        val exception = assertThrows<IllegalStateException> {
-            getDataValueInputTest(
-                rowIds = listOf(TableId("dataElementId", TableIdType.DataElement)),
-                columnIds = listOf(TableId("dataElementId", TableIdType.DataElement)),
-            )
-        }
-        assertEquals(
-            "Only one data element can be provided",
-            exception.message,
-        )
-    }
-
-    @Test
-    fun `should throw error if more than one category option combo is provided`() = runTest {
-        val exception = assertThrows<IllegalStateException> {
-            getDataValueInputTest(
-                rowIds = listOf(
-                    TableId("dataElementUid", TableIdType.DataElement),
-                    TableId("catOptionComboUid", TableIdType.CategoryOptionCombo),
-                ),
-                columnIds = listOf(TableId("catOptionComboUid", TableIdType.CategoryOptionCombo)),
-            )
-        }
-        assertEquals(
-            "Only one category option combo can be provided",
-            exception.message,
-        )
-    }
-
-    @Test
-    fun `should throw error if category options and category option combos are provided`() =
-        runTest {
-            val exception = assertThrows<IllegalStateException> {
-                getDataValueInputTest(
-                    rowIds = listOf(
-                        TableId("dataElementUid", TableIdType.DataElement),
-                        TableId("catOptionUid", TableIdType.CategoryOption),
-                    ),
-                    columnIds = listOf(
-                        TableId("catOptionComboUid", TableIdType.CategoryOptionCombo),
-                    ),
-                )
-            }
-            assertEquals(
-                "Category options and category option combos cannot be provided at the same time",
-                exception.message,
-            )
-        }
-
     private val inputTypeValues = listOf(
         InputType.Text,
         InputType.LongText,
diff --git a/aggregates/src/commonTest/kotlin/org/dhis2/mobile/aggregates/ui/viewModel/DataSetTableViewModelTest.kt b/aggregates/src/commonTest/kotlin/org/dhis2/mobile/aggregates/ui/viewModel/DataSetTableViewModelTest.kt
index 9acde264d1a..f9f818b4ff4 100644
--- a/aggregates/src/commonTest/kotlin/org/dhis2/mobile/aggregates/ui/viewModel/DataSetTableViewModelTest.kt
+++ b/aggregates/src/commonTest/kotlin/org/dhis2/mobile/aggregates/ui/viewModel/DataSetTableViewModelTest.kt
@@ -50,15 +50,17 @@ import org.dhis2.mobile.aggregates.ui.inputs.TableId
 import org.dhis2.mobile.aggregates.ui.inputs.TableIdType
 import org.dhis2.mobile.aggregates.ui.inputs.UiAction
 import org.dhis2.mobile.aggregates.ui.provider.DataSetModalDialogProvider
+import org.dhis2.mobile.aggregates.ui.provider.IdsProvider
 import org.dhis2.mobile.aggregates.ui.provider.ResourceManager
 import org.dhis2.mobile.aggregates.ui.states.DataSetModalDialogUIState
 import org.dhis2.mobile.aggregates.ui.states.DataSetScreenState
 import org.dhis2.mobile.aggregates.ui.states.DataSetSectionTable
-import org.dhis2.mobile.aggregates.ui.states.InputExtra
 import org.dhis2.mobile.commons.extensions.toColor
 import org.junit.After
 import org.junit.Assert.assertTrue
 import org.junit.Before
+import org.junit.Test
+import org.junit.jupiter.api.assertThrows
 import org.koin.core.component.get
 import org.koin.core.context.startKoin
 import org.koin.core.context.stopKoin
@@ -72,7 +74,6 @@ import org.mockito.kotlin.doReturn
 import org.mockito.kotlin.doReturnConsecutively
 import org.mockito.kotlin.verify
 import org.mockito.kotlin.whenever
-import kotlin.test.Test
 import kotlin.test.assertEquals
 
 @OptIn(ExperimentalCoroutinesApi::class)
@@ -118,7 +119,7 @@ internal class DataSetTableViewModelTest : KoinTest {
         getDataValueInput = declareMock<GetDataValueInput>()
         setDataValue = declareMock<SetDataValue>()
         getIndicators = declareMock<GetDataSetSectionIndicators>()
-        declareMock<ResourceManager>() {
+        declareMock<ResourceManager> {
             whenever(runBlocking { defaultHeaderLabel() }) doReturn "HeaderLabel"
             whenever(runBlocking { totalsHeader() }) doReturn "TotalsHeader"
             whenever(runBlocking { provideSaved() }) doReturn "saved"
@@ -285,8 +286,8 @@ internal class DataSetTableViewModelTest : KoinTest {
     @Test
     fun `should update selected cell`() = runTest {
         val testingId = CellIdGenerator.generateId(
-            rowIds = listOf(TableId("rowId", TableIdType.DataElement)),
-            columnIds = listOf(TableId("columnId", TableIdType.CategoryOptionCombo)),
+            rowIds = listOf(TableId("rowId123456", TableIdType.DataElement)),
+            columnIds = listOf(TableId("columnId123", TableIdType.CategoryOptionCombo)),
         )
         val cellInfoData = listOf(
             CellInfo(
@@ -294,7 +295,7 @@ internal class DataSetTableViewModelTest : KoinTest {
                 value = "This is it",
                 displayValue = "The display value",
                 inputType = InputType.Text,
-                inputExtra = InputExtra.None,
+                inputExtra = null,
                 supportingText = emptyList(),
                 errors = emptyList(),
                 warnings = emptyList(),
@@ -306,7 +307,7 @@ internal class DataSetTableViewModelTest : KoinTest {
                 value = "This is other",
                 displayValue = "The display value",
                 inputType = InputType.Text,
-                inputExtra = InputExtra.None,
+                inputExtra = null,
                 supportingText = emptyList(),
                 errors = emptyList(),
                 warnings = emptyList(),
@@ -317,7 +318,7 @@ internal class DataSetTableViewModelTest : KoinTest {
         )
         viewModel.dataSetScreenState.test {
             awaitInitialization()
-            whenever(getDataValueInput(any(), any())) doReturnConsecutively cellInfoData
+            whenever(getDataValueInput(any(), any(), any())) doReturnConsecutively cellInfoData
             viewModel.updateSelectedCell(testingId)
             with(awaitItem()) {
                 if (this is DataSetScreenState.Loaded) {
@@ -457,7 +458,7 @@ internal class DataSetTableViewModelTest : KoinTest {
             value = null,
             displayValue = null,
             inputType = InputType.Text,
-            inputExtra = InputExtra.None,
+            inputExtra = null,
             supportingText = emptyList(),
             errors = emptyList(),
             warnings = emptyList(),
@@ -465,7 +466,7 @@ internal class DataSetTableViewModelTest : KoinTest {
             legendLabel = null,
             legendColor = null,
         )
-        whenever(getDataValueInput(any(), any())) doReturn cellInfo
+        whenever(getDataValueInput(any(), any(), any())) doReturn cellInfo
 
         viewModel.dataSetScreenState.test {
             awaitInitialization()
@@ -477,6 +478,99 @@ internal class DataSetTableViewModelTest : KoinTest {
         }
     }
 
+    @Test
+    fun `should start call intent`() = runTest {
+        val testingId = CellIdGenerator.generateId(
+            rowIds = listOf(TableId("rowId123456", TableIdType.DataElement)),
+            columnIds = listOf(TableId("columnId123", TableIdType.CategoryOptionCombo)),
+        )
+        viewModel.dataSetScreenState.test {
+            awaitInitialization()
+            viewModel.onUiAction(UiAction.OnCall(testingId, "111111111"))
+            testDispatcher.scheduler.advanceUntilIdle()
+            verify(uiActionHandler).onCall(any(), any())
+        }
+    }
+
+    @Test
+    fun `should start send email intent`() = runTest {
+        val testingId = CellIdGenerator.generateId(
+            rowIds = listOf(TableId("rowId123456", TableIdType.DataElement)),
+            columnIds = listOf(TableId("columnId123", TableIdType.CategoryOptionCombo)),
+        )
+        viewModel.dataSetScreenState.test {
+            awaitInitialization()
+            viewModel.onUiAction(UiAction.OnEmailAction(testingId, "email@email.com"))
+            testDispatcher.scheduler.advanceUntilIdle()
+            verify(uiActionHandler).onSendEmail(any(), any())
+        }
+    }
+
+    @Test
+    fun `should start open url intent`() = runTest {
+        val testingId = CellIdGenerator.generateId(
+            rowIds = listOf(TableId("rowId123456", TableIdType.DataElement)),
+            columnIds = listOf(TableId("columnId123", TableIdType.CategoryOptionCombo)),
+        )
+        viewModel.dataSetScreenState.test {
+            awaitInitialization()
+            viewModel.onUiAction(UiAction.OnLinkClicked(testingId, "www.test.com"))
+            testDispatcher.scheduler.advanceUntilIdle()
+            verify(uiActionHandler).onOpenLink(any(), any())
+        }
+    }
+
+    @Test
+    fun `should throw error if more than one data element is provided`() {
+        val exception = assertThrows<IllegalStateException> {
+            IdsProvider.getDataElementUid(
+                rowIds = listOf(TableId("dataElementId", TableIdType.DataElement)),
+                columnIds = listOf(TableId("dataElementId", TableIdType.DataElement)),
+            )
+        }
+        assertEquals(
+            "Only one data element can be provided",
+            exception.message,
+        )
+    }
+
+    @Test
+    fun `should throw error if more than one category option combo is provided`() = runTest {
+        val exception = assertThrows<IllegalStateException> {
+            IdsProvider.getCategoryOptionCombo(
+                rowIds = listOf(
+                    TableId("dataElementUid", TableIdType.DataElement),
+                    TableId("catOptionComboUid", TableIdType.CategoryOptionCombo),
+                ),
+                columnIds = listOf(TableId("catOptionComboUid", TableIdType.CategoryOptionCombo)),
+            )
+        }
+        assertEquals(
+            "Only one category option combo can be provided",
+            exception.message,
+        )
+    }
+
+    @Test
+    fun `should throw error if category options and category option combos are provided`() =
+        runTest {
+            val exception = assertThrows<IllegalStateException> {
+                IdsProvider.getCategoryOptionCombo(
+                    rowIds = listOf(
+                        TableId("dataElementUid", TableIdType.DataElement),
+                        TableId("catOptionUid", TableIdType.CategoryOption),
+                    ),
+                    columnIds = listOf(
+                        TableId("catOptionComboUid", TableIdType.CategoryOptionCombo),
+                    ),
+                )
+            }
+            assertEquals(
+                "Category options and category option combos cannot be provided at the same time",
+                exception.message,
+            )
+        }
+
     private suspend fun ReceiveTurbine<DataSetScreenState>.awaitInitialization() = with(this) {
         awaitItem()
         awaitItem()
diff --git a/commonskmm/src/androidMain/kotlin/org/dhis2/mobile/commons/data/ValueParserImpl.kt b/commonskmm/src/androidMain/kotlin/org/dhis2/mobile/commons/data/ValueParserImpl.kt
index 87784ab6315..d96c021ccd3 100644
--- a/commonskmm/src/androidMain/kotlin/org/dhis2/mobile/commons/data/ValueParserImpl.kt
+++ b/commonskmm/src/androidMain/kotlin/org/dhis2/mobile/commons/data/ValueParserImpl.kt
@@ -27,6 +27,18 @@ internal class ValueParserImpl(private val d2: D2) : ValueParser {
             )
         }
 
+    override suspend fun valueFromMultiTextAsOptionNames(
+        optionSetUid: String,
+        value: String,
+    ): String {
+        val options = d2.optionModule().options()
+            .byOptionSetUid().eq(optionSetUid)
+            .blockingGet().associate { it.code() to it.displayName() }
+        return value.split(",").map { optionCode ->
+            options[optionCode]
+        }.joinToString(",")
+    }
+
     override suspend fun valueFromOptionSetAsOptionName(
         optionSetUid: String,
         value: String,
diff --git a/commonskmm/src/commonMain/kotlin/org/dhis2/mobile/commons/data/ValueParser.kt b/commonskmm/src/commonMain/kotlin/org/dhis2/mobile/commons/data/ValueParser.kt
index 4e1e4426312..0e8f20271f8 100644
--- a/commonskmm/src/commonMain/kotlin/org/dhis2/mobile/commons/data/ValueParser.kt
+++ b/commonskmm/src/commonMain/kotlin/org/dhis2/mobile/commons/data/ValueParser.kt
@@ -4,6 +4,7 @@ import org.dhis2.mobile.commons.model.internal.ValueInfo
 
 internal interface ValueParser {
     suspend fun getValueInfo(uid: String, value: String): ValueInfo
+    suspend fun valueFromMultiTextAsOptionNames(optionSetUid: String, value: String): String
     suspend fun valueFromOptionSetAsOptionName(optionSetUid: String, value: String): String
     suspend fun valueFromOrgUnitAsOrgUnitName(value: String): String
     suspend fun valueFromFileAsPath(value: String): String
diff --git a/commonskmm/src/commonMain/kotlin/org/dhis2/mobile/commons/extensions/StringExtensions.kt b/commonskmm/src/commonMain/kotlin/org/dhis2/mobile/commons/extensions/StringExtensions.kt
index 16bd3cd8ef8..12e0068058c 100644
--- a/commonskmm/src/commonMain/kotlin/org/dhis2/mobile/commons/extensions/StringExtensions.kt
+++ b/commonskmm/src/commonMain/kotlin/org/dhis2/mobile/commons/extensions/StringExtensions.kt
@@ -27,6 +27,8 @@ suspend fun String.userFriendlyValue(
         val valueInfo = valueParser.getValueInfo(uid, this)
 
         return when {
+            valueInfo.isMultiText ->
+                valueParser.valueFromMultiTextAsOptionNames(valueInfo.optionSetUid!!, this)
             valueInfo.parseToOptionName() ->
                 valueParser.valueFromOptionSetAsOptionName(valueInfo.optionSetUid!!, this)
 


Reviews:
user: login: xavimolloy
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-07T07:04:02Z
user: login: andresmr
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-07T07:45:28Z
user: login: andresmr
state: CHANGES_REQUESTED
author_association: COLLABORATOR
submitted_at: 2025-03-07T07:49:38Z
user: login: andresmr
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-10T06:29:35Z
user: login: andresmr
state: CHANGES_REQUESTED
author_association: COLLABORATOR
submitted_at: 2025-03-10T09:19:01Z
user: login: xavimolloy
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-03-11T09:38:42Z
user: login: andresmr
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-03-11T09:57:41Z
user: login: andresmr
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-03-12T10:32:28Z

Review comments:
diff_hunk: @@ -0,0 +1,49 @@
+package org.dhis2.mobile.aggregates.data
+
+import androidx.compose.runtime.Composable
+import androidx.paging.compose.collectAsLazyPagingItems
+import androidx.paging.map
+import kotlinx.coroutines.flow.map
+import org.dhis2.commons.bindings.dataElement
+import org.hisp.dhis.android.core.D2
+import org.hisp.dhis.mobile.ui.designsystem.component.CheckBoxData
+
+class OptionRepositoryImpl(private val d2: D2) : OptionRepository {
+
+    @Composable
path: aggregates/src/androidMain/kotlin/org/dhis2/mobile/aggregates/data/OptionRepositoryImpl.kt
user: login: xavimolloy
body: I am not sure. but is having a composable method in the repository breaking the clean architecture rules?
created_at: 2025-03-07T06:51:17Z
author_association: COLLABORATOR
diff_hunk: @@ -16,6 +17,7 @@ internal class GetDataValueInput(
     private val orgUnitUid: String,
     private val attrOptionComboUid: String,
     private val repository: DataSetInstanceRepository,
+    private val optionRepository: OptionRepository,
 ) : ValueValidator(repository) {
     suspend operator fun invoke(
         rowIds: List<TableId>,
path: aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/domain/GetDataValueInput.kt
user: login: xavimolloy
body: similar thing here, TableId is in the ui package, maybe it belongs in the model package?
created_at: 2025-03-07T06:56:17Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,49 @@
+package org.dhis2.mobile.aggregates.data
+
+import androidx.compose.runtime.Composable
+import androidx.paging.compose.collectAsLazyPagingItems
+import androidx.paging.map
+import kotlinx.coroutines.flow.map
+import org.dhis2.commons.bindings.dataElement
+import org.hisp.dhis.android.core.D2
+import org.hisp.dhis.mobile.ui.designsystem.component.CheckBoxData
+
+class OptionRepositoryImpl(private val d2: D2) : OptionRepository {
+
+    @Composable
path: aggregates/src/androidMain/kotlin/org/dhis2/mobile/aggregates/data/OptionRepositoryImpl.kt
user: login: andresmr
body: agree, maybe information can be stored in a domain model and the mapping if from the ViewModel to a UiState and then we can create the composable in the UI 
created_at: 2025-03-07T07:45:28Z
author_association: COLLABORATOR
diff_hunk: @@ -28,4 +32,41 @@ internal class UIActionHandlerImpl(private val activity: ComponentActivity) : UI
         callback: (result: String?) -> Unit,
     ) {
     }
+
+    override fun onCall(phoneNumber: String) {
+        val phoneCallIntent = Intent(Intent.ACTION_DIAL).apply {
+            data = Uri.parse("tel:$phoneNumber")
+        }
+        launchIntentChooser(phoneCallIntent)
+    }
+
+    override fun onSendEmail(email: String) {
+        val phoneCallIntent = Intent(Intent.ACTION_SENDTO).apply {
+            data = Uri.parse("mailto:$email")
+        }
+        launchIntentChooser(phoneCallIntent)
+    }
+
+    override fun onOpenLink(url: String) {
+        val phoneCallIntent = Intent(Intent.ACTION_DIAL).apply {
+            data =
+                if (!url.startsWith("http://") && !url.startsWith("https://")) {
+                    Uri.parse("http://$url")
+                } else {
+                    Uri.parse(url)
+                }
+        }
+        launchIntentChooser(phoneCallIntent)
+    }
+
+    private fun launchIntentChooser(intent: Intent) {
+        val title = activity.getString(R.string.open_with)
+        val chooser = Intent.createChooser(intent, title)
+
+        try {
+            activity.startActivity(chooser)
+        } catch (e: ActivityNotFoundException) {
+            /*do nothing*/
path: aggregates/src/androidMain/kotlin/org/dhis2/mobile/aggregates/ui/UIActionHandlerImpl.kt
user: login: andresmr
body: should we send a message (maybe a snackbar) informing that there has been an error?
created_at: 2025-03-07T07:48:01Z
author_association: COLLABORATOR
diff_hunk: @@ -463,7 +470,11 @@ internal class DataSetInstanceRepositoryImpl(
                     it.categoryOptionCombo()?.uid() == categoryOptionComboUid
             } != null
 
-        val inputType = requireNotNull(dataElement?.valueType()?.toInputType())
+        val inputType = if (dataElement?.displayFormName() == "Large Multiselect option test") {
path: aggregates/src/androidMain/kotlin/org/dhis2/mobile/aggregates/data/DataSetInstanceRepositoryImpl.kt
user: login: andresmr
body: is this final code?
created_at: 2025-03-10T06:28:58Z
author_association: COLLABORATOR
diff_hunk: @@ -16,6 +17,7 @@ internal class GetDataValueInput(
     private val orgUnitUid: String,
     private val attrOptionComboUid: String,
     private val repository: DataSetInstanceRepository,
+    private val optionRepository: OptionRepository,
 ) : ValueValidator(repository) {
     suspend operator fun invoke(
         rowIds: List<TableId>,
path: aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/domain/GetDataValueInput.kt
user: login: andresmr
body: yes, it seems you are accessing to ui from domain
created_at: 2025-03-10T09:17:51Z
author_association: COLLABORATOR

PR comments:
user: login: sonarqubecloud[bot]
created_at: 2025-03-12T12:01:09Z
body: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=dhis2_dhis2-android-capture-app&pullRequest=4020) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 New issues](https://sonarcloud.io/project/issues?id=dhis2_dhis2-android-capture-app&pullRequest=4020&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=dhis2_dhis2-android-capture-app&pullRequest=4020&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=dhis2_dhis2-android-capture-app&pullRequest=4020&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Coverage on New Code](https://sonarcloud.io/component_measures?id=dhis2_dhis2-android-capture-app&pullRequest=4020&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=dhis2_dhis2-android-capture-app&pullRequest=4020&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=dhis2_dhis2-android-capture-app&pullRequest=4020)


author_association: NONE