diff --git a/.tx/config b/.tx/config index 0fa80f633d..d8ae3194cb 100644 --- a/.tx/config +++ b/.tx/config @@ -64,3 +64,10 @@ source_file = tracker/src/main/res/values/strings.xml source_lang = en type = ANDROID minimum_perc = 0 + +[o:hisp-uio:p:dhis2-android-capture-app:r:aggregates-strings-xml] +file_filter = aggregates/src/commonMain/composeResources/values-/strings.xml +source_file = aggregates/src/commonMain/composeResources/values/strings.xml +source_lang = en +type = ANDROID +minimum_perc = 0 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 8ce275528d..43b5f8ec51 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 @@ -9,17 +9,25 @@ import org.dhis2.mobile.aggregates.model.DataSetDetails 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.MandatoryCellElements import org.dhis2.mobile.aggregates.model.TableGroup +import org.dhis2.mobile.aggregates.model.ValidationResultStatus +import org.dhis2.mobile.aggregates.model.ValidationRulesResult +import org.dhis2.mobile.aggregates.model.Violation import org.dhis2.mobile.aggregates.ui.constants.NO_SECTION_UID import org.hisp.dhis.android.core.D2 import org.hisp.dhis.android.core.arch.helpers.GeometryHelper +import org.hisp.dhis.android.core.arch.helpers.UidsHelper import org.hisp.dhis.android.core.arch.repositories.scope.RepositoryScope import org.hisp.dhis.android.core.common.Geometry import org.hisp.dhis.android.core.common.State import org.hisp.dhis.android.core.common.ValueType +import org.hisp.dhis.android.core.dataelement.DataElementOperand import org.hisp.dhis.android.core.dataset.DataSetEditableStatus import org.hisp.dhis.android.core.dataset.Section +import org.hisp.dhis.android.core.maintenance.D2Error +import org.hisp.dhis.android.core.validation.engine.ValidationResultViolation internal class DataSetInstanceRepositoryImpl( private val d2: D2, @@ -70,6 +78,35 @@ internal class DataSetInstanceRepositoryImpl( .byDataSetUid().eq(dataSetUid) .blockingGet().map(Section::toDataSetSection) + override suspend fun isComplete( + dataSetUid: String, + periodId: String, + orgUnitUid: String, + attrOptionComboUid: String, + ): Boolean { + return d2.dataSetModule().dataSetCompleteRegistrations() + .byDataSetUid().eq(dataSetUid) + .byPeriod().eq(periodId) + .byOrganisationUnitUid().eq(orgUnitUid) + .byAttributeOptionComboUid().eq(attrOptionComboUid) + .byDeleted().isFalse + .isEmpty() + .map { isEmpty -> !isEmpty }.blockingGet() + } + + override suspend fun areValidationRulesMandatory(dataSetUid: String): Boolean { + return d2.dataSetModule() + .dataSets().uid(dataSetUid) + .blockingGet()?.validCompleteOnly() ?: false + } + + override suspend fun checkIfHasValidationRules(dataSetUid: String): Boolean { + return !d2.validationModule().validationRules() + .byDataSetUids(listOf(dataSetUid)) + .bySkipFormValidation().isFalse + .blockingIsEmpty() + } + override suspend fun getRenderingConfig( dataSetUid: String, ) = d2.dataSetModule().dataSets() @@ -423,4 +460,202 @@ internal class DataSetInstanceRepositoryImpl( ).toString() }.toSortedMap(compareBy { it }) .takeIf { it.isNotEmpty() } + + override suspend fun checkIfHasMissingMandatoryFields( + dataSetUid: String, + periodId: String, + orgUnitUid: String, + attributeOptionComboUid: String, + ): Boolean { + return !d2.dataSetModule().dataSets().withCompulsoryDataElementOperands().uid(dataSetUid) + .get() + .map { + it.compulsoryDataElementOperands()?.filter { dataElementOperand -> + dataElementOperand.dataElement()?.let { dataElement -> + dataElementOperand.categoryOptionCombo()?.let { categoryOptionCombo -> + !d2.dataValueModule().dataValues() + .value( + periodId, + orgUnitUid, + dataElement.uid(), + categoryOptionCombo.uid(), + attributeOptionComboUid, + ).blockingExists() + } + } ?: false + } + }.blockingGet().isNullOrEmpty() + } + + override suspend fun checkIfHasMissingMandatoryFieldsCombination( + dataSetUid: String, + periodId: String, + orgUnitUid: String, + attributeOptionComboUid: String, + ): Boolean { + return d2.dataSetModule().dataSets().withDataSetElements().uid(dataSetUid).blockingGet() + ?.let { dataSet -> + if (dataSet.fieldCombinationRequired() == true) { + dataSet.dataSetElements() + ?.filter { dataSetElement -> + val catComboUid = dataSetElement.categoryCombo()?.uid() + ?: d2.dataElementModule().dataElements() + .uid(dataSetElement.dataElement().uid()) + .blockingGet()?.categoryComboUid() + val categoryOptionCombos = + d2.categoryModule().categoryOptionCombos().byCategoryComboUid() + .eq(catComboUid).blockingGet() + val dataValueRepository = d2.dataValueModule().dataValues() + .byPeriod().eq(periodId) + .byOrganisationUnitUid().eq(orgUnitUid) + .byAttributeOptionComboUid().eq(attributeOptionComboUid) + .byDeleted().isFalse + .byDataElementUid().eq(dataSetElement.dataElement().uid()) + .byCategoryOptionComboUid() + .`in`(UidsHelper.getUidsList(categoryOptionCombos)) + dataValueRepository.blockingGet().isNotEmpty() && + dataValueRepository + .blockingCount() != categoryOptionCombos.size + }?.map { dataSetElement -> + dataSetElement.dataElement().uid() + }.isNullOrEmpty().not() + } else { + false + } + } ?: false + } + + override suspend fun completeDataset( + dataSetUid: String, + periodId: String, + orgUnitUid: String, + attributeOptionComboUid: String, + ): Result { + return try { + d2.dataSetModule().dataSetCompleteRegistrations() + .value(periodId, orgUnitUid, dataSetUid, attributeOptionComboUid) + .blockingSet() + Result.success(Unit) + } catch (error: D2Error) { + Result.failure(error) + } + } + + override suspend fun runValidationRules( + dataSetUid: String, + periodId: String, + orgUnitUid: String, + attrOptionComboUid: String, + ): ValidationRulesResult { + val result = d2.validationModule() + .validationEngine().validate( + dataSetUid, + periodId, + orgUnitUid, + attrOptionComboUid, + ).blockingGet() + + return ValidationRulesResult( + ValidationResultStatus.valueOf(result.status().name), + mapViolations( + violations = result.violations(), + periodId = periodId, + orgUnitUid = orgUnitUid, + attrOptionComboUid = attrOptionComboUid, + ), + ) + } + + private fun mapViolations( + violations: List, + periodId: String, + orgUnitUid: String, + attrOptionComboUid: String, + ): List { + return violations.map { + Violation( + it.validationRule().description(), + it.validationRule().instruction(), + mapDataElements( + dataElementUids = it.dataElementUids(), + periodId = periodId, + orgUnitUid = orgUnitUid, + attrOptionComboUid = attrOptionComboUid, + ), + ) + } + } + + private fun mapDataElements( + dataElementUids: MutableSet, + periodId: String, + orgUnitUid: String, + attrOptionComboUid: String, + ): List { + val dataToReview = arrayListOf() + dataElementUids.mapNotNull { deOperand -> + d2.dataElementModule().dataElements() + .uid(deOperand.dataElement()?.uid()) + .blockingGet()?.let { + Pair(deOperand, it) + } + }.forEach { (deOperand, de) -> + val catOptCombos = + if (deOperand.categoryOptionCombo() != null) { + d2.categoryModule().categoryOptionCombos() + .byUid().like(deOperand.categoryOptionCombo()?.uid()) + .blockingGet() + } else { + d2.categoryModule().categoryOptionCombos() + .byCategoryComboUid().like(de.categoryComboUid()) + .blockingGet() + } + catOptCombos.forEach { catOptCombo -> + val value = if (d2.dataValueModule().dataValues() + .value( + periodId, + orgUnitUid, + de.uid(), + catOptCombo.uid(), + attrOptionComboUid, + ) + .blockingExists() && + d2.dataValueModule().dataValues() + .value( + periodId, + orgUnitUid, + de.uid(), + catOptCombo.uid(), + attrOptionComboUid, + ) + .blockingGet()?.deleted() != true + ) { + d2.dataValueModule().dataValues() + .value( + periodId, + orgUnitUid, + de.uid(), + catOptCombo.uid(), + attrOptionComboUid, + ) + .blockingGet()?.value() ?: "-" + } else { + "-" + } + val isFromDefaultCatCombo = d2.categoryModule().categoryCombos() + .uid(catOptCombo.categoryCombo()?.uid()).blockingGet()?.isDefault == true + dataToReview.add( + DataToReview( + de.uid(), + de.displayFormName(), + catOptCombo.uid(), + catOptCombo.displayName(), + value, + isFromDefaultCatCombo, + ), + ) + } + } + return dataToReview + } } diff --git a/aggregates/src/commonMain/composeResources/values-ar/strings.xml b/aggregates/src/commonMain/composeResources/values-ar/strings.xml new file mode 100644 index 0000000000..24684252de --- /dev/null +++ b/aggregates/src/commonMain/composeResources/values-ar/strings.xml @@ -0,0 +1,16 @@ + + + كل شيء يبدو جيدا! + هل تريد أيضاً تعيين حزمة البيانات كمكتملة؟ + مكتمل + تم الحفظ! + هناك حقول إلزامية مفقودة. من فضلك قم بملؤها لإكمال حزمة البيانات. + إذا قمت بتعيين قيمة ، فإن الصف بأكمله يحتاج إلى كل القيم لإكمال مجموعة البيانات. + موافق + محفوظ ومكتمل + هل تريد التحقق من جودة البيانات؟ + خطأ + إكمال على أية حال + بيانات للمراجعة + + \ No newline at end of file diff --git a/aggregates/src/commonMain/composeResources/values-ckb/strings.xml b/aggregates/src/commonMain/composeResources/values-ckb/strings.xml new file mode 100644 index 0000000000..65ee42d4a3 --- /dev/null +++ b/aggregates/src/commonMain/composeResources/values-ckb/strings.xml @@ -0,0 +1,6 @@ + + + تةواو + + + \ No newline at end of file diff --git a/aggregates/src/commonMain/composeResources/values-cs/strings.xml b/aggregates/src/commonMain/composeResources/values-cs/strings.xml new file mode 100644 index 0000000000..e6bce2238c --- /dev/null +++ b/aggregates/src/commonMain/composeResources/values-cs/strings.xml @@ -0,0 +1,17 @@ + + + Všechno vypadá dobře! + Chcete také vyplnit soubor dat? + Dokončit + Uloženo! + Chybí povinná pole. Chcete-li vyplnit soubor dat, vyplňte je prosím. + Pokud nastavíte hodnotu, celý řádek potřebuje k dokončení datové sady všechny hodnoty. + OK + Uloženo a dokončeno! + Chcete zkontrolovat kvalitu dat? + Chyba + Přesto dokončit + Posouzení + Data ke kontrole + + \ No newline at end of file diff --git a/aggregates/src/commonMain/composeResources/values-es/strings.xml b/aggregates/src/commonMain/composeResources/values-es/strings.xml new file mode 100644 index 0000000000..4fce818805 --- /dev/null +++ b/aggregates/src/commonMain/composeResources/values-es/strings.xml @@ -0,0 +1,18 @@ + + + ¡Parece que todo está bien! + ¿Quiere completar también el set de datos? + Ahora no + Completar + ¡Guardado! + Hay campos obligatorios vacíos. Por favor, rellénelos para poder completar el set de datos. + Si introduce un valor, tendrá que completar toda la fila para poder marcar como Completado el Data Set. + Correcto + ¡Guardado y completado! + ¿Quiere validar la calidad de los datos? + Error + Completar de todas formas + Revisar + Datos a revisar + + diff --git a/aggregates/src/commonMain/composeResources/values-fr/strings.xml b/aggregates/src/commonMain/composeResources/values-fr/strings.xml new file mode 100644 index 0000000000..46b907d7f8 --- /dev/null +++ b/aggregates/src/commonMain/composeResources/values-fr/strings.xml @@ -0,0 +1,17 @@ + + + Tout a l\'air bien! + Voulez-vous également compléter l\'ensemble de données? + Terminer + Enregistré! + Il manque des champs obligatoires. Veuillez les remplir pour compléter l\'ensemble de données. + Si vous saisissez une valeur, la ligne entière doit être remplie pour compléter l\'ensemble de données. + Ok + Enregistré et terminé! + Voulez-vous vérifier la qualité des données? + Erreur + Terminer quand même + Vérifier + Données à examiner + + \ No newline at end of file diff --git a/aggregates/src/commonMain/composeResources/values-id/strings.xml b/aggregates/src/commonMain/composeResources/values-id/strings.xml new file mode 100644 index 0000000000..18ec631f52 --- /dev/null +++ b/aggregates/src/commonMain/composeResources/values-id/strings.xml @@ -0,0 +1,16 @@ + + + Semuanya terlihat bagus! + Apakah Anda juga ingin melengkapi data set? + Lengkap + Tersimpan! + Terdapat hal penting yang hilang. Harap mengisinya untuk melengkapi data.set + Jika Anda mengisi, semua baris harus terisi untuk menyelesaikan data set + Ok + Tersimpan dan selesai! + Apakah Anda ingin memeriksa kualitas data? + Kesalahan + Lengkap + Data untuk ditinjau + + \ No newline at end of file diff --git a/aggregates/src/commonMain/composeResources/values-lo/strings.xml b/aggregates/src/commonMain/composeResources/values-lo/strings.xml new file mode 100644 index 0000000000..d0e3b6cf29 --- /dev/null +++ b/aggregates/src/commonMain/composeResources/values-lo/strings.xml @@ -0,0 +1,16 @@ + + + ທຸກຢ່າງຮຽບຮ້ອຍດີ + ທ່ານຕ້ອງການເຮັດຊຸດຂໍ້ມູນໃຫ້ຄົບຖ້ວນບໍ? + ບໍ່​ແມ່ນ​ຕອນ​ນີ້ + ສຳເລັດແລ້ວ + ບັນທຶກແລ້ວ! + ຖ້າທ່ານຕັ້ງຄ່າ, ແຖວທັ້ງໝົດຕ້ອງໃຊ້ຄ່າທັ້ງໝົດເພື່ອເຮັດໃຫ້ຊຸດຂໍ້ມູນສົມບູນ + ​ຕົກ​ລົງ + ບັນ​ທຶກ​ແລະ​ສໍາ​ເລັດ​! + ທ່ານຕ້ອງການກວດສອບຄຸນນະພາບຂໍ້ມູນນີ້ບໍ? + ເກີດການຜິດພາດ + ທົບທວນຄືນ + ຂໍ້ມູນທີ່ຕ້ອງທົບທວນ + + \ No newline at end of file diff --git a/aggregates/src/commonMain/composeResources/values-nb/strings.xml b/aggregates/src/commonMain/composeResources/values-nb/strings.xml new file mode 100644 index 0000000000..4af3fd1ce9 --- /dev/null +++ b/aggregates/src/commonMain/composeResources/values-nb/strings.xml @@ -0,0 +1,16 @@ + + + Alt ser bra ut + Vil du også fullføre datasettet? + Fullfør + Lagret! + Det mangler obligatoriske felt. Vennligst fyll dem for å fullføre datasettet. + Hvis du setter en verdi, så trenger hele raden alle verdier for å fullføre datasettet. + OK + Lagret og fullført! + Vil du sjekke datakvaliteten? + Feil + Fullfør likevel + Data til gjennomgang + + \ No newline at end of file diff --git a/aggregates/src/commonMain/composeResources/values-nl/strings.xml b/aggregates/src/commonMain/composeResources/values-nl/strings.xml new file mode 100644 index 0000000000..9397ecf536 --- /dev/null +++ b/aggregates/src/commonMain/composeResources/values-nl/strings.xml @@ -0,0 +1,18 @@ + + + Alles ziet er goed uit! + Wil je ook de dataset compleet maken? + Niet nu + Compleet + Opgeslagen! + Er ontbreken verplichte velden. Gelieve deze in te vullen om de dataset compleet te maken. + Als u een waarde instelt, heeft de hele rij alle waarden nodig om de gegevensset te voltooien. + OK + Opgeslagen en voltooid! + Wilt u de datakwaliteit controleren? + Fout + In ieder geval compleet + Beoordeling + Gegevens om te bekijken + + \ No newline at end of file diff --git a/aggregates/src/commonMain/composeResources/values-prs/strings.xml b/aggregates/src/commonMain/composeResources/values-prs/strings.xml new file mode 100644 index 0000000000..e95f4903ef --- /dev/null +++ b/aggregates/src/commonMain/composeResources/values-prs/strings.xml @@ -0,0 +1,6 @@ + + + تأیید + + + \ No newline at end of file diff --git a/aggregates/src/commonMain/composeResources/values-ps/strings.xml b/aggregates/src/commonMain/composeResources/values-ps/strings.xml new file mode 100644 index 0000000000..91b01cc2e3 --- /dev/null +++ b/aggregates/src/commonMain/composeResources/values-ps/strings.xml @@ -0,0 +1,6 @@ + + + سم دی + + + \ No newline at end of file diff --git a/aggregates/src/commonMain/composeResources/values-pt/strings.xml b/aggregates/src/commonMain/composeResources/values-pt/strings.xml new file mode 100644 index 0000000000..d54c3dda2a --- /dev/null +++ b/aggregates/src/commonMain/composeResources/values-pt/strings.xml @@ -0,0 +1,18 @@ + + + Tudo parece bem! + Você também deseja completar o conjunto de dados? + Não agora + Completo + Gravado! + Estão faltando campos obrigatórios. Por favor, preencha-os para completar o conjunto de dados. + Se você definir um valor, a linha inteira precisa de todos os valores para concluir o conjunto de dados. + Aprovado + Salvo e concluído! + Você quer verificar a qualidade dos dados? + Erro + Completar mesmo assim + Revisão + Dados para revisar + + diff --git a/aggregates/src/commonMain/composeResources/values-ru/strings.xml b/aggregates/src/commonMain/composeResources/values-ru/strings.xml new file mode 100644 index 0000000000..fb341e1e13 --- /dev/null +++ b/aggregates/src/commonMain/composeResources/values-ru/strings.xml @@ -0,0 +1,7 @@ + + + Завершенный + ОК + Ошибка + + diff --git a/aggregates/src/commonMain/composeResources/values-sv/strings.xml b/aggregates/src/commonMain/composeResources/values-sv/strings.xml new file mode 100644 index 0000000000..070b741719 --- /dev/null +++ b/aggregates/src/commonMain/composeResources/values-sv/strings.xml @@ -0,0 +1,6 @@ + + + ok + + + \ No newline at end of file diff --git a/aggregates/src/commonMain/composeResources/values-uk/strings.xml b/aggregates/src/commonMain/composeResources/values-uk/strings.xml new file mode 100644 index 0000000000..6ed6602d92 --- /dev/null +++ b/aggregates/src/commonMain/composeResources/values-uk/strings.xml @@ -0,0 +1,18 @@ + + + Все виглядає добре! + Ви бажаєте також завершити набір даних? + Не зараз + Завершити + Збережено! + Обов\'язкові поля не заповнені. Будь ласка, заповніть їх, щоб завершити набір даних. + Якщо Ви встановлюєте значення, то щодо усього рядка потрібно вказати всі значення, щоб завершити набір даних. + ОК + Збережено та завершено! + Хочете перевірити якість даних? + Помилка + Все одно завершити + Перегляд + Дані для перегляду + + \ No newline at end of file diff --git a/aggregates/src/commonMain/composeResources/values-ur/strings.xml b/aggregates/src/commonMain/composeResources/values-ur/strings.xml new file mode 100644 index 0000000000..039ab35bb2 --- /dev/null +++ b/aggregates/src/commonMain/composeResources/values-ur/strings.xml @@ -0,0 +1,6 @@ + + + ٹھیک + + + \ No newline at end of file diff --git a/aggregates/src/commonMain/composeResources/values-uz/strings.xml b/aggregates/src/commonMain/composeResources/values-uz/strings.xml new file mode 100644 index 0000000000..5b146d2da3 --- /dev/null +++ b/aggregates/src/commonMain/composeResources/values-uz/strings.xml @@ -0,0 +1,16 @@ + + + Кўринишидан ҳаммаси яхши! + Маълумотлар тўпламини тўлдиришни хохлайсизми? + Бажарилди + Сақланди! + Шарт бўлган маълумотлар етишмаяпти. Маълумотлар тўпламини шакллантириш учун уларни тўлдиринг. + Агар сиз қийматни ўрнатган бўлсангиз, маълумотлар тўпламини тўлдириш учун қатордаги барча қийматлар бўлиши лозим. + ОК + Якунланди ва сақланди! + Маълумотлар сифатини текширишни хохлайсизми? + Хатолик + Барибир тўлдиринг + Кўриб чиқиладиган маълумотлар + + \ No newline at end of file diff --git a/aggregates/src/commonMain/composeResources/values-vi/strings.xml b/aggregates/src/commonMain/composeResources/values-vi/strings.xml new file mode 100644 index 0000000000..59af1d228f --- /dev/null +++ b/aggregates/src/commonMain/composeResources/values-vi/strings.xml @@ -0,0 +1,18 @@ + + + Mọi thứ đều ổn! + Bạn cũng muốn hoàn tất biểu nhập phải không? + Không phải bây giờ + Hoàn tất + Đã lưu! + Các trường nhập bắt buộc đang bị thiếu. Xin vui lòng điền giá trị bắt buộc để hoàn thành biểu nhập. + Nếu bạn nhập một giá trị, cần nhập tất cả giá trị để hoàn tất Biểu Nhập + OK + Đã lưu và Hoàn tất! + Ban có muốn kiểm tra chất lượng dữ liệu không? + Lỗi + Hoàn tất luôn + Xem xét + Dữ liệu để xem xét + + \ No newline at end of file diff --git a/aggregates/src/commonMain/composeResources/values-zh-rCN/strings.xml b/aggregates/src/commonMain/composeResources/values-zh-rCN/strings.xml new file mode 100644 index 0000000000..623f563ff2 --- /dev/null +++ b/aggregates/src/commonMain/composeResources/values-zh-rCN/strings.xml @@ -0,0 +1,15 @@ + + + 一切都很好 + 你要完成数据集? + 完成 + 已经保存! + 有强制字段请填充后完成数据集 + 如果你设置值,整行的值需要设置才能完成数据集。 + + 保存并完成! + 你要检查数据质量? + 任何都要完成 + 复查的数据 + + \ No newline at end of file diff --git a/aggregates/src/commonMain/composeResources/values-zh/strings.xml b/aggregates/src/commonMain/composeResources/values-zh/strings.xml new file mode 100644 index 0000000000..e8103bcd6e --- /dev/null +++ b/aggregates/src/commonMain/composeResources/values-zh/strings.xml @@ -0,0 +1,18 @@ + + + 一切看起来都不错! + 你要完成数据集? + 现在不要 + 完成 + 已经保存! + 有强制字段请填充后完成数据集 + 如果你设置值,整行的值需要设置才能完成数据集。 + + 保存并完成! + 您要检查数据质量吗? + 错误 + 任何都要完成 + 审查 + 复查的数据 + + diff --git a/aggregates/src/commonMain/composeResources/values/strings.xml b/aggregates/src/commonMain/composeResources/values/strings.xml index 42887b8a3f..d43125fed0 100644 --- a/aggregates/src/commonMain/composeResources/values/strings.xml +++ b/aggregates/src/commonMain/composeResources/values/strings.xml @@ -16,4 +16,25 @@ Out of range Incorrect format Not supported + Everything looks good + Do you also want to complete the dataset? + Everything looks good + "Do you also want to complete the data set?" + Not now + Complete + Saved! + There are missing mandatory fields. Please, fill them to complete the data set. + If you set a value, the whole row needs all of values to complete the Data Set. + OK + Saved and completed! + There has been an error completing the dataset + Do you want to check data quality? + No + Yes + Error + Errors + Complete anyway + Review + Data to review + \ No newline at end of file diff --git a/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/data/DataSetInstanceRepository.kt b/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/data/DataSetInstanceRepository.kt index 3ab28e9fbf..f8f21a11ae 100644 --- a/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/data/DataSetInstanceRepository.kt +++ b/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/data/DataSetInstanceRepository.kt @@ -8,6 +8,7 @@ import org.dhis2.mobile.aggregates.model.DataSetInstanceSectionConfiguration import org.dhis2.mobile.aggregates.model.DataSetRenderingConfig import org.dhis2.mobile.aggregates.model.DataSetSection import org.dhis2.mobile.aggregates.model.TableGroup +import org.dhis2.mobile.aggregates.model.ValidationRulesResult import java.util.SortedMap internal interface DataSetInstanceRepository { @@ -93,4 +94,43 @@ internal interface DataSetInstanceRepository { suspend fun categoryOptionComboFromCategoryOptions(categoryOptions: List): String suspend fun getCoordinatesFrom(value: String): Pair + + suspend fun checkIfHasValidationRules(dataSetUid: String): Boolean + + suspend fun areValidationRulesMandatory(dataSetUid: String): Boolean + + suspend fun isComplete( + dataSetUid: String, + periodId: String, + orgUnitUid: String, + attrOptionComboUid: String, + ): Boolean + + suspend fun checkIfHasMissingMandatoryFields( + dataSetUid: String, + periodId: String, + orgUnitUid: String, + attributeOptionComboUid: String, + ): Boolean + + suspend fun checkIfHasMissingMandatoryFieldsCombination( + dataSetUid: String, + periodId: String, + orgUnitUid: String, + attributeOptionComboUid: String, + ): Boolean + + suspend fun completeDataset( + dataSetUid: String, + periodId: String, + orgUnitUid: String, + attributeOptionComboUid: String, + ): Result + + suspend fun runValidationRules( + dataSetUid: String, + periodId: String, + orgUnitUid: String, + attrOptionComboUid: String, + ): ValidationRulesResult } 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 b85d2a4c16..2187ba6ccc 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 @@ -1,14 +1,19 @@ package org.dhis2.mobile.aggregates.di import kotlinx.coroutines.Dispatchers +import org.dhis2.mobile.aggregates.domain.CheckCompletionStatus +import org.dhis2.mobile.aggregates.domain.CheckValidationRulesConfiguration +import org.dhis2.mobile.aggregates.domain.CompleteDataSet import org.dhis2.mobile.aggregates.domain.GetDataSetInstanceData import org.dhis2.mobile.aggregates.domain.GetDataSetSectionData import org.dhis2.mobile.aggregates.domain.GetDataSetSectionIndicators import org.dhis2.mobile.aggregates.domain.GetDataValueData import org.dhis2.mobile.aggregates.domain.GetDataValueInput -import org.dhis2.mobile.aggregates.domain.ResourceManager +import org.dhis2.mobile.aggregates.domain.RunValidationRules import org.dhis2.mobile.aggregates.domain.SetDataValue import org.dhis2.mobile.aggregates.ui.dispatcher.Dispatcher +import org.dhis2.mobile.aggregates.ui.provider.DataSetModalDialogProvider +import org.dhis2.mobile.aggregates.ui.provider.ResourceManager import org.dhis2.mobile.aggregates.ui.viewModel.DataSetTableViewModel import org.koin.core.module.Module import org.koin.core.module.dsl.singleOf @@ -85,13 +90,58 @@ internal val featureModule = module { ) } + factory { params -> + CheckValidationRulesConfiguration( + dataSetUid = params.get(), + dataSetInstanceRepository = get(), + ) + } + + factory { params -> + CheckCompletionStatus( + dataSetUid = params.get(), + periodId = params.get(), + orgUnitUid = params.get(), + attrOptionComboUid = params.get(), + dataSetInstanceRepository = get(), + ) + } + + factory { + DataSetModalDialogProvider( + resourceManager = get(), + ) + } + + factory { params -> + CompleteDataSet( + dataSetUid = params.get(), + periodId = params.get(), + orgUnitUid = params.get(), + attrOptionComboUid = params.get(), + dataSetInstanceRepository = get(), + ) + } + + factory { params -> + RunValidationRules( + dataSetUid = params.get(), + periodId = params.get(), + orgUnitUid = params.get(), + attrOptionComboUid = params.get(), + dataSetInstanceRepository = get(), + ) + } + viewModel { params -> val dataSetUid = params.get() val periodId = params.get() val orgUnitUid = params.get() val attrOptionComboUid = params.get() + val onClose = params.get<() -> Unit>() DataSetTableViewModel( + onClose = onClose, getDataSetInstanceData = get { parametersOf( dataSetUid, @@ -116,7 +166,20 @@ internal val featureModule = module { parametersOf(periodId, orgUnitUid, attrOptionComboUid) }, resourceManager = get(), + checkValidationRulesConfiguration = get { + parametersOf(dataSetUid) + }, + checkCompletionStatus = get { + parametersOf(dataSetUid, periodId, orgUnitUid, attrOptionComboUid) + }, + datasetModalDialogProvider = get(), + completeDataSet = get { + parametersOf(dataSetUid, periodId, orgUnitUid, attrOptionComboUid) + }, dispatcher = get(), + runValidationRules = get { + parametersOf(dataSetUid, periodId, orgUnitUid, attrOptionComboUid) + }, ) } } diff --git a/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/domain/CheckCompletionStatus.kt b/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/domain/CheckCompletionStatus.kt new file mode 100644 index 0000000000..a67c181933 --- /dev/null +++ b/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/domain/CheckCompletionStatus.kt @@ -0,0 +1,30 @@ +package org.dhis2.mobile.aggregates.domain + +import org.dhis2.mobile.aggregates.data.DataSetInstanceRepository +import org.dhis2.mobile.aggregates.model.DataSetCompletionStatus +import org.dhis2.mobile.aggregates.model.DataSetCompletionStatus.COMPLETED +import org.dhis2.mobile.aggregates.model.DataSetCompletionStatus.NOT_COMPLETED + +internal class CheckCompletionStatus( + private val dataSetUid: String, + private val periodId: String, + private val orgUnitUid: String, + private val attrOptionComboUid: String, + private val dataSetInstanceRepository: DataSetInstanceRepository, +) { + + suspend operator fun invoke(): DataSetCompletionStatus { + val isComplete = dataSetInstanceRepository.isComplete( + dataSetUid = dataSetUid, + periodId = periodId, + orgUnitUid = orgUnitUid, + attrOptionComboUid = attrOptionComboUid, + ) + + return if (isComplete) { + COMPLETED + } else { + NOT_COMPLETED + } + } +} diff --git a/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/domain/CheckValidationRulesConfiguration.kt b/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/domain/CheckValidationRulesConfiguration.kt new file mode 100644 index 0000000000..7f1ac638f0 --- /dev/null +++ b/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/domain/CheckValidationRulesConfiguration.kt @@ -0,0 +1,31 @@ +package org.dhis2.mobile.aggregates.domain + +import org.dhis2.mobile.aggregates.data.DataSetInstanceRepository +import org.dhis2.mobile.aggregates.model.ValidationRulesConfiguration +import org.dhis2.mobile.aggregates.model.ValidationRulesConfiguration.MANDATORY +import org.dhis2.mobile.aggregates.model.ValidationRulesConfiguration.NONE +import org.dhis2.mobile.aggregates.model.ValidationRulesConfiguration.OPTIONAL + +internal class CheckValidationRulesConfiguration( + private val dataSetUid: String, + private val dataSetInstanceRepository: DataSetInstanceRepository, +) { + suspend operator fun invoke(): ValidationRulesConfiguration { + val hasValidationRules = dataSetInstanceRepository.checkIfHasValidationRules( + dataSetUid = dataSetUid, + ) + + return if (hasValidationRules) { + val mandatory = dataSetInstanceRepository.areValidationRulesMandatory( + dataSetUid = dataSetUid, + ) + if (mandatory) { + MANDATORY + } else { + OPTIONAL + } + } else { + NONE + } + } +} diff --git a/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/domain/CompleteDataSet.kt b/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/domain/CompleteDataSet.kt new file mode 100644 index 0000000000..9bb81b136c --- /dev/null +++ b/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/domain/CompleteDataSet.kt @@ -0,0 +1,59 @@ +package org.dhis2.mobile.aggregates.domain + +import org.dhis2.mobile.aggregates.data.DataSetInstanceRepository +import org.dhis2.mobile.aggregates.model.DataSetMandatoryFieldsStatus +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 + +internal class CompleteDataSet( + private val dataSetUid: String, + private val periodId: String, + private val orgUnitUid: String, + private val attrOptionComboUid: String, + private val dataSetInstanceRepository: DataSetInstanceRepository, +) { + suspend operator fun invoke(): DataSetMandatoryFieldsStatus { + return if (checkIfHasMissingMandatoryFields()) { + MISSING_MANDATORY_FIELDS + } else if (checkIfHasMissingMandatoryFieldsCombination()) { + MISSING_MANDATORY_FIELDS_COMBINATION + } else { + dataSetInstanceRepository.completeDataset( + dataSetUid = dataSetUid, + periodId = periodId, + orgUnitUid = orgUnitUid, + attributeOptionComboUid = attrOptionComboUid, + ).fold( + onSuccess = { + SUCCESS + }, + onFailure = { + ERROR + }, + ) + } + } + + private suspend fun checkIfHasMissingMandatoryFields(): Boolean { + val hasMissingMandatoryFields = dataSetInstanceRepository.checkIfHasMissingMandatoryFields( + dataSetUid = dataSetUid, + periodId = periodId, + orgUnitUid = orgUnitUid, + attributeOptionComboUid = attrOptionComboUid, + ) + return hasMissingMandatoryFields + } + + private suspend fun checkIfHasMissingMandatoryFieldsCombination(): Boolean { + val hasMissingMandatoryFieldsCombination = + dataSetInstanceRepository.checkIfHasMissingMandatoryFieldsCombination( + dataSetUid = dataSetUid, + periodId = periodId, + orgUnitUid = orgUnitUid, + attributeOptionComboUid = attrOptionComboUid, + ) + return hasMissingMandatoryFieldsCombination + } +} diff --git a/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/domain/ResourceManager.kt b/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/domain/ResourceManager.kt deleted file mode 100644 index 8a3beaba50..0000000000 --- a/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/domain/ResourceManager.kt +++ /dev/null @@ -1,11 +0,0 @@ -package org.dhis2.mobile.aggregates.domain - -import org.dhis2.mobile.aggregates.resources.Res -import org.dhis2.mobile.aggregates.resources.default_column_label -import org.dhis2.mobile.aggregates.resources.total_header_label -import org.jetbrains.compose.resources.getString - -internal class ResourceManager { - suspend fun defaultHeaderLabel() = getString(Res.string.default_column_label) - suspend fun totalsHeader() = getString(Res.string.total_header_label) -} diff --git a/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/domain/RunValidationRules.kt b/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/domain/RunValidationRules.kt new file mode 100644 index 0000000000..e2eebd9938 --- /dev/null +++ b/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/domain/RunValidationRules.kt @@ -0,0 +1,21 @@ +package org.dhis2.mobile.aggregates.domain + +import org.dhis2.mobile.aggregates.data.DataSetInstanceRepository +import org.dhis2.mobile.aggregates.model.ValidationRulesResult + +internal class RunValidationRules( + private val dataSetUid: String, + private val periodId: String, + private val orgUnitUid: String, + private val attrOptionComboUid: String, + private val dataSetInstanceRepository: DataSetInstanceRepository, +) { + suspend operator fun invoke(): ValidationRulesResult { + return dataSetInstanceRepository.runValidationRules( + dataSetUid, + periodId, + orgUnitUid, + attrOptionComboUid, + ) + } +} diff --git a/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/model/DataSetCompletionStatus.kt b/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/model/DataSetCompletionStatus.kt new file mode 100644 index 0000000000..7b98339a69 --- /dev/null +++ b/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/model/DataSetCompletionStatus.kt @@ -0,0 +1,6 @@ +package org.dhis2.mobile.aggregates.model + +internal enum class DataSetCompletionStatus { + COMPLETED, + NOT_COMPLETED, +} diff --git a/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/model/DataSetMandatoryFieldsStatus.kt b/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/model/DataSetMandatoryFieldsStatus.kt new file mode 100644 index 0000000000..2a5511c728 --- /dev/null +++ b/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/model/DataSetMandatoryFieldsStatus.kt @@ -0,0 +1,8 @@ +package org.dhis2.mobile.aggregates.model + +internal enum class DataSetMandatoryFieldsStatus { + MISSING_MANDATORY_FIELDS, + MISSING_MANDATORY_FIELDS_COMBINATION, + SUCCESS, + ERROR, +} diff --git a/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/model/ValidationRulesConfiguration.kt b/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/model/ValidationRulesConfiguration.kt new file mode 100644 index 0000000000..8e0c1d0a37 --- /dev/null +++ b/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/model/ValidationRulesConfiguration.kt @@ -0,0 +1,7 @@ +package org.dhis2.mobile.aggregates.model + +internal enum class ValidationRulesConfiguration { + NONE, + MANDATORY, + OPTIONAL, +} diff --git a/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/model/ValidationRulesResult.kt b/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/model/ValidationRulesResult.kt new file mode 100644 index 0000000000..3fb054b633 --- /dev/null +++ b/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/model/ValidationRulesResult.kt @@ -0,0 +1,38 @@ +package org.dhis2.mobile.aggregates.model + +internal data class ValidationRulesResult( + val validationResultStatus: ValidationResultStatus, + val violations: List, +) + +internal data class Violation( + val description: String?, + val instruction: String?, + val dataToReview: List, +) + +internal data class DataToReview( + val dataElementUid: String, + val dataElementDisplayName: String?, + val categoryOptionComboUid: String, + val categoryOptionComboDisplayName: String?, + val value: String, + val isFromDefaultCatCombo: Boolean, +) { + fun formattedDataLabel(): String { + return if (isFromDefaultCatCombo) { + dataElementDisplayName ?: dataElementUid + } else { + String.format( + "%s / %s", + dataElementDisplayName, + categoryOptionComboDisplayName, + ) + } + } +} + +internal enum class ValidationResultStatus { + OK, + ERROR, +} diff --git a/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/model/mapper/IndicatorsMapToTableModel.kt b/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/model/mapper/IndicatorsMapToTableModel.kt index 9889c04dd2..8edc14af87 100644 --- a/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/model/mapper/IndicatorsMapToTableModel.kt +++ b/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/model/mapper/IndicatorsMapToTableModel.kt @@ -1,8 +1,8 @@ package org.dhis2.mobile.aggregates.model.mapper import org.dhis2.mobile.aggregates.domain.IndicatorMap -import org.dhis2.mobile.aggregates.domain.ResourceManager import org.dhis2.mobile.aggregates.ui.constants.INDICATOR_TABLE_UID +import org.dhis2.mobile.aggregates.ui.provider.ResourceManager import org.hisp.dhis.mobile.ui.designsystem.component.table.model.RowHeader import org.hisp.dhis.mobile.ui.designsystem.component.table.model.TableCell import org.hisp.dhis.mobile.ui.designsystem.component.table.model.TableHeader diff --git a/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/model/mapper/TableGroupToTableModel.kt b/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/model/mapper/TableGroupToTableModel.kt index b8b084d2d7..43ad0783d8 100644 --- a/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/model/mapper/TableGroupToTableModel.kt +++ b/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/model/mapper/TableGroupToTableModel.kt @@ -1,6 +1,5 @@ package org.dhis2.mobile.aggregates.model.mapper -import org.dhis2.mobile.aggregates.domain.ResourceManager import org.dhis2.mobile.aggregates.model.DataSetInstanceSectionData import org.dhis2.mobile.aggregates.model.DataValueData import org.dhis2.mobile.aggregates.model.TableGroup @@ -9,6 +8,7 @@ import org.dhis2.mobile.aggregates.ui.inputs.CellIdGenerator import org.dhis2.mobile.aggregates.ui.inputs.CellIdGenerator.totalRow import org.dhis2.mobile.aggregates.ui.inputs.TableId import org.dhis2.mobile.aggregates.ui.inputs.TableIdType +import org.dhis2.mobile.aggregates.ui.provider.ResourceManager import org.hisp.dhis.mobile.ui.designsystem.component.table.model.RowHeader import org.hisp.dhis.mobile.ui.designsystem.component.table.model.TableCell import org.hisp.dhis.mobile.ui.designsystem.component.table.model.TableHeader diff --git a/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/model/mapper/TableModelToTableModelWithTotalRow.kt b/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/model/mapper/TableModelToTableModelWithTotalRow.kt index 08df6fc416..bd48795a43 100644 --- a/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/model/mapper/TableModelToTableModelWithTotalRow.kt +++ b/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/model/mapper/TableModelToTableModelWithTotalRow.kt @@ -1,9 +1,9 @@ package org.dhis2.mobile.aggregates.model.mapper -import org.dhis2.mobile.aggregates.domain.ResourceManager import org.dhis2.mobile.aggregates.ui.inputs.CellIdGenerator.totalCellId import org.dhis2.mobile.aggregates.ui.inputs.CellIdGenerator.totalHeaderRowId import org.dhis2.mobile.aggregates.ui.inputs.CellIdGenerator.totalId +import org.dhis2.mobile.aggregates.ui.provider.ResourceManager import org.hisp.dhis.mobile.ui.designsystem.component.table.model.RowHeader import org.hisp.dhis.mobile.ui.designsystem.component.table.model.TableCell import org.hisp.dhis.mobile.ui.designsystem.component.table.model.TableModel diff --git a/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/model/mapper/TableModelToTableModelWithUpdatedValue.kt b/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/model/mapper/TableModelToTableModelWithUpdatedValue.kt index 8179276400..2a279e909d 100644 --- a/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/model/mapper/TableModelToTableModelWithUpdatedValue.kt +++ b/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/model/mapper/TableModelToTableModelWithUpdatedValue.kt @@ -1,7 +1,7 @@ package org.dhis2.mobile.aggregates.model.mapper -import org.dhis2.mobile.aggregates.domain.ResourceManager import org.dhis2.mobile.aggregates.ui.inputs.CellIdGenerator.totalHeaderRowId +import org.dhis2.mobile.aggregates.ui.provider.ResourceManager import org.hisp.dhis.mobile.ui.designsystem.component.table.model.TableModel internal suspend fun TableModel.updateValue( diff --git a/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/ui/DataSetTableScreen.kt b/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/ui/DataSetTableScreen.kt index 85e6bc3abb..ed3b839424 100644 --- a/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/ui/DataSetTableScreen.kt +++ b/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/ui/DataSetTableScreen.kt @@ -1,11 +1,12 @@ -@file:OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) +@file:OptIn(ExperimentalMaterial3Api::class) package org.dhis2.mobile.aggregates.ui import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically -import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy import androidx.compose.foundation.layout.Box @@ -19,10 +20,16 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Done import androidx.compose.material.icons.filled.Sync +import androidx.compose.material.icons.outlined.Done import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold +import androidx.compose.material3.Snackbar +import androidx.compose.material3.SnackbarDefaults +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable @@ -30,6 +37,7 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -37,20 +45,27 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch import org.dhis2.mobile.aggregates.model.DataSetDetails import org.dhis2.mobile.aggregates.model.DataSetInstanceParameters import org.dhis2.mobile.aggregates.model.DataSetSection import org.dhis2.mobile.aggregates.resources.Res import org.dhis2.mobile.aggregates.resources.action_done +import org.dhis2.mobile.aggregates.ui.component.ValidationBar +import org.dhis2.mobile.aggregates.ui.component.ValidationBottomSheet import org.dhis2.mobile.aggregates.ui.constants.INPUT_DIALOG_DONE_TAG import org.dhis2.mobile.aggregates.ui.constants.INPUT_DIALOG_TAG import org.dhis2.mobile.aggregates.ui.constants.SYNC_BUTTON_TAG import org.dhis2.mobile.aggregates.ui.inputs.InputProvider +import org.dhis2.mobile.aggregates.ui.snackbar.ObserveAsEvents +import org.dhis2.mobile.aggregates.ui.snackbar.SnackbarController import org.dhis2.mobile.aggregates.ui.states.DataSetScreenState import org.dhis2.mobile.aggregates.ui.states.DataSetSectionTable import org.dhis2.mobile.aggregates.ui.viewModel.DataSetTableViewModel import org.hisp.dhis.mobile.ui.designsystem.component.Button import org.hisp.dhis.mobile.ui.designsystem.component.ButtonStyle +import org.hisp.dhis.mobile.ui.designsystem.component.FAB +import org.hisp.dhis.mobile.ui.designsystem.component.FABStyle import org.hisp.dhis.mobile.ui.designsystem.component.IconButton import org.hisp.dhis.mobile.ui.designsystem.component.IconButtonStyle import org.hisp.dhis.mobile.ui.designsystem.component.InputDialog @@ -69,6 +84,9 @@ import org.hisp.dhis.mobile.ui.designsystem.component.table.ui.DataTable import org.hisp.dhis.mobile.ui.designsystem.component.table.ui.TableSelection import org.hisp.dhis.mobile.ui.designsystem.theme.Radius import org.hisp.dhis.mobile.ui.designsystem.theme.Spacing +import org.hisp.dhis.mobile.ui.designsystem.theme.SurfaceColor +import org.hisp.dhis.mobile.ui.designsystem.theme.TextColor +import org.hisp.dhis.mobile.ui.designsystem.theme.dropShadow import org.jetbrains.compose.resources.stringResource import org.koin.compose.viewmodel.koinViewModel import org.koin.core.parameter.parametersOf @@ -96,6 +114,7 @@ fun DataSetInstanceScreen( parameters.periodId, parameters.organisationUnitUid, parameters.attributeOptionComboUid, + onBackClicked, ) }) val dataSetScreenState by dataSetTableViewModel.dataSetScreenState.collectAsState() @@ -106,6 +125,23 @@ fun DataSetInstanceScreen( } } + val snackbarHostState = remember { SnackbarHostState() } + val scope = rememberCoroutineScope() + + ObserveAsEvents( + flow = SnackbarController.events, + snackbarHostState, + ) { event -> + scope.launch { + snackbarHostState.currentSnackbarData?.dismiss() + + snackbarHostState.showSnackbar( + message = event.message, + duration = SnackbarDuration.Short, + ) + } + } + Scaffold( modifier = Modifier .fillMaxSize(), @@ -152,6 +188,32 @@ fun DataSetInstanceScreen( ), ) }, + snackbarHost = { CustomSnackbarHost(snackbarHostState) }, + floatingActionButton = { + AnimatedVisibility( + visible = ((dataSetScreenState as? DataSetScreenState.Loaded)?.dataSetSectionTable is DataSetSectionTable.Loaded), + enter = fadeIn(), + exit = fadeOut(), + ) { + FAB( + style = FABStyle.SECONDARY, + icon = { + Icon( + imageVector = Icons.Outlined.Done, + contentDescription = "Save Button", + ) + }, + onClick = { + dataSetTableViewModel.onSaveClicked() + }, + ) + } + }, + bottomBar = { + (dataSetScreenState as? DataSetScreenState.Loaded)?.validationBar?.let { validationBarUiState -> + ValidationBar(uiState = validationBarUiState) + } + }, ) { Box( modifier = Modifier.fillMaxSize() @@ -287,6 +349,10 @@ fun DataSetInstanceScreen( } } } + + (dataSetScreenState as? DataSetScreenState.Loaded)?.modalDialog?.let { dataSetUIState -> + ValidationBottomSheet(dataSetUIState = dataSetUIState) + } } /** @@ -435,3 +501,15 @@ private fun DataSetTable( bottomContent = {}, ) } + +@Composable +private fun CustomSnackbarHost(hostState: SnackbarHostState) { + SnackbarHost(hostState = hostState) { data -> + Snackbar( + modifier = Modifier.dropShadow(shape = SnackbarDefaults.shape), + snackbarData = data, + containerColor = SurfaceColor.SurfaceBright, + contentColor = TextColor.OnSurface, + ) + } +} diff --git a/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/ui/component/ValidationBar.kt b/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/ui/component/ValidationBar.kt new file mode 100644 index 0000000000..adb740d1ae --- /dev/null +++ b/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/ui/component/ValidationBar.kt @@ -0,0 +1,66 @@ +package org.dhis2.mobile.aggregates.ui.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.ExpandLess +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import org.dhis2.mobile.aggregates.ui.states.ValidationBarUiState +import org.hisp.dhis.mobile.ui.designsystem.component.Badge +import org.hisp.dhis.mobile.ui.designsystem.component.IconButton +import org.hisp.dhis.mobile.ui.designsystem.component.IconButtonStyle +import org.hisp.dhis.mobile.ui.designsystem.theme.Spacing +import org.hisp.dhis.mobile.ui.designsystem.theme.SurfaceColor +import org.hisp.dhis.mobile.ui.designsystem.theme.TextColor + +@Composable +internal fun ValidationBar( + uiState: ValidationBarUiState, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(Spacing.Spacing8), + modifier = Modifier + .fillMaxWidth() + .background(color = SurfaceColor.ErrorContainer) + .padding( + start = Spacing.Spacing16, + end = Spacing.Spacing4, + ), + ) { + Badge( + modifier = Modifier + .padding( + start = Spacing.Spacing4, + end = Spacing.Spacing4, + ), + text = uiState.quantity.toString(), + color = SurfaceColor.Error, + textColor = TextColor.OnPrimary, + ) + Text( + modifier = Modifier.weight(1f), + text = uiState.description, + style = MaterialTheme.typography.bodyMedium, + ) + IconButton( + style = IconButtonStyle.STANDARD, + icon = { + Icon( + imageVector = Icons.Outlined.ExpandLess, + contentDescription = "Expand", + tint = TextColor.OnSurfaceVariant, + ) + }, + onClick = uiState.onExpandErrors, + ) + } +} diff --git a/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/ui/component/ValidationBottomSheet.kt b/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/ui/component/ValidationBottomSheet.kt new file mode 100644 index 0000000000..aa25f60c77 --- /dev/null +++ b/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/ui/component/ValidationBottomSheet.kt @@ -0,0 +1,188 @@ +package org.dhis2.mobile.aggregates.ui.component + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.ErrorOutline +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import org.dhis2.mobile.aggregates.model.Violation +import org.dhis2.mobile.aggregates.resources.Res +import org.dhis2.mobile.aggregates.resources.complete +import org.dhis2.mobile.aggregates.resources.complete_anyway +import org.dhis2.mobile.aggregates.resources.no +import org.dhis2.mobile.aggregates.resources.not_now +import org.dhis2.mobile.aggregates.resources.ok +import org.dhis2.mobile.aggregates.resources.review +import org.dhis2.mobile.aggregates.resources.yes +import org.dhis2.mobile.aggregates.ui.states.DataSetModalDialogUIState +import org.dhis2.mobile.aggregates.ui.states.DataSetModalType +import org.dhis2.mobile.aggregates.ui.states.DataSetModalType.COMPLETION +import org.dhis2.mobile.aggregates.ui.states.DataSetModalType.MANDATORY_FIELDS +import org.dhis2.mobile.aggregates.ui.states.DataSetModalType.VALIDATION_RULES +import org.dhis2.mobile.aggregates.ui.states.DataSetModalType.VALIDATION_RULES_ERROR +import org.hisp.dhis.mobile.ui.designsystem.component.BottomSheetShell +import org.hisp.dhis.mobile.ui.designsystem.component.Button +import org.hisp.dhis.mobile.ui.designsystem.component.ButtonBlock +import org.hisp.dhis.mobile.ui.designsystem.component.ButtonStyle +import org.hisp.dhis.mobile.ui.designsystem.component.state.BottomSheetShellDefaults +import org.hisp.dhis.mobile.ui.designsystem.theme.SurfaceColor +import org.jetbrains.compose.resources.stringResource + +@Composable +internal fun ValidationBottomSheet(dataSetUIState: DataSetModalDialogUIState) { + BottomSheetShell( + uiState = dataSetUIState.contentDialogUIState, + content = { + provideContent( + type = dataSetUIState.type, + violations = dataSetUIState.violations, + ) + }, + buttonBlock = { + provideButtonBlock( + type = dataSetUIState.type, + onPrimaryButtonClick = dataSetUIState.onPrimaryButtonClick, + onSecondaryButtonClick = dataSetUIState.onSecondaryButtonClick, + ) + }, + onDismiss = dataSetUIState.onDismiss, + icon = { + provideIcon(dataSetUIState.type) + }, + ) +} + +@Composable +private fun provideIcon(type: DataSetModalType) { + when (type) { + VALIDATION_RULES_ERROR -> { + Icon( + imageVector = Icons.Outlined.ErrorOutline, + contentDescription = null, + tint = SurfaceColor.Error, + ) + } + + else -> { + // No icon + } + } +} + +@Composable +private fun provideContent( + type: DataSetModalType, + violations: List?, +) { + when (type) { + VALIDATION_RULES_ERROR -> { + violations?.let { + ValidationRulesErrorDialog( + violations = it, + ) + } + } + + else -> { + // No content + } + } +} + +@Composable +private fun provideButtonBlock( + type: DataSetModalType, + onPrimaryButtonClick: () -> Unit, + onSecondaryButtonClick: () -> Unit, +) { + when (type) { + COMPLETION -> { + ButtonBlock( + modifier = Modifier.padding( + BottomSheetShellDefaults.buttonBlockPaddings(), + ), + primaryButton = { + Button( + style = ButtonStyle.OUTLINED, + text = stringResource(Res.string.not_now), + onClick = onPrimaryButtonClick, + modifier = Modifier.fillMaxWidth(), + ) + }, + secondaryButton = { + Button( + style = ButtonStyle.FILLED, + text = stringResource(Res.string.complete), + modifier = Modifier.fillMaxWidth(), + onClick = onSecondaryButtonClick, + ) + }, + ) + } + + MANDATORY_FIELDS -> { + ButtonBlock( + modifier = Modifier.padding( + BottomSheetShellDefaults.buttonBlockPaddings(), + ), + primaryButton = { + Button( + style = ButtonStyle.FILLED, + text = stringResource(Res.string.ok), + modifier = Modifier.fillMaxWidth(), + onClick = onPrimaryButtonClick, + ) + }, + ) + } + + VALIDATION_RULES -> { + ButtonBlock( + modifier = Modifier.padding( + BottomSheetShellDefaults.buttonBlockPaddings(), + ), + primaryButton = { + Button( + style = ButtonStyle.OUTLINED, + text = stringResource(Res.string.no), + onClick = onPrimaryButtonClick, + modifier = Modifier.fillMaxWidth(), + ) + }, + secondaryButton = { + Button( + style = ButtonStyle.FILLED, + text = stringResource(Res.string.yes), + modifier = Modifier.fillMaxWidth(), + onClick = onSecondaryButtonClick, + ) + }, + ) + } + + VALIDATION_RULES_ERROR -> { + ButtonBlock( + modifier = Modifier.padding( + BottomSheetShellDefaults.buttonBlockPaddings(), + ), + primaryButton = { + Button( + style = ButtonStyle.TEXT, + text = stringResource(Res.string.complete_anyway), + onClick = onPrimaryButtonClick, + ) + }, + secondaryButton = { + Button( + style = ButtonStyle.FILLED, + text = stringResource(Res.string.review), + modifier = Modifier.fillMaxWidth(), + onClick = onSecondaryButtonClick, + ) + }, + ) + } + } +} diff --git a/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/ui/component/ValidationRulesErrorDialog.kt b/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/ui/component/ValidationRulesErrorDialog.kt new file mode 100644 index 0000000000..534ead5389 --- /dev/null +++ b/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/ui/component/ValidationRulesErrorDialog.kt @@ -0,0 +1,180 @@ +package org.dhis2.mobile.aggregates.ui.component + +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import org.dhis2.mobile.aggregates.model.Violation +import org.dhis2.mobile.aggregates.resources.Res +import org.dhis2.mobile.aggregates.resources.validation_rules_data_to_review +import org.hisp.dhis.mobile.ui.designsystem.component.Tag +import org.hisp.dhis.mobile.ui.designsystem.component.TagType +import org.hisp.dhis.mobile.ui.designsystem.theme.Shape +import org.hisp.dhis.mobile.ui.designsystem.theme.Spacing +import org.hisp.dhis.mobile.ui.designsystem.theme.SurfaceColor +import org.jetbrains.compose.resources.stringResource +import kotlin.math.max + +@Composable +internal fun ValidationRulesErrorDialog(violations: List) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = spacedBy(Spacing.Spacing24), + ) { + val pageCount = violations.size + val pagerState = rememberPagerState( + pageCount = { pageCount }, + ) + + if (pageCount > 1) { + PagerIndicator( + pageCount = pageCount, + currentPage = pagerState.currentPage, + ) + } + + HorizontalPager( + state = pagerState, + pageSpacing = Spacing.Spacing8, + contentPadding = PaddingValues(horizontal = Spacing.Spacing24), + ) { page -> + val violation = violations[page] + + LazyColumn( + modifier = Modifier + .fillMaxSize() + .clip(Shape.Large) + .background(SurfaceColor.PrimaryContainer) + .padding(Spacing.Spacing16), + ) { + item { + Column( + verticalArrangement = spacedBy(Spacing.Spacing8), + ) { + Text( + text = violation.description ?: "", + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Bold, + ) + Text( + text = violation.instruction ?: "", + style = MaterialTheme.typography.bodyMedium, + ) + } + } + + item { + Spacer(modifier = Modifier.height(Spacing.Spacing24)) + } + item { + Text( + text = stringResource(Res.string.validation_rules_data_to_review), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Bold, + ) + } + items(violation.dataToReview) { dataToReview -> + Spacer(modifier = Modifier.height(Spacing.Spacing8)) + Row( + horizontalArrangement = spacedBy(Spacing.Spacing4), + ) { + Text( + text = "${dataToReview.formattedDataLabel()}:", + style = MaterialTheme.typography.bodyMedium, + ) + Tag( + label = dataToReview.value, + type = TagType.ERROR, + ) + } + } + } + } + } +} + +@Composable +internal fun PagerIndicator( + indicatorScrollState: LazyListState = rememberLazyListState(), + pageCount: Int, + currentPage: Int, + dotIndicatorColor: Color = SurfaceColor.Error, +) { + LaunchedEffect(key1 = currentPage) { + val size = indicatorScrollState.layoutInfo.visibleItemsInfo.size + val lastVisibleIndex = + indicatorScrollState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0 + val firstVisibleItemIndex = indicatorScrollState.firstVisibleItemIndex + + if (currentPage > lastVisibleIndex - 1) { + indicatorScrollState.animateScrollToItem(currentPage - size + 2) + } else if (currentPage <= firstVisibleItemIndex + 1) { + indicatorScrollState.animateScrollToItem(max(currentPage - 1, 0)) + } + } + + LazyRow( + state = indicatorScrollState, + modifier = Modifier + .width(120.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + repeat(pageCount) { iteration -> + val color = + if (currentPage == iteration) dotIndicatorColor else SurfaceColor.ErrorContainer + item(key = "item$iteration") { + val firstVisibleIndex by remember { derivedStateOf { indicatorScrollState.firstVisibleItemIndex } } + val lastVisibleIndex = + indicatorScrollState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0 + val size by animateDpAsState( + targetValue = when (iteration) { + currentPage -> Spacing.Spacing14 + in firstVisibleIndex + 1.. 10.dp + else -> Spacing.Spacing14 + }, + label = "PagerIndicatorDotSizeAnimation", + ) + Box( + modifier = Modifier + .padding(all = Spacing.Spacing8) + .background(color = color, CircleShape) + .size( + size, + ), + ) + } + } + } +} diff --git a/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/ui/provider/DataSetModalDialogProvider.kt b/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/ui/provider/DataSetModalDialogProvider.kt new file mode 100644 index 0000000000..d11f149630 --- /dev/null +++ b/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/ui/provider/DataSetModalDialogProvider.kt @@ -0,0 +1,94 @@ +package org.dhis2.mobile.aggregates.ui.provider + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.ui.text.style.TextAlign +import org.dhis2.mobile.aggregates.model.Violation +import org.dhis2.mobile.aggregates.ui.states.DataSetModalDialogUIState +import org.dhis2.mobile.aggregates.ui.states.DataSetModalType +import org.hisp.dhis.mobile.ui.designsystem.component.state.BottomSheetShellUIState +import org.hisp.dhis.mobile.ui.designsystem.theme.Spacing + +internal class DataSetModalDialogProvider( + val resourceManager: ResourceManager, +) { + + suspend fun provideCompletionDialog( + onDismiss: () -> Unit, + onNotNow: () -> Unit, + onComplete: () -> Unit, + ): DataSetModalDialogUIState { + return DataSetModalDialogUIState( + contentDialogUIState = BottomSheetShellUIState( + title = resourceManager.provideCompletionDialogTitle(), + subtitle = resourceManager.provideCompletionDialogDescription(), + showBottomSectionDivider = false, + headerTextAlignment = TextAlign.Start, + ), + onDismiss = onDismiss, + onPrimaryButtonClick = onNotNow, + onSecondaryButtonClick = onComplete, + type = DataSetModalType.COMPLETION, + ) + } + + suspend fun provideMandatoryFieldsDialog( + mandatoryFieldsMessage: String, + onDismiss: () -> Unit, + onAccept: () -> Unit, + ): DataSetModalDialogUIState { + return DataSetModalDialogUIState( + contentDialogUIState = BottomSheetShellUIState( + title = resourceManager.provideSaved(), + subtitle = mandatoryFieldsMessage, + showBottomSectionDivider = false, + headerTextAlignment = TextAlign.Start, + ), + onDismiss = onDismiss, + onPrimaryButtonClick = onAccept, + type = DataSetModalType.MANDATORY_FIELDS, + ) + } + + suspend fun provideAskRunValidationsDialog( + onDismiss: () -> Unit, + onDeny: () -> Unit, + onAccept: () -> Unit, + ): DataSetModalDialogUIState { + return DataSetModalDialogUIState( + contentDialogUIState = BottomSheetShellUIState( + title = resourceManager.provideSaved(), + subtitle = resourceManager.provideAskRunValidations(), + showBottomSectionDivider = false, + headerTextAlignment = TextAlign.Start, + ), + onDismiss = onDismiss, + onPrimaryButtonClick = onDeny, + onSecondaryButtonClick = onAccept, + type = DataSetModalType.VALIDATION_RULES, + ) + } + + suspend fun provideValidationRulesErrorDialog( + onDismiss: () -> Unit, + onMarkAsComplete: () -> Unit, + violations: List, + ): DataSetModalDialogUIState { + return DataSetModalDialogUIState( + contentDialogUIState = BottomSheetShellUIState( + title = "${violations.size} ${ + resourceManager.provideValidationErrorDescription( + violations.size, + ) + }", + showTopSectionDivider = false, + showBottomSectionDivider = false, + contentPadding = PaddingValues(Spacing.Spacing0), + ), + onDismiss = onDismiss, + onPrimaryButtonClick = onMarkAsComplete, + onSecondaryButtonClick = onDismiss, + type = DataSetModalType.VALIDATION_RULES_ERROR, + violations = violations, + ) + } +} 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 new file mode 100644 index 0000000000..d5ef40ad8b --- /dev/null +++ b/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/ui/provider/ResourceManager.kt @@ -0,0 +1,48 @@ +package org.dhis2.mobile.aggregates.ui.provider + +import org.dhis2.mobile.aggregates.resources.Res +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 +import org.dhis2.mobile.aggregates.resources.error_on_complete_dataset +import org.dhis2.mobile.aggregates.resources.errors +import org.dhis2.mobile.aggregates.resources.field_mandatory +import org.dhis2.mobile.aggregates.resources.field_required +import org.dhis2.mobile.aggregates.resources.mark_dataset_complete +import org.dhis2.mobile.aggregates.resources.run_validation_rules +import org.dhis2.mobile.aggregates.resources.saved +import org.dhis2.mobile.aggregates.resources.total_header_label +import org.dhis2.mobile.aggregates.resources.validation_success_title +import org.jetbrains.compose.resources.getString + +internal class ResourceManager { + suspend fun defaultHeaderLabel() = getString(Res.string.default_column_label) + + suspend fun totalsHeader() = getString(Res.string.total_header_label) + + suspend fun provideCompletionDialogTitle() = getString(Res.string.validation_success_title) + + suspend fun provideCompletionDialogDescription() = getString(Res.string.mark_dataset_complete) + + suspend fun provideSaved() = getString(Res.string.saved) + + suspend fun provideMandatoryFieldsMessage() = + getString(Res.string.field_mandatory) + + suspend fun provideMandatoryFieldsCombinationMessage() = + getString(Res.string.field_required) + + suspend fun provideSavedAndCompleted() = getString(Res.string.dataset_saved_completed) + + suspend fun provideErrorOnCompleteDataset() = getString(Res.string.error_on_complete_dataset) + + suspend fun provideAskRunValidations() = getString(Res.string.run_validation_rules) + + suspend fun provideValidationErrorDescription(errors: Int): String { + return if (errors == 1) { + getString(Res.string.error) + } else { + getString(Res.string.errors) + } + } +} diff --git a/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/ui/snackbar/ObserveAsEvents.kt b/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/ui/snackbar/ObserveAsEvents.kt new file mode 100644 index 0000000000..5f5e630880 --- /dev/null +++ b/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/ui/snackbar/ObserveAsEvents.kt @@ -0,0 +1,23 @@ +package org.dhis2.mobile.aggregates.ui.snackbar + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.platform.LocalLifecycleOwner +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.withContext + +@Composable +fun ObserveAsEvents( + flow: Flow, + key1: Any? = null, + key2: Any? = null, + onEvent: (T) -> Unit, +) { + val lifecycleOwner = LocalLifecycleOwner.current + LaunchedEffect(lifecycleOwner.lifecycle, key1, key2, flow) { + withContext(Dispatchers.Main.immediate) { + flow.collect(onEvent) + } + } +} diff --git a/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/ui/snackbar/SnackbarController.kt b/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/ui/snackbar/SnackbarController.kt new file mode 100644 index 0000000000..0732af7988 --- /dev/null +++ b/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/ui/snackbar/SnackbarController.kt @@ -0,0 +1,24 @@ +package org.dhis2.mobile.aggregates.ui.snackbar + +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.receiveAsFlow + +data class SnackbarEvent( + val message: String, + val action: SnackbarAction? = null, +) + +data class SnackbarAction( + val name: String, + val action: suspend () -> Unit, +) + +object SnackbarController { + + private val _events = Channel() + val events = _events.receiveAsFlow() + + suspend fun sendEvent(event: SnackbarEvent) { + _events.send(event) + } +} diff --git a/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/ui/states/DataSetModalDialogUIState.kt b/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/ui/states/DataSetModalDialogUIState.kt new file mode 100644 index 0000000000..74bd720a8d --- /dev/null +++ b/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/ui/states/DataSetModalDialogUIState.kt @@ -0,0 +1,20 @@ +package org.dhis2.mobile.aggregates.ui.states + +import org.dhis2.mobile.aggregates.model.Violation +import org.hisp.dhis.mobile.ui.designsystem.component.state.BottomSheetShellUIState + +internal data class DataSetModalDialogUIState( + val contentDialogUIState: BottomSheetShellUIState, + val onDismiss: () -> Unit, + val onPrimaryButtonClick: () -> Unit, + val onSecondaryButtonClick: () -> Unit = {}, + val type: DataSetModalType, + val violations: List? = null, +) + +internal enum class DataSetModalType { + COMPLETION, + MANDATORY_FIELDS, + VALIDATION_RULES, + VALIDATION_RULES_ERROR, +} diff --git a/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/ui/states/DataSetScreenState.kt b/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/ui/states/DataSetScreenState.kt index 2076bbf02e..ce0966fb9c 100644 --- a/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/ui/states/DataSetScreenState.kt +++ b/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/ui/states/DataSetScreenState.kt @@ -6,12 +6,15 @@ import org.dhis2.mobile.aggregates.model.DataSetSection import org.hisp.dhis.mobile.ui.designsystem.component.table.model.TableModel internal sealed class DataSetScreenState { + data class Loaded( val dataSetDetails: DataSetDetails, val dataSetSections: List, val renderingConfig: DataSetRenderingConfig, val dataSetSectionTable: DataSetSectionTable, val selectedCellInfo: InputData? = null, + val modalDialog: DataSetModalDialogUIState? = null, + val validationBar: ValidationBarUiState? = null, ) : DataSetScreenState() { override fun allowTwoPane(canUseTwoPane: Boolean) = dataSetSections.isNotEmpty() && canUseTwoPane && renderingConfig.useVerticalTabs diff --git a/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/ui/states/ValidationBarUiState.kt b/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/ui/states/ValidationBarUiState.kt new file mode 100644 index 0000000000..076f997f26 --- /dev/null +++ b/aggregates/src/commonMain/kotlin/org/dhis2/mobile/aggregates/ui/states/ValidationBarUiState.kt @@ -0,0 +1,7 @@ +package org.dhis2.mobile.aggregates.ui.states + +internal data class ValidationBarUiState( + val quantity: Int, + val description: String, + val onExpandErrors: () -> Unit, +) 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 1de0347adb..97e0786eac 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 @@ -11,13 +11,28 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.supervisorScope +import kotlinx.coroutines.withContext +import org.dhis2.mobile.aggregates.domain.CheckCompletionStatus +import org.dhis2.mobile.aggregates.domain.CheckValidationRulesConfiguration +import org.dhis2.mobile.aggregates.domain.CompleteDataSet import org.dhis2.mobile.aggregates.domain.GetDataSetInstanceData import org.dhis2.mobile.aggregates.domain.GetDataSetSectionData import org.dhis2.mobile.aggregates.domain.GetDataSetSectionIndicators import org.dhis2.mobile.aggregates.domain.GetDataValueData import org.dhis2.mobile.aggregates.domain.GetDataValueInput -import org.dhis2.mobile.aggregates.domain.ResourceManager +import org.dhis2.mobile.aggregates.domain.RunValidationRules import org.dhis2.mobile.aggregates.domain.SetDataValue +import org.dhis2.mobile.aggregates.model.DataSetCompletionStatus.COMPLETED +import org.dhis2.mobile.aggregates.model.DataSetCompletionStatus.NOT_COMPLETED +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.ValidationResultStatus +import org.dhis2.mobile.aggregates.model.ValidationRulesConfiguration.MANDATORY +import org.dhis2.mobile.aggregates.model.ValidationRulesConfiguration.NONE +import org.dhis2.mobile.aggregates.model.ValidationRulesConfiguration.OPTIONAL +import org.dhis2.mobile.aggregates.model.Violation import org.dhis2.mobile.aggregates.model.mapper.toInputData import org.dhis2.mobile.aggregates.model.mapper.toTableModel import org.dhis2.mobile.aggregates.model.mapper.updateValue @@ -26,11 +41,17 @@ 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.UiAction +import org.dhis2.mobile.aggregates.ui.provider.DataSetModalDialogProvider +import org.dhis2.mobile.aggregates.ui.provider.ResourceManager +import org.dhis2.mobile.aggregates.ui.snackbar.SnackbarController +import org.dhis2.mobile.aggregates.ui.snackbar.SnackbarEvent import org.dhis2.mobile.aggregates.ui.states.DataSetScreenState import org.dhis2.mobile.aggregates.ui.states.DataSetSectionTable +import org.dhis2.mobile.aggregates.ui.states.ValidationBarUiState import org.hisp.dhis.mobile.ui.designsystem.component.table.model.TableModel internal class DataSetTableViewModel( + private val onClose: () -> Unit, private val getDataSetInstanceData: GetDataSetInstanceData, private val getDataSetSectionData: GetDataSetSectionData, private val getDataValueData: GetDataValueData, @@ -38,7 +59,12 @@ internal class DataSetTableViewModel( private val getDataValueInput: GetDataValueInput, private val setDataValue: SetDataValue, private val resourceManager: ResourceManager, + private val checkValidationRulesConfiguration: CheckValidationRulesConfiguration, + private val checkCompletionStatus: CheckCompletionStatus, private val dispatcher: Dispatcher, + private val datasetModalDialogProvider: DataSetModalDialogProvider, + private val completeDataSet: CompleteDataSet, + private val runValidationRules: RunValidationRules, ) : ViewModel() { private val _dataSetScreenState = @@ -212,4 +238,197 @@ internal class DataSetTableViewModel( } } } + + fun onSaveClicked() { + viewModelScope.launch(dispatcher.io()) { + when (checkValidationRulesConfiguration()) { + NONE -> { + attemptToFinnish() + } + + MANDATORY -> { + checkValidationRules() + } + + OPTIONAL -> { + askRunValidationRules() + } + } + } + } + + private fun askRunValidationRules() { + viewModelScope.launch(dispatcher.io()) { + _dataSetScreenState.update { + if (it is DataSetScreenState.Loaded) { + it.copy( + modalDialog = datasetModalDialogProvider.provideAskRunValidationsDialog( + onDismiss = { onModalDialogDismissed() }, + onDeny = { attemptToComplete() }, + onAccept = { checkValidationRules() }, + ), + ) + } else { + it + } + } + } + } + + private fun checkValidationRules() { + viewModelScope.launch(dispatcher.io()) { + val rules = runValidationRules() + when (rules.validationResultStatus) { + ValidationResultStatus.OK -> { + _dataSetScreenState.update { + if (it is DataSetScreenState.Loaded) { + it.copy(validationBar = null) + } else { + it + } + } + attemptToFinnish() + } + + ValidationResultStatus.ERROR -> { + onModalDialogDismissed() + _dataSetScreenState.update { + if (it is DataSetScreenState.Loaded) { + it.copy( + validationBar = ValidationBarUiState( + quantity = rules.violations.size, + description = resourceManager.provideValidationErrorDescription( + errors = rules.violations.size, + ), + onExpandErrors = { expandValidationErrors(rules.violations) }, + ), + ) + } else { + it + } + } + } + } + } + } + + private fun expandValidationErrors(violations: List) { + viewModelScope.launch(dispatcher.main()) { + _dataSetScreenState.update { + if (it is DataSetScreenState.Loaded) { + it.copy( + modalDialog = datasetModalDialogProvider.provideValidationRulesErrorDialog( + violations = violations, + onDismiss = { onModalDialogDismissed() }, + onMarkAsComplete = { attemptToComplete() }, + ), + ) + } else { + it + } + } + } + } + + private fun attemptToFinnish() { + viewModelScope.launch(dispatcher.io()) { + val onSavedMessage = resourceManager.provideSaved() + + when (checkCompletionStatus()) { + COMPLETED -> onExit(onSavedMessage) + NOT_COMPLETED -> { + _dataSetScreenState.update { + if (it is DataSetScreenState.Loaded) { + it.copy( + modalDialog = datasetModalDialogProvider.provideCompletionDialog( + onDismiss = { onModalDialogDismissed() }, + onNotNow = { onExit(onSavedMessage) }, + onComplete = { attemptToComplete() }, + ), + ) + } else { + it + } + } + } + } + } + } + + private fun attemptToComplete() { + viewModelScope.launch(dispatcher.io()) { + when (completeDataSet()) { + MISSING_MANDATORY_FIELDS -> { + _dataSetScreenState.update { + if (it is DataSetScreenState.Loaded) { + it.copy( + modalDialog = datasetModalDialogProvider.provideMandatoryFieldsDialog( + mandatoryFieldsMessage = resourceManager.provideMandatoryFieldsMessage(), + onDismiss = { onModalDialogDismissed() }, + onAccept = { onModalDialogDismissed() }, + ), + ) + } else { + it + } + } + } + + MISSING_MANDATORY_FIELDS_COMBINATION -> { + _dataSetScreenState.update { + if (it is DataSetScreenState.Loaded) { + it.copy( + modalDialog = datasetModalDialogProvider.provideMandatoryFieldsDialog( + mandatoryFieldsMessage = resourceManager.provideMandatoryFieldsCombinationMessage(), + onDismiss = { onModalDialogDismissed() }, + onAccept = { onModalDialogDismissed() }, + ), + ) + } else { + it + } + } + } + + SUCCESS -> { + onModalDialogDismissed() + onExit(resourceManager.provideSavedAndCompleted()) + } + + ERROR -> { + onModalDialogDismissed() + showSnackbar(resourceManager.provideErrorOnCompleteDataset()) + } + } + } + } + + private fun onExit(exitMessage: String) { + showSnackbar(exitMessage) + viewModelScope.launch { + withContext(dispatcher.main()) { + onClose() + } + } + } + + private fun onModalDialogDismissed() { + _dataSetScreenState.update { + if (it is DataSetScreenState.Loaded) { + it.copy(modalDialog = null) + } else { + it + } + } + } + + private fun showSnackbar(message: String) { + viewModelScope.launch { + SnackbarController.sendEvent( + event = SnackbarEvent( + message = message, + ), + ) + } + } } diff --git a/aggregates/src/commonTest/kotlin/org/dhis2/mobile/aggregates/domain/CheckCompletionStatusTest.kt b/aggregates/src/commonTest/kotlin/org/dhis2/mobile/aggregates/domain/CheckCompletionStatusTest.kt new file mode 100644 index 0000000000..2f2a493693 --- /dev/null +++ b/aggregates/src/commonTest/kotlin/org/dhis2/mobile/aggregates/domain/CheckCompletionStatusTest.kt @@ -0,0 +1,72 @@ +package org.dhis2.mobile.aggregates.domain + +import kotlinx.coroutines.test.runTest +import org.dhis2.mobile.aggregates.data.DataSetInstanceRepository +import org.dhis2.mobile.aggregates.model.DataSetCompletionStatus.COMPLETED +import org.dhis2.mobile.aggregates.model.DataSetCompletionStatus.NOT_COMPLETED +import org.junit.Before +import org.junit.Test +import org.mockito.Mockito.mock +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.whenever +import kotlin.test.assertEquals + +class CheckCompletionStatusTest { + + private val dataSetInstanceRepository: DataSetInstanceRepository = mock() + + private val dataSetUid = "dataSetUid" + private val periodId = "periodId" + private val orgUnitUid = "orgUnitUid" + private val attrOptionComboUid = "attrOptionComboUid" + internal lateinit var checkCompletionStatus: CheckCompletionStatus + + @Before + fun setUp() { + checkCompletionStatus = CheckCompletionStatus( + dataSetUid, + periodId, + orgUnitUid, + attrOptionComboUid, + dataSetInstanceRepository, + ) + } + + @Test + fun `should return dataset instance is completed`() = runTest { + // Given dataset instance is completed + whenever( + dataSetInstanceRepository.isComplete( + dataSetUid = dataSetUid, + periodId = periodId, + orgUnitUid = orgUnitUid, + attrOptionComboUid = attrOptionComboUid, + ), + ) doReturn true + + // When user check if dataset is completed + val result = checkCompletionStatus() + + // Then return completed + assertEquals(COMPLETED, result) + } + + @Test + fun `should return dataset instance is not completed`() = runTest { + // Given dataset instance is not completed + whenever( + dataSetInstanceRepository.isComplete( + dataSetUid = dataSetUid, + periodId = periodId, + orgUnitUid = orgUnitUid, + attrOptionComboUid = attrOptionComboUid, + ), + ) doReturn false + + // When user check if dataset is completed + val result = checkCompletionStatus() + + // Then return completed + assertEquals(NOT_COMPLETED, result) + } +} diff --git a/aggregates/src/commonTest/kotlin/org/dhis2/mobile/aggregates/domain/CheckValidationRulesConfigurationTest.kt b/aggregates/src/commonTest/kotlin/org/dhis2/mobile/aggregates/domain/CheckValidationRulesConfigurationTest.kt new file mode 100644 index 0000000000..918174b4d9 --- /dev/null +++ b/aggregates/src/commonTest/kotlin/org/dhis2/mobile/aggregates/domain/CheckValidationRulesConfigurationTest.kt @@ -0,0 +1,63 @@ +package org.dhis2.mobile.aggregates.domain + +import kotlinx.coroutines.test.runTest +import org.dhis2.mobile.aggregates.data.DataSetInstanceRepository +import org.dhis2.mobile.aggregates.model.ValidationRulesConfiguration +import org.junit.Assert.assertEquals +import org.junit.Before +import org.mockito.Mockito.mock +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.whenever +import kotlin.test.Test + +class CheckValidationRulesConfigurationTest { + + private val dataSetInstanceRepository: DataSetInstanceRepository = mock() + + private val dataSetUid = "dataSetUid" + internal lateinit var checkValidationRulesConfiguration: CheckValidationRulesConfiguration + + @Before + fun setUp() { + checkValidationRulesConfiguration = + CheckValidationRulesConfiguration(dataSetUid, dataSetInstanceRepository) + } + + @Test + fun `should return NONE when there are no validation rules`() = runTest { + // Given dataset has no validation rules + whenever(dataSetInstanceRepository.checkIfHasValidationRules(dataSetUid)) doReturn false + + // When checking validation rules + val result = checkValidationRulesConfiguration() + + // Then return none + assertEquals(ValidationRulesConfiguration.NONE, result) + } + + @Test + fun `should return Mandatory when validation rules are mandatory`() = runTest { + // Given dataset has mandatory validation rules + whenever(dataSetInstanceRepository.checkIfHasValidationRules(dataSetUid)) doReturn true + whenever(dataSetInstanceRepository.areValidationRulesMandatory(dataSetUid)) doReturn true + + // When checking validation rules + val result = checkValidationRulesConfiguration() + + // Then return MANDATORY + assertEquals(ValidationRulesConfiguration.MANDATORY, result) + } + + @Test + fun `should return Optional when validation rules are optional`() = runTest { + // Given dataset has optional validation rules + whenever(dataSetInstanceRepository.checkIfHasValidationRules(dataSetUid)) doReturn true + whenever(dataSetInstanceRepository.areValidationRulesMandatory(dataSetUid)) doReturn false + + // When checking validation rules + val result = checkValidationRulesConfiguration() + + // Then return OPTIONAL + assertEquals(ValidationRulesConfiguration.OPTIONAL, result) + } +} diff --git a/aggregates/src/commonTest/kotlin/org/dhis2/mobile/aggregates/domain/CompleteDataSetTest.kt b/aggregates/src/commonTest/kotlin/org/dhis2/mobile/aggregates/domain/CompleteDataSetTest.kt new file mode 100644 index 0000000000..a3050778b2 --- /dev/null +++ b/aggregates/src/commonTest/kotlin/org/dhis2/mobile/aggregates/domain/CompleteDataSetTest.kt @@ -0,0 +1,159 @@ +package org.dhis2.mobile.aggregates.domain + +import kotlinx.coroutines.test.runTest +import org.dhis2.mobile.aggregates.data.DataSetInstanceRepository +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.junit.Assert.assertEquals +import org.junit.Before +import org.mockito.Mockito.mock +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.whenever +import kotlin.test.Test + +class CompleteDataSetTest { + + private val dataSetInstanceRepository: DataSetInstanceRepository = mock() + + private val dataSetUid = "dataSetUid" + private val periodId = "periodId" + private val orgUnitUid = "orgUnitUid" + private val attrOptionComboUid = "attrOptionComboUid" + internal lateinit var completeDataSet: CompleteDataSet + + @Before + fun setUp() { + completeDataSet = CompleteDataSet( + dataSetUid, + periodId, + orgUnitUid, + attrOptionComboUid, + dataSetInstanceRepository, + ) + } + + @Test + fun `should return success when everything is right`() = runTest { + // Given dataset instance has no missing fields + whenever( + dataSetInstanceRepository.checkIfHasMissingMandatoryFields( + dataSetUid, + periodId, + orgUnitUid, + attrOptionComboUid, + ), + ) doReturn false + + whenever( + dataSetInstanceRepository.checkIfHasMissingMandatoryFieldsCombination( + dataSetUid, + periodId, + orgUnitUid, + attrOptionComboUid, + ), + ) doReturn false + + // And can be completed + whenever( + dataSetInstanceRepository.completeDataset( + dataSetUid, + periodId, + orgUnitUid, + attrOptionComboUid, + ), + ) doReturn Result.success(Unit) + + // When user tries to complete the dataset + val result = completeDataSet() + + // Then completion is successful + assertEquals(SUCCESS, result) + } + + @Test + fun `should return error when there is any unhandled error`() = runTest { + // Given dataset instance has no missing fields + whenever( + dataSetInstanceRepository.checkIfHasMissingMandatoryFields( + dataSetUid, + periodId, + orgUnitUid, + attrOptionComboUid, + ), + ) doReturn false + + whenever( + dataSetInstanceRepository.checkIfHasMissingMandatoryFieldsCombination( + dataSetUid, + periodId, + orgUnitUid, + attrOptionComboUid, + ), + ) doReturn false + + // And has an unknown error + whenever( + dataSetInstanceRepository.completeDataset( + dataSetUid, + periodId, + orgUnitUid, + attrOptionComboUid, + ), + ) doReturn Result.failure(Throwable()) + + // When user tries to complete the dataset + val result = completeDataSet() + + // Then completion fails + assertEquals(ERROR, result) + } + + @Test + fun `should return missing mandatory fields information`() = runTest { + // Given dataset instance has missing mandatory fields + whenever( + dataSetInstanceRepository.checkIfHasMissingMandatoryFields( + dataSetUid, + periodId, + orgUnitUid, + attrOptionComboUid, + ), + ) doReturn true + + // When user tries to complete the dataset + val result = completeDataSet() + + // Then completion fails by missing mandatory fields + assertEquals(MISSING_MANDATORY_FIELDS, result) + } + + @Test + fun `should return missing mandatory fields combination information`() = runTest { + // Given dataset instance has missing fields combination + whenever( + dataSetInstanceRepository.checkIfHasMissingMandatoryFields( + dataSetUid, + periodId, + orgUnitUid, + attrOptionComboUid, + ), + ) doReturn false + + whenever( + dataSetInstanceRepository.checkIfHasMissingMandatoryFieldsCombination( + dataSetUid, + periodId, + orgUnitUid, + attrOptionComboUid, + ), + ) doReturn true + + // When user tries to complete the dataset + val result = completeDataSet() + + // Then completion fails by missing fields combination + assertEquals(MISSING_MANDATORY_FIELDS_COMBINATION, result) + } +} 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 fad55e91bf..491d3b5bd6 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 @@ -8,19 +8,25 @@ import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.TestDispatcher import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain import org.dhis2.mobile.aggregates.data.DataSetInstanceRepository import org.dhis2.mobile.aggregates.di.aggregatesModule +import org.dhis2.mobile.aggregates.domain.CheckCompletionStatus +import org.dhis2.mobile.aggregates.domain.CheckValidationRulesConfiguration +import org.dhis2.mobile.aggregates.domain.CompleteDataSet import org.dhis2.mobile.aggregates.domain.GetDataSetInstanceData import org.dhis2.mobile.aggregates.domain.GetDataSetSectionData import org.dhis2.mobile.aggregates.domain.GetDataSetSectionIndicators import org.dhis2.mobile.aggregates.domain.GetDataValueData import org.dhis2.mobile.aggregates.domain.GetDataValueInput -import org.dhis2.mobile.aggregates.domain.ResourceManager +import org.dhis2.mobile.aggregates.domain.RunValidationRules import org.dhis2.mobile.aggregates.domain.SetDataValue import org.dhis2.mobile.aggregates.model.CellElement import org.dhis2.mobile.aggregates.model.CellInfo +import org.dhis2.mobile.aggregates.model.DataSetCompletionStatus.COMPLETED +import org.dhis2.mobile.aggregates.model.DataSetCompletionStatus.NOT_COMPLETED import org.dhis2.mobile.aggregates.model.DataSetDetails import org.dhis2.mobile.aggregates.model.DataSetInstanceConfiguration import org.dhis2.mobile.aggregates.model.DataSetInstanceData @@ -30,10 +36,18 @@ import org.dhis2.mobile.aggregates.model.DataSetRenderingConfig import org.dhis2.mobile.aggregates.model.DataSetSection import org.dhis2.mobile.aggregates.model.InputType import org.dhis2.mobile.aggregates.model.TableGroup +import org.dhis2.mobile.aggregates.model.ValidationResultStatus +import org.dhis2.mobile.aggregates.model.ValidationRulesConfiguration.MANDATORY +import org.dhis2.mobile.aggregates.model.ValidationRulesConfiguration.NONE +import org.dhis2.mobile.aggregates.model.ValidationRulesConfiguration.OPTIONAL +import org.dhis2.mobile.aggregates.model.ValidationRulesResult 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.provider.DataSetModalDialogProvider +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 @@ -49,8 +63,10 @@ import org.koin.test.mock.declareMock import org.mockito.Mockito.mock import org.mockito.kotlin.any import org.mockito.kotlin.doReturn +import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import kotlin.test.Test +import kotlin.test.assertEquals @OptIn(ExperimentalCoroutinesApi::class) internal class DataSetTableViewModelTest : KoinTest { @@ -65,6 +81,15 @@ internal class DataSetTableViewModelTest : KoinTest { private lateinit var dispatcher: Dispatcher private lateinit var testDispatcher: TestDispatcher + private lateinit var checkValidationRulesConfiguration: CheckValidationRulesConfiguration + private lateinit var checkCompletionStatus: CheckCompletionStatus + private lateinit var dataSetModalDialogProvider: DataSetModalDialogProvider + private lateinit var completeDataSet: CompleteDataSet + private lateinit var runValidationRules: RunValidationRules + + private val onCloseCallback: () -> Unit = mock() + private val modalDialog: DataSetModalDialogUIState = mock() + private lateinit var viewModel: DataSetTableViewModel @Before @@ -88,10 +113,17 @@ internal class DataSetTableViewModelTest : KoinTest { declareMock() { whenever(runBlocking { defaultHeaderLabel() }) doReturn "HeaderLabel" whenever(runBlocking { totalsHeader() }) doReturn "TotalsHeader" + whenever(runBlocking { provideSaved() }) doReturn "saved" } dispatcher = declareMock() + checkValidationRulesConfiguration = declareMock() + checkCompletionStatus = declareMock() + dataSetModalDialogProvider = declareMock() + completeDataSet = declareMock() + runValidationRules = declareMock() whenever(dispatcher.io).thenReturn { testDispatcher } + whenever(dispatcher.main).thenReturn { testDispatcher } whenever(getDataSetInstanceData(any())).thenReturn( DataSetInstanceData( dataSetDetails = DataSetDetails( @@ -150,6 +182,7 @@ internal class DataSetTableViewModelTest : KoinTest { whenever(getIndicators(any())).thenReturn(null) viewModel = DataSetTableViewModel( + onClose = onCloseCallback, getDataSetInstanceData = get(), getDataSetSectionData = get(), getDataValueData = get(), @@ -157,7 +190,12 @@ internal class DataSetTableViewModelTest : KoinTest { getDataValueInput = get(), setDataValue = get(), resourceManager = get(), + checkValidationRulesConfiguration = get(), + checkCompletionStatus = get(), dispatcher = get(), + datasetModalDialogProvider = get(), + completeDataSet = get(), + runValidationRules = get(), ) } @@ -239,6 +277,106 @@ internal class DataSetTableViewModelTest : KoinTest { } } + @Test + fun `should finish a completed data set without validation rules`() = runTest { + // Given there are no validation rules + whenever(checkValidationRulesConfiguration()) doReturn NONE + // And data set instance is completed + whenever(checkCompletionStatus()) doReturn COMPLETED + + // When attempt to save + viewModel.onSaveClicked() + + // Then data set instance is closed + runCurrent() // Advance coroutine execution + verify(onCloseCallback).invoke() + } + + @Test + fun `should show complete dialog when no validation rules and uncompleted`() = runTest { + // Given there are no validation rules + whenever(checkValidationRulesConfiguration()) doReturn NONE + // And data set is not completed + whenever(checkCompletionStatus()) doReturn NOT_COMPLETED + + whenever( + dataSetModalDialogProvider.provideCompletionDialog( + any(), any(), any(), + ), + ) doReturn modalDialog + + viewModel.dataSetScreenState.test { + awaitInitialization() + + // When attempt to save + viewModel.onSaveClicked() + + // Then shows completion dialog + with(awaitItem()) { + assertTrue(this is DataSetScreenState.Loaded) + assertEquals(modalDialog, (this as DataSetScreenState.Loaded).modalDialog) + } + } + } + + @Test + fun `should ask to complete when running mandatory validation rules successfully`() = runTest { + // Given there are mandatory validation rules + whenever(checkValidationRulesConfiguration()) doReturn MANDATORY + // And validation rules execution is OK + val validationRulesResult = ValidationRulesResult( + validationResultStatus = ValidationResultStatus.OK, + violations = emptyList(), + ) + whenever(runValidationRules()) doReturn validationRulesResult + // And data set is not completed + whenever(checkCompletionStatus()) doReturn NOT_COMPLETED + + whenever( + dataSetModalDialogProvider.provideCompletionDialog( + any(), any(), any(), + ), + ) doReturn modalDialog + + viewModel.dataSetScreenState.test { + awaitInitialization() + + // When attempt to save + viewModel.onSaveClicked() + + // Then shows completion dialog + with(awaitItem()) { + assertTrue(this is DataSetScreenState.Loaded) + assertEquals(modalDialog, (this as DataSetScreenState.Loaded).modalDialog) + } + } + } + + @Test + fun `should ask to run optional validation rules`() = runTest { + // Given there are optional validation rules + whenever(checkValidationRulesConfiguration()) doReturn OPTIONAL + + whenever( + dataSetModalDialogProvider.provideAskRunValidationsDialog( + any(), any(), any(), + ), + ) doReturn modalDialog + + viewModel.dataSetScreenState.test { + awaitInitialization() + + // When attempt to save + viewModel.onSaveClicked() + + // Then shows optional validation rules dialog + with(awaitItem()) { + assertTrue(this is DataSetScreenState.Loaded) + assertEquals(modalDialog, (this as DataSetScreenState.Loaded).modalDialog) + } + } + } + private suspend fun ReceiveTurbine.awaitInitialization() = with(this) { awaitItem() awaitItem() diff --git a/app/src/main/java/org/dhis2/usescases/datasets/dataSetTable/DataSetInstanceActivity.kt b/app/src/main/java/org/dhis2/usescases/datasets/dataSetTable/DataSetInstanceActivity.kt index 57d1b52fa7..8669a02416 100644 --- a/app/src/main/java/org/dhis2/usescases/datasets/dataSetTable/DataSetInstanceActivity.kt +++ b/app/src/main/java/org/dhis2/usescases/datasets/dataSetTable/DataSetInstanceActivity.kt @@ -2,7 +2,6 @@ package org.dhis2.usescases.datasets.dataSetTable import android.os.Bundle import androidx.activity.compose.setContent -import androidx.activity.enableEdgeToEdge import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass @@ -18,7 +17,7 @@ class DataSetInstanceActivity : ActivityGlobalAbstract() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - enableEdgeToEdge() +// enableEdgeToEdge() setContent { DHIS2Theme { val useTwoPane = when (calculateWindowSizeClass(this).widthSizeClass) { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index db44f12541..5c675207e4 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -59,7 +59,6 @@ root = "0.0.7" openid = "0.8.1" conscrypt = "2.5.2" gson = "2.9.0" -#gsonconverter = "2.9.0" okhttp = "4.9.3" jodatime = "2.10.5" glide = "4.9.0"