From f497e44b980e4585263bb0e97a62233e7c368e6b Mon Sep 17 00:00:00 2001 From: Alex Date: Wed, 15 Jul 2026 09:57:48 +0100 Subject: [PATCH 01/13] Simprints RAMP: Org unit selector crash fixed in filtering options --- app/src/main/java/org/dhis2/usescases/main/MainActivity.kt | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/src/main/java/org/dhis2/usescases/main/MainActivity.kt b/app/src/main/java/org/dhis2/usescases/main/MainActivity.kt index 988e45bba5..86909dc5b0 100644 --- a/app/src/main/java/org/dhis2/usescases/main/MainActivity.kt +++ b/app/src/main/java/org/dhis2/usescases/main/MainActivity.kt @@ -315,6 +315,8 @@ class MainActivity : ActivityGlobalAbstract() { } private fun showPeriodRequest(periodRequest: FilterManager.PeriodRequest) { + if (supportFragmentManager.isStateSaved) return + if (periodRequest == FilterManager.PeriodRequest.FROM_TO) { FilterPeriodsDialog .newPeriodsFilter(filterType = Filters.PERIOD, isFromToFilter = true) @@ -327,6 +329,8 @@ class MainActivity : ActivityGlobalAbstract() { } private fun openOrgUnitTreeSelector() { + if (supportFragmentManager.isStateSaved) return + OUTreeFragment .Builder() .withPreselectedOrgUnits( From f88d26b5db64247f1bdb2e4c16141c26b999b873 Mon Sep 17 00:00:00 2001 From: Alex Date: Wed, 15 Jul 2026 10:14:17 +0100 Subject: [PATCH 02/13] Simprints RAMP: User's org unit selection button in filter options --- .../org/dhis2/commons/filters/FilterItem.kt | 14 +++++++++++++ .../commons/filters/data/FilterRepository.kt | 11 ++++++++++ .../src/main/res/drawable/ic_i_user.xml | 0 .../main/res/layout/item_header_filter.xml | 20 +++++++++++++++++-- commons/src/main/res/values/strings.xml | 1 + 5 files changed, 44 insertions(+), 2 deletions(-) rename {app => commons}/src/main/res/drawable/ic_i_user.xml (100%) diff --git a/commons/src/main/java/org/dhis2/commons/filters/FilterItem.kt b/commons/src/main/java/org/dhis2/commons/filters/FilterItem.kt index 9fdf5b94c3..53fed60a05 100644 --- a/commons/src/main/java/org/dhis2/commons/filters/FilterItem.kt +++ b/commons/src/main/java/org/dhis2/commons/filters/FilterItem.kt @@ -31,6 +31,18 @@ sealed class FilterItem( fun showSorting(): Boolean = Sorting.getSortingOptions(programType).any { it == type } + fun showUserOrgUnitAction(): Boolean = + (this as? OrgUnitFilter)?.showUserOrgUnitAction == true + + fun onUserOrgUnitClick() { + val filterManager = FilterManager.getInstance() + if (!filterManager.isFilterActiveForWorkingList(type)) { + (this as? OrgUnitFilter)?.userOrgUnit?.let { + filterManager.addOrgUnits(listOf(it)) + } + } + } + fun observeCount(): ObservableField = FilterManager.getInstance().observeField(type) fun getFilterValue(defaultValue: String): String = FilterManager.getInstance().getFilterStringValue(type, defaultValue) @@ -158,6 +170,8 @@ data class OrgUnitFilter( override val sortingItem: ObservableField, override val openFilter: ObservableField, override val filterLabel: String, + val userOrgUnit: OrganisationUnit? = null, + val showUserOrgUnitAction: Boolean = userOrgUnit != null, ) : FilterItem(Filters.ORG_UNIT, programType, sortingItem, openFilter, filterLabel) { override fun icon(): Int = R.drawable.ic_filter_ou } diff --git a/commons/src/main/java/org/dhis2/commons/filters/data/FilterRepository.kt b/commons/src/main/java/org/dhis2/commons/filters/data/FilterRepository.kt index 7387f2b32b..41da40080c 100644 --- a/commons/src/main/java/org/dhis2/commons/filters/data/FilterRepository.kt +++ b/commons/src/main/java/org/dhis2/commons/filters/data/FilterRepository.kt @@ -62,6 +62,15 @@ constructor( .byOrganisationUnitScope(OrganisationUnit.Scope.SCOPE_TEI_SEARCH) .blockingCount() + private fun singleCaptureOrgUnit(programUid: String): OrganisationUnit? = + d2 + .organisationUnitModule() + .organisationUnits() + .byOrganisationUnitScope(OrganisationUnit.Scope.SCOPE_DATA_CAPTURE) + .byProgramUids(listOf(programUid)) + .blockingGet() + .singleOrNull() + fun trackedEntityInstanceQueryByProgram(programUid: String): TrackedEntitySearchCollectionRepository = d2 .trackedEntityModule() @@ -567,6 +576,8 @@ constructor( observableSortingInject, observableOpenFilter, resources.filterOrgUnitLabel(), + userOrgUnit = singleCaptureOrgUnit(program.uid()), + showUserOrgUnitAction = true, ) defaultTrackerFilters[ProgramFilter.SYNC_STATUS] = SyncStateFilter( diff --git a/app/src/main/res/drawable/ic_i_user.xml b/commons/src/main/res/drawable/ic_i_user.xml similarity index 100% rename from app/src/main/res/drawable/ic_i_user.xml rename to commons/src/main/res/drawable/ic_i_user.xml diff --git a/commons/src/main/res/layout/item_header_filter.xml b/commons/src/main/res/layout/item_header_filter.xml index 45f259dfdb..f5fc490fde 100644 --- a/commons/src/main/res/layout/item_header_filter.xml +++ b/commons/src/main/res/layout/item_header_filter.xml @@ -29,7 +29,7 @@ android:onClick="@{()->filterItem.onClick()}" android:paddingStart="16dp" android:paddingEnd="16dp" - app:layout_constraintEnd_toStartOf="@id/sortingIcon" + app:layout_constraintEnd_toStartOf="@id/userOrgUnitIcon" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent"> @@ -109,6 +109,22 @@ + + diff --git a/commons/src/main/res/values/strings.xml b/commons/src/main/res/values/strings.xml index 9d34e6f518..8576d77ed4 100644 --- a/commons/src/main/res/values/strings.xml +++ b/commons/src/main/res/values/strings.xml @@ -42,6 +42,7 @@ @string/enrollment_status Followed up %s Select period type + Select my organization unit Weekly Bi-weekly Weekly starting Wednesday From cf0e2105550c62dac8fa1282e01447ae044c65cd Mon Sep 17 00:00:00 2001 From: Alex Date: Wed, 15 Jul 2026 12:33:56 +0100 Subject: [PATCH 03/13] Simprints RAMP: "Transferred to" in bold + icon in TEI list, its filter, & details screen --- .../SearchRepositoryImpl.java | 1 + .../SearchRepositoryImplKt.kt | 6 +- .../searchTrackEntity/SearchTEActivity.kt | 1 + .../searchTrackEntity/SearchTEIViewModel.kt | 2 +- .../ui/mapper/TEICardMapper.kt | 19 ++- .../teiDashboard/DashboardRepositoryImpl.kt | 5 +- .../teidata/TeiDataRepositoryImpl.kt | 3 +- .../teiDashboard/ui/TeiDetailDashboard.kt | 20 ++- .../ui/mapper/TeiDashboardCardMapper.kt | 22 ++- .../teiDashboard/ui/model/TeiCardUiModel.kt | 1 + app/src/main/res/values/strings.xml | 1 + .../dhis2/data/filter/FilterRepositoryTest.kt | 7 +- .../ui/mapper/TEICardMapperTest.kt | 28 +++- .../DashboardRepositoryImplTest.kt | 51 +++++++ .../ui/mapper/TEIDetailMapperTest.kt | 12 +- .../dhis2/utils/filters/FilterManagerTest.kt | 14 ++ .../org/dhis2/commons/filters/FilterItem.kt | 17 ++- .../dhis2/commons/filters/FilterManager.java | 39 +++++- .../dhis2/commons/filters/FilterResources.kt | 2 + .../java/org/dhis2/commons/filters/Filters.kt | 1 + .../commons/filters/data/FilterPresenter.kt | 3 + .../commons/filters/data/FilterRepository.kt | 39 ++++++ .../filters/data/TrackerFilterSearchHelper.kt | 8 +- .../filters/data/TransferredPatientFilter.kt | 30 ++++ .../org/dhis2/commons/ui/ListCardProvider.kt | 130 +++++++++++++++++- .../dhis2/commons/ui/model/ListCardUiModel.kt | 1 + .../src/main/res/drawable/ic_transfer.xml | 0 .../res/layout/item_filter_transferred.xml | 37 +++++ commons/src/main/res/values/strings.xml | 1 + .../data/TrackerFilterSearchHelperTest.kt | 11 ++ .../data/TransferredPatientFilterTest.kt | 87 ++++++++++++ .../SearchTrackedEntityRepositoryImplTest.kt | 18 +++ .../data/SearchTrackedEntityRepositoryImpl.kt | 10 +- 33 files changed, 605 insertions(+), 22 deletions(-) create mode 100644 commons/src/main/java/org/dhis2/commons/filters/data/TransferredPatientFilter.kt rename {app => commons}/src/main/res/drawable/ic_transfer.xml (100%) create mode 100644 commons/src/main/res/layout/item_filter_transferred.xml create mode 100644 commons/src/test/kotlin/org/dhis2/commons/filters/data/TransferredPatientFilterTest.kt diff --git a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchRepositoryImpl.java b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchRepositoryImpl.java index c5135a2061..3747fd0b1b 100644 --- a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchRepositoryImpl.java +++ b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchRepositoryImpl.java @@ -336,6 +336,7 @@ public List trackedEntityTypeFields() { @Override public boolean filtersApplyOnGlobalSearch() { + if (FilterManager.getInstance().getTransferredFilter()) return false; return FilterManager.getInstance().getTotalFilters() == 0 || !FilterManager.getInstance().getOrgUnitFilters().isEmpty() || !FilterManager.getInstance().getStateFilters().isEmpty(); diff --git a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchRepositoryImplKt.kt b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchRepositoryImplKt.kt index ebe848c574..3b720c7656 100644 --- a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchRepositoryImplKt.kt +++ b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchRepositoryImplKt.kt @@ -87,7 +87,11 @@ class SearchRepositoryImplKt( allowCache = true } - return if (isOnline && FilterManager.getInstance().stateFilters.isEmpty()) { + return if ( + isOnline && + FilterManager.getInstance().stateFilters.isEmpty() && + !(FilterManager.getInstance().transferredFilter && searchParametersModel.selectedProgram != null) + ) { trackedEntityInstanceQuery .allowOnlineCache() .eq(allowCache) diff --git a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEActivity.kt b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEActivity.kt index 59e76b4b04..b45a90d84a 100644 --- a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEActivity.kt +++ b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEActivity.kt @@ -293,6 +293,7 @@ class SearchTEActivity : FilterManager.getInstance().clearSorting() FilterManager.getInstance().clearAssignToMe() FilterManager.getInstance().clearFollowUp() + FilterManager.getInstance().clearTransferred() presenter.clearOtherFiltersIfWebAppIsConfig() } super.onDestroy() diff --git a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEIViewModel.kt b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEIViewModel.kt index 17e8eff9a7..e9afc520ef 100644 --- a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEIViewModel.kt +++ b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEIViewModel.kt @@ -815,7 +815,7 @@ class SearchTEIViewModel( fun fetchGlobalResults(): Flow>? { // get uids to exclude for possible duplicates - return if (searching) { + return if (searching && !filterManager.transferredFilter) { flow { val excludeValues = searchRepositoryKt.getExcludeValues() diff --git a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/ui/mapper/TEICardMapper.kt b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/ui/mapper/TEICardMapper.kt index c5d520c6d0..25fe407e39 100644 --- a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/ui/mapper/TEICardMapper.kt +++ b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/ui/mapper/TEICardMapper.kt @@ -16,6 +16,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.painter.BitmapPainter +import androidx.compose.ui.res.painterResource import org.dhis2.R import org.dhis2.commons.bindings.isFilePathValid import org.dhis2.commons.date.toDateSpan @@ -56,6 +57,11 @@ class TEICardMapper( title = getTitle(searchTEIModel), lastUpdated = searchTEIModel.tei.lastUpdated?.toJavaDate().toDateSpan(context), additionalInfo = getAdditionalInfoList(searchTEIModel), + emphasizedAdditionalInfoKey = + resourceManager.getString(R.string.transferredTo).takeIf { + searchTEIModel.tei.ownerOrgUnit != null && + searchTEIModel.tei.ownerOrgUnit != searchTEIModel.tei.enrollmentOrgUnit + }, actionButton = { ProvideSyncButton(searchTEIModel, onSyncIconClick) }, expandLabelText = resourceManager.getString(R.string.show_more), shrinkLabelText = resourceManager.getString(R.string.show_less), @@ -142,7 +148,7 @@ class TEICardMapper( return attributeList.also { list -> searchTEIModel.tei.ownerOrgUnit?.let { if (it != searchTEIModel.tei.enrollmentOrgUnit) { - addOwnedBy( + addTransferredTo( list = list, ownerOrgUnit = it, ) @@ -302,13 +308,20 @@ class TEICardMapper( } - private fun addOwnedBy( + private fun addTransferredTo( list: MutableList, ownerOrgUnit: String, ) { list.add( AdditionalInfoItem( - key = resourceManager.getString(R.string.ownedBy), + icon = { + Icon( + painter = painterResource(id = R.drawable.ic_transfer), + contentDescription = resourceManager.getString(R.string.transferredTo), + tint = AdditionalInfoItemColor.DISABLED.color, + ) + }, + key = resourceManager.getString(R.string.transferredTo), value = ownerOrgUnit, isConstantItem = true, ), diff --git a/app/src/main/java/org/dhis2/usescases/teiDashboard/DashboardRepositoryImpl.kt b/app/src/main/java/org/dhis2/usescases/teiDashboard/DashboardRepositoryImpl.kt index 05c96e0c35..b8062f166d 100644 --- a/app/src/main/java/org/dhis2/usescases/teiDashboard/DashboardRepositoryImpl.kt +++ b/app/src/main/java/org/dhis2/usescases/teiDashboard/DashboardRepositoryImpl.kt @@ -533,7 +533,10 @@ class DashboardRepositoryImpl( .uid(teiUid) .blockingGet() ?.programOwners() - ?.firstOrNull { it.trackedEntityInstance() == teiUid } + ?.firstOrNull { + it.trackedEntityInstance() == teiUid && + (programUid.isNullOrEmpty() || it.program() == programUid) + } ?.ownerOrgUnit() ?.let { orgUnitUid -> d2 diff --git a/app/src/main/java/org/dhis2/usescases/teiDashboard/dashboardfragments/teidata/TeiDataRepositoryImpl.kt b/app/src/main/java/org/dhis2/usescases/teiDashboard/dashboardfragments/teidata/TeiDataRepositoryImpl.kt index 64385dbe08..2b3fe8ad12 100644 --- a/app/src/main/java/org/dhis2/usescases/teiDashboard/dashboardfragments/teidata/TeiDataRepositoryImpl.kt +++ b/app/src/main/java/org/dhis2/usescases/teiDashboard/dashboardfragments/teidata/TeiDataRepositoryImpl.kt @@ -554,7 +554,8 @@ class TeiDataRepositoryImpl( .blockingGet() ?.programOwners() ?.firstOrNull { - it.trackedEntityInstance() == teiUid + it.trackedEntityInstance() == teiUid && + (programUid.isNullOrEmpty() || it.program() == programUid) }?.ownerOrgUnit() } diff --git a/app/src/main/java/org/dhis2/usescases/teiDashboard/ui/TeiDetailDashboard.kt b/app/src/main/java/org/dhis2/usescases/teiDashboard/ui/TeiDetailDashboard.kt index 635f25dd27..d31ce0597d 100644 --- a/app/src/main/java/org/dhis2/usescases/teiDashboard/ui/TeiDetailDashboard.kt +++ b/app/src/main/java/org/dhis2/usescases/teiDashboard/ui/TeiDetailDashboard.kt @@ -12,6 +12,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag import androidx.compose.ui.unit.dp import org.dhis2.commons.data.EventCreationType +import org.dhis2.commons.ui.EmphasizedAdditionalInfoColumn import org.dhis2.usescases.teiDashboard.ui.model.InfoBarUiModel import org.dhis2.usescases.teiDashboard.ui.model.QuickActionUiModel import org.dhis2.usescases.teiDashboard.ui.model.TeiCardUiModel @@ -57,11 +58,26 @@ fun TeiDetailDashboard( } card?.let { + val emphasizedKey = card.emphasizedAdditionalInfoKey CardDetail( title = card.title, - additionalInfoList = card.additionalInfo, + additionalInfoList = card.additionalInfo.takeIf { emphasizedKey == null } ?: emptyList(), avatar = card.avatar, - actionButton = card.actionButton, + actionButton = + if (emphasizedKey == null) { + card.actionButton + } else { + { + EmphasizedAdditionalInfoColumn( + additionalInfo = card.additionalInfo, + emphasizedKey = emphasizedKey, + expandLabelText = card.expandLabelText, + shrinkLabelText = card.shrinkLabelText, + isDetailCard = true, + ) + card.actionButton() + } + }, expandLabelText = card.expandLabelText, shrinkLabelText = card.shrinkLabelText, showLoading = card.showLoading, diff --git a/app/src/main/java/org/dhis2/usescases/teiDashboard/ui/mapper/TeiDashboardCardMapper.kt b/app/src/main/java/org/dhis2/usescases/teiDashboard/ui/mapper/TeiDashboardCardMapper.kt index 474f2e22e1..fe2aef93dc 100644 --- a/app/src/main/java/org/dhis2/usescases/teiDashboard/ui/mapper/TeiDashboardCardMapper.kt +++ b/app/src/main/java/org/dhis2/usescases/teiDashboard/ui/mapper/TeiDashboardCardMapper.kt @@ -8,6 +8,7 @@ import androidx.compose.material3.Icon import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.painter.BitmapPainter +import androidx.compose.ui.res.painterResource import org.dhis2.R import org.dhis2.commons.date.toUi import org.dhis2.commons.resources.ResourceManager @@ -21,6 +22,7 @@ import org.hisp.dhis.android.core.program.Program import org.hisp.dhis.android.core.trackedentity.TrackedEntityAttribute import org.hisp.dhis.android.core.trackedentity.TrackedEntityAttributeValue import org.hisp.dhis.mobile.ui.designsystem.component.AdditionalInfoItem +import org.hisp.dhis.mobile.ui.designsystem.component.AdditionalInfoItemColor import org.hisp.dhis.mobile.ui.designsystem.component.Avatar import org.hisp.dhis.mobile.ui.designsystem.component.AvatarStyleData import org.hisp.dhis.mobile.ui.designsystem.theme.SurfaceColor @@ -54,6 +56,10 @@ class TeiDashboardCardMapper( emailCallback, programsCallback, ), + emphasizedAdditionalInfoKey = + (dashboardModel as? DashboardEnrollmentModel) + ?.takeIf { it.ownerOrgUnit != null && it.getCurrentOrgUnit() != it.ownerOrgUnit } + ?.let { resourceManager.getString(R.string.transferredTo) }, actionButton = {}, expandLabelText = resourceManager.getString(R.string.show_more), shrinkLabelText = resourceManager.getString(R.string.show_less), @@ -175,6 +181,7 @@ class TeiDashboardCardMapper( addOwnedBy( list, item.ownerOrgUnit, + item.getCurrentOrgUnit() != item.ownerOrgUnit, ) if (item.getCurrentOrgUnit() != item.ownerOrgUnit) { addEnrollIn( @@ -229,10 +236,23 @@ class TeiDashboardCardMapper( private fun addOwnedBy( list: MutableList, ownedByOrgUnit: OrganisationUnit?, + transferred: Boolean, ) { list.add( AdditionalInfoItem( - key = resourceManager.getString(R.string.ownedBy), + icon = + if (transferred) { + { + Icon( + painter = painterResource(id = R.drawable.ic_transfer), + contentDescription = resourceManager.getString(R.string.transferredTo), + tint = AdditionalInfoItemColor.DISABLED.color, + ) + } + } else { + null + }, + key = resourceManager.getString(if (transferred) R.string.transferredTo else R.string.ownedBy), value = ownedByOrgUnit?.displayName() ?: "-", isConstantItem = true, ), diff --git a/app/src/main/java/org/dhis2/usescases/teiDashboard/ui/model/TeiCardUiModel.kt b/app/src/main/java/org/dhis2/usescases/teiDashboard/ui/model/TeiCardUiModel.kt index 5110b0c2cd..782a6d8132 100644 --- a/app/src/main/java/org/dhis2/usescases/teiDashboard/ui/model/TeiCardUiModel.kt +++ b/app/src/main/java/org/dhis2/usescases/teiDashboard/ui/model/TeiCardUiModel.kt @@ -7,6 +7,7 @@ data class TeiCardUiModel( val avatar: (@Composable () -> Unit)? = null, val title: String, val additionalInfo: List, + val emphasizedAdditionalInfoKey: String? = null, val actionButton: @Composable (() -> Unit), val expandLabelText: String, val shrinkLabelText: String, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 45f07e1d31..5edee5b596 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -547,6 +547,7 @@ Downloading images... Enrolled in Owned by + Transferred to Sync needed Sync error Synced diff --git a/app/src/test/java/org/dhis2/data/filter/FilterRepositoryTest.kt b/app/src/test/java/org/dhis2/data/filter/FilterRepositoryTest.kt index 1fa1ddc8cb..d22832c367 100644 --- a/app/src/test/java/org/dhis2/data/filter/FilterRepositoryTest.kt +++ b/app/src/test/java/org/dhis2/data/filter/FilterRepositoryTest.kt @@ -45,6 +45,7 @@ class FilterRepositoryTest { const val HOME_FILTER = "HomeFilter" const val PROGRAM_FILTER = "ProgramFilter" const val FOLLOW_UP = "follow_up" + const val TRANSFERRED = "transferred" } private val observableSortingInject = ObservableField() @@ -84,6 +85,7 @@ class FilterRepositoryTest { whenever(filterResources.filterAssignedToMeLabel()) doReturn ASSIGN_TO_ME whenever(filterResources.filterEventDateLabel("random")) doReturn EVENT_DATE whenever(filterResources.filterFollowUpLabel("Name")) doReturn FOLLOW_UP + whenever(filterResources.filterTransferredPatientLabel()) doReturn TRANSFERRED } @Test @@ -747,8 +749,9 @@ class FilterRepositoryTest { assert(result[1].type == Filters.ENROLLMENT_STATUS) assert(result[2].type == Filters.EVENT_STATUS) assert(result[3].type == Filters.FOLLOW_UP) - assert(result[4].type == Filters.ASSIGNED_TO_ME) - assert(result.size == 5) + assert(result[4].type == Filters.TRANSFERRED) + assert(result[5].type == Filters.ASSIGNED_TO_ME) + assert(result.size == 6) } private fun createTrackerEntityInstanceFilters(): List = diff --git a/app/src/test/java/org/dhis2/usescases/searchTrackEntity/ui/mapper/TEICardMapperTest.kt b/app/src/test/java/org/dhis2/usescases/searchTrackEntity/ui/mapper/TEICardMapperTest.kt index 787a71b48d..33389fd06d 100644 --- a/app/src/test/java/org/dhis2/usescases/searchTrackEntity/ui/mapper/TEICardMapperTest.kt +++ b/app/src/test/java/org/dhis2/usescases/searchTrackEntity/ui/mapper/TEICardMapperTest.kt @@ -21,6 +21,8 @@ import org.dhis2.tracker.search.model.TrackedEntityTypeAttributeDomain import org.dhis2.tracker.search.model.TrackedEntityTypeDomain import org.dhis2.usescases.searchTrackEntity.SearchTeiModel import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull import org.junit.Before import org.junit.Test import org.mockito.kotlin.any @@ -62,6 +64,8 @@ class TEICardMapperTest { whenever(resourceManager.getString(R.string.show_more)) doReturn "Show more" whenever(resourceManager.getString(R.string.show_less)) doReturn "Show less" whenever(resourceManager.getString(R.string.completed)) doReturn "Completed" + whenever(resourceManager.getString(R.string.enrolledIn)) doReturn "Enrolled in" + whenever(resourceManager.getString(R.string.transferredTo)) doReturn "Transferred to" whenever( resourceManager.formatWithEnrollmentLabel(any(), any(), any(), any()), ) doReturn "Enrollment Completed" @@ -88,7 +92,11 @@ class TEICardMapperTest { assertEquals(result.title, model.header) assertEquals(result.lastUpdated, model.tei.lastUpdated?.toJavaDate().toDateSpan(context)) assertEquals(result.additionalInfo[0].value, model.attributeValues["Name"]?.value) + assertEquals(result.emphasizedAdditionalInfoKey, "Transferred to") + assertEquals(result.additionalInfo[1].key, "Transferred to") + assertNotNull(result.additionalInfo[1].icon) assertEquals(result.additionalInfo[1].value, model.tei.ownerOrgUnit) + assertEquals(result.additionalInfo[2].key, "Enrolled in") assertEquals(result.additionalInfo[2].value, model.tei.enrollmentOrgUnit) assertEquals( result.additionalInfo[3].value, @@ -109,6 +117,23 @@ class TEICardMapperTest { ) } + @Test + fun shouldOnlyShowEnrollmentOrgUnitWhenNotTransferred() { + val model = createFakeModel(ownerOrgUnit = null) + + val result = + mapper.map( + searchTEIModel = model, + onSyncIconClick = {}, + onCardClick = {}, + onImageClick = {}, + ) + + assertNull(result.emphasizedAdditionalInfoKey) + assertEquals(result.additionalInfo[1].key, "Enrolled in") + assertEquals(result.additionalInfo[1].value, model.tei.enrollmentOrgUnit) + } + @Test fun shouldShowOverDueLabel() { val overdueDate = DateUtils.getInstance().calendar @@ -133,6 +158,7 @@ class TEICardMapperTest { private fun createFakeModel( currentDate: Date = Date(), + ownerOrgUnit: String? = "ownerOrgUnit", ): SearchTeiModel { val attributeValues = LinkedHashMap() val attribute = TrackedEntitySearchItemAttributeDomain( @@ -152,7 +178,7 @@ class TEICardMapperTest { lastUpdated = Instant.parse("2020-01-01T00:00:00.00Z"), createdAtClient = Instant.parse("2020-01-01T00:00:00.00Z"), lastUpdatedAtClient = Instant.parse("2020-01-01T00:00:00.00Z"), - ownerOrgUnit = "ownerOrgUnit", + ownerOrgUnit = ownerOrgUnit, enrollmentOrgUnit = "enrollmentOrgUnit", shouldDisplayOrgUnit = true, geometry = null, diff --git a/app/src/test/java/org/dhis2/usescases/teiDashboard/DashboardRepositoryImplTest.kt b/app/src/test/java/org/dhis2/usescases/teiDashboard/DashboardRepositoryImplTest.kt index a096d5881e..0b188658ac 100644 --- a/app/src/test/java/org/dhis2/usescases/teiDashboard/DashboardRepositoryImplTest.kt +++ b/app/src/test/java/org/dhis2/usescases/teiDashboard/DashboardRepositoryImplTest.kt @@ -16,9 +16,12 @@ import org.hisp.dhis.android.core.event.Event import org.hisp.dhis.android.core.maintenance.D2Error import org.hisp.dhis.android.core.maintenance.D2ErrorCode import org.hisp.dhis.android.core.maintenance.D2ErrorComponent +import org.hisp.dhis.android.core.organisationunit.OrganisationUnit import org.hisp.dhis.android.core.program.ProgramStage import org.hisp.dhis.android.core.trackedentity.TrackedEntityAttribute import org.hisp.dhis.android.core.trackedentity.TrackedEntityAttributeValue +import org.hisp.dhis.android.core.trackedentity.TrackedEntityInstance +import org.hisp.dhis.android.core.trackedentity.ownership.ProgramOwner import org.junit.Assert.assertFalse import org.junit.Before import org.junit.Test @@ -71,6 +74,43 @@ class DashboardRepositoryImplTest { assertFalse(repository.programHasSimprintsRampProgramStageHistoryTable()) } + @Test + fun `Should use current program owner when checking transfer availability`() { + val selectedOwner = OrganisationUnit.builder().uid("selectedOwner").build() + val trackedEntity = + TrackedEntityInstance + .builder() + .uid("teiUid") + .programOwners( + listOf( + programOwner("otherProgram", "otherOwner"), + programOwner("programUid", selectedOwner.uid()), + ), + ).build() + + whenever( + d2 + .organisationUnitModule() + .organisationUnits() + .byOrganisationUnitScope(OrganisationUnit.Scope.SCOPE_TEI_SEARCH) + .byProgramUids(listOf("programUid")) + .blockingGet(), + ) doReturn listOf(selectedOwner) + whenever( + d2 + .trackedEntityModule() + .trackedEntityInstances() + .withProgramOwners() + .uid("teiUid") + .blockingGet(), + ) doReturn trackedEntity + whenever( + d2.organisationUnitModule().organisationUnits().uid(selectedOwner.uid()).blockingGet(), + ) doReturn selectedOwner + + assertFalse(repository.teiCanBeTransferred()) + } + @Test fun `Should return program stage to show display generate event`() { whenever(d2.eventModule()) doReturn mock() @@ -606,6 +646,17 @@ class DashboardRepositoryImplTest { .attributeOptionCombo("attributeOptionComboUid") .build() + private fun programOwner( + programUid: String, + ownerOrgUnitUid: String, + ): ProgramOwner = + ProgramOwner + .builder() + .trackedEntityInstance("teiUid") + .program(programUid) + .ownerOrgUnit(ownerOrgUnitUid) + .build() + private fun getMockSingleEvent(): Event = Event .builder() diff --git a/app/src/test/java/org/dhis2/usescases/teiDashboard/ui/mapper/TEIDetailMapperTest.kt b/app/src/test/java/org/dhis2/usescases/teiDashboard/ui/mapper/TEIDetailMapperTest.kt index f8c2e9e03d..c8bda92523 100644 --- a/app/src/test/java/org/dhis2/usescases/teiDashboard/ui/mapper/TEIDetailMapperTest.kt +++ b/app/src/test/java/org/dhis2/usescases/teiDashboard/ui/mapper/TEIDetailMapperTest.kt @@ -15,6 +15,8 @@ import org.hisp.dhis.android.core.trackedentity.TrackedEntityAttribute import org.hisp.dhis.android.core.trackedentity.TrackedEntityAttributeValue import org.hisp.dhis.android.core.trackedentity.TrackedEntityInstance import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull import org.junit.Before import org.junit.Test import org.mockito.kotlin.doReturn @@ -29,6 +31,8 @@ class TEIDetailMapperTest { fun setUp() { whenever(resourceManager.getString(R.string.show_more)) doReturn "Show more" whenever(resourceManager.getString(R.string.show_less)) doReturn "Show less" + whenever(resourceManager.getString(R.string.ownedBy)) doReturn "Owned by" + whenever(resourceManager.getString(R.string.transferredTo)) doReturn "Transferred to" mapper = TeiDashboardCardMapper(resourceManager) } @@ -49,11 +53,14 @@ class TEIDetailMapperTest { assertEquals(result.title, model.teiHeader) assertEquals(result.additionalInfo[0].value, model.trackedEntityAttributes[0].second.value()) assertEquals(result.additionalInfo[1].value, model.trackedEntityAttributes[1].second.value()) + assertEquals("Owned by", result.additionalInfo[3].key) + assertNull(result.additionalInfo[3].icon) assertEquals(result.additionalInfo[3].value, orgUnit().displayName()) + assertNull(result.emphasizedAdditionalInfoKey) } @Test - fun shouldShowOwnedAndEnrolledOrgUnit() { + fun shouldShowTransferredAndEnrolledOrgUnit() { val model = createFakeModel(otherOrgUnit()) val result = @@ -65,8 +72,11 @@ class TEIDetailMapperTest { onImageClick = {}, ) + assertEquals("Transferred to", result.additionalInfo[3].key) + assertNotNull(result.additionalInfo[3].icon) assertEquals(result.additionalInfo[3].value, otherOrgUnit().displayName()) assertEquals(result.additionalInfo[4].value, model.orgUnits.first().displayName()) + assertEquals("Transferred to", result.emphasizedAdditionalInfoKey) } private fun createFakeModel(ownerOrgUnit: OrganisationUnit = orgUnit()): DashboardEnrollmentModel { diff --git a/app/src/test/java/org/dhis2/utils/filters/FilterManagerTest.kt b/app/src/test/java/org/dhis2/utils/filters/FilterManagerTest.kt index 274245d70b..3473c8b81c 100644 --- a/app/src/test/java/org/dhis2/utils/filters/FilterManagerTest.kt +++ b/app/src/test/java/org/dhis2/utils/filters/FilterManagerTest.kt @@ -129,6 +129,20 @@ class FilterManagerTest { assertTrue(filterManager.observeField(Filters.ASSIGNED_TO_ME).get() == 1) } + @Test + fun `Should add transferred patient filter`() { + filterManager.setTransferred(true) + + assertTrue(filterManager.totalFilters == 1) + assertTrue(filterManager.observeField(Filters.TRANSFERRED).get() == 1) + assertTrue(filterManager.copy().sameFilters(filterManager)) + + filterManager.clearTransferred() + + assertTrue(filterManager.totalFilters == 0) + assertTrue(filterManager.observeTransferred().get() == false) + } + @Test fun `Should add a sortingItem to filterManager`() { val sortingItem = SortingItem(Filters.ORG_UNIT, SortingStatus.ASC) diff --git a/commons/src/main/java/org/dhis2/commons/filters/FilterItem.kt b/commons/src/main/java/org/dhis2/commons/filters/FilterItem.kt index 53fed60a05..84b2926666 100644 --- a/commons/src/main/java/org/dhis2/commons/filters/FilterItem.kt +++ b/commons/src/main/java/org/dhis2/commons/filters/FilterItem.kt @@ -79,7 +79,7 @@ sealed class FilterItem( } fun displayExpandArrow(): Boolean { - val filters = listOf(Filters.FOLLOW_UP, Filters.ASSIGNED_TO_ME) + val filters = listOf(Filters.FOLLOW_UP, Filters.TRANSFERRED, Filters.ASSIGNED_TO_ME) return !filters.any { it == type } } } @@ -329,3 +329,18 @@ data class FollowUpFilter( override fun icon() = R.drawable.ic_follow_up_filter } + +data class TransferredFilter( + override val programType: ProgramType, + override val sortingItem: ObservableField, + override val openFilter: ObservableField, + override val filterLabel: String, +) : FilterItem(Filters.TRANSFERRED, programType, sortingItem, openFilter, filterLabel) { + fun activate(setActive: Boolean) { + FilterManager.getInstance().setTransferred(setActive) + } + + fun observeTransferred(): ObservableField = FilterManager.getInstance().observeTransferred() + + override fun icon() = R.drawable.ic_transfer +} diff --git a/commons/src/main/java/org/dhis2/commons/filters/FilterManager.java b/commons/src/main/java/org/dhis2/commons/filters/FilterManager.java index d993da4e34..06af3724e5 100644 --- a/commons/src/main/java/org/dhis2/commons/filters/FilterManager.java +++ b/commons/src/main/java/org/dhis2/commons/filters/FilterManager.java @@ -85,6 +85,8 @@ public enum PeriodRequest { private SortingItem sortingItem; private boolean followUpFilter; private ObservableField observableFollowUp = new ObservableField<>(); + private boolean transferredFilter; + private ObservableField observableTransferred = new ObservableField<>(); private ArrayList unsupportedFilters = new ArrayList<>(); @@ -97,6 +99,7 @@ public enum PeriodRequest { private ObservableField enrollmentStatusFiltersApplied; private ObservableField assignedToMeApplied; private ObservableField followUpFilterApplied; + private ObservableField transferredFilterApplied; private List stateValues = new ArrayList<>(); @@ -155,6 +158,8 @@ public void reset() { enrollmentStatusFilters = new ArrayList<>(); assignedFilter = false; followUpFilter = false; + transferredFilter = false; + observableTransferred.set(false); sortingItem = null; ouFiltersApplied = new ObservableField<>(0); @@ -166,6 +171,7 @@ public void reset() { enrollmentStatusFiltersApplied = new ObservableField<>(0); assignedToMeApplied = new ObservableField<>(0); followUpFilterApplied = new ObservableField<>(0); + transferredFilterApplied = new ObservableField<>(0); filterProcessor = PublishProcessor.create(); filterFlow = FilterManagerExtensionsKt.initFlow(this); @@ -185,6 +191,7 @@ public FilterManager copy() { copy.enrollmentStatusFilters = new ArrayList<>(getEnrollmentStatusFilters()); copy.assignedFilter = getAssignedFilter(); copy.followUpFilter = getFollowUpFilter(); + copy.transferredFilter = getTransferredFilter(); copy.sortingItem = getSortingItem(); return copy; } @@ -199,6 +206,7 @@ public boolean sameFilters(FilterManager filterManager) { Objects.equals(filterManager.enrollmentStatusFilters, this.enrollmentStatusFilters) && filterManager.assignedFilter == this.assignedFilter && filterManager.followUpFilter == this.followUpFilter && + filterManager.transferredFilter == this.transferredFilter && Objects.equals(filterManager.sortingItem, this.sortingItem); } @@ -363,6 +371,8 @@ public ObservableField observeField(Filters filter) { return assignedToMeApplied; case FOLLOW_UP: return followUpFilterApplied; + case TRANSFERRED: + return transferredFilterApplied; default: return new ObservableField<>(0); } @@ -413,10 +423,11 @@ public int getTotalFilters() { int sortingIsActive = sortingItem != null ? 1 : 0; int workingListFilters = getTotalFilterCounterForWorkingList(currentWorkingListScope.get()); int followUpApplying = followUpFilter ? 1 : 0; + int transferredApplying = transferredFilter ? 1 : 0; return ouIsApplying + stateIsApplying + periodIsApplying + eventStatusApplying + catComboApplying + assignedApplying + enrollmentPeriodIsApplying + enrollmentStatusApplying + - sortingIsActive + workingListFilters + followUpApplying; + sortingIsActive + workingListFilters + followUpApplying + transferredApplying; } public List getPeriodFilters() { @@ -557,6 +568,14 @@ public void clearFollowUp() { } } + public void clearTransferred() { + if (transferredFilter) { + transferredFilter = false; + observableTransferred.set(false); + transferredFilterApplied.set(0); + } + } + public void clearSorting() { sortingItem = null; } @@ -600,6 +619,8 @@ public void clearAllFilters() { sortingItem = null; followUpFilter = false; observableFollowUp.set(false); + transferredFilter = false; + observableTransferred.set(false); eventStatusFiltersApplied.set(eventStatusFilters.size()); enrollmentPeriodFiltersApplied.set(enrollmentPeriodFilters.size()); @@ -612,6 +633,7 @@ public void clearAllFilters() { this.currentWorkingList = null; setWorkingListScope(new EmptyWorkingList()); followUpFilterApplied.set(0); + transferredFilterApplied.set(0); if (!workingListActive()) publishData(); @@ -659,6 +681,21 @@ public void setFollowUp(boolean isChecked) { } + public boolean getTransferredFilter() { + return transferredFilter; + } + + public ObservableField observeTransferred() { + return observableTransferred; + } + + public void setTransferred(boolean isChecked) { + this.transferredFilter = isChecked; + observableTransferred.set(isChecked); + transferredFilterApplied.set(isChecked ? 1 : 0); + publishData(); + } + public SortingItem getSortingItem() { return sortingItem; } diff --git a/commons/src/main/java/org/dhis2/commons/filters/FilterResources.kt b/commons/src/main/java/org/dhis2/commons/filters/FilterResources.kt index 4d39f8bfd7..59846dc0b4 100644 --- a/commons/src/main/java/org/dhis2/commons/filters/FilterResources.kt +++ b/commons/src/main/java/org/dhis2/commons/filters/FilterResources.kt @@ -118,6 +118,8 @@ class FilterResources( fun filterFollowUpLabel(teTypeName: String): String = resourceManager.getString(R.string.filter_follow_up_label).format(teTypeName) + fun filterTransferredPatientLabel(): String = resourceManager.getString(R.string.filter_transferred_patient_label) + fun filterEventStatusLabel(programUid: String): String = eventResourcesProvider.formatWithProgramEventLabel( R.string.filters_title_event_label_status, diff --git a/commons/src/main/java/org/dhis2/commons/filters/Filters.kt b/commons/src/main/java/org/dhis2/commons/filters/Filters.kt index ae70ab0034..8513f03e7b 100644 --- a/commons/src/main/java/org/dhis2/commons/filters/Filters.kt +++ b/commons/src/main/java/org/dhis2/commons/filters/Filters.kt @@ -16,5 +16,6 @@ enum class Filters( ENROLLMENT_STATUS(R.layout.item_filter_enrollment_status), WORKING_LIST(R.layout.item_filter_working_list), FOLLOW_UP(R.layout.item_filter_followup), + TRANSFERRED(R.layout.item_filter_transferred), NON(-1), } diff --git a/commons/src/main/java/org/dhis2/commons/filters/data/FilterPresenter.kt b/commons/src/main/java/org/dhis2/commons/filters/data/FilterPresenter.kt index a857bc0013..a2843fa595 100644 --- a/commons/src/main/java/org/dhis2/commons/filters/data/FilterPresenter.kt +++ b/commons/src/main/java/org/dhis2/commons/filters/data/FilterPresenter.kt @@ -52,6 +52,9 @@ class FilterPresenter fun areFiltersActive(): Boolean = filterManager.totalFilters != 0 + fun requiresOfflineSearch(programUid: String?): Boolean = + programUid != null && filterManager.transferredFilter + fun getOrgUnitsByName(name: String): FilteredOrgUnitResult { filteredOrgUnitResult = FilteredOrgUnitResult( diff --git a/commons/src/main/java/org/dhis2/commons/filters/data/FilterRepository.kt b/commons/src/main/java/org/dhis2/commons/filters/data/FilterRepository.kt index 41da40080c..7c5e9189de 100644 --- a/commons/src/main/java/org/dhis2/commons/filters/data/FilterRepository.kt +++ b/commons/src/main/java/org/dhis2/commons/filters/data/FilterRepository.kt @@ -15,6 +15,7 @@ import org.dhis2.commons.filters.OrgUnitFilter import org.dhis2.commons.filters.PeriodFilter import org.dhis2.commons.filters.ProgramType import org.dhis2.commons.filters.SyncStateFilter +import org.dhis2.commons.filters.TransferredFilter import org.dhis2.commons.filters.WorkingListFilter import org.dhis2.commons.filters.sorting.SortingItem import org.dhis2.commons.filters.workingLists.EventFilterToWorkingListItemMapper @@ -43,6 +44,8 @@ import org.hisp.dhis.android.core.settings.ProgramFilter import org.hisp.dhis.android.core.trackedentity.search.TrackedEntitySearchCollectionRepository import javax.inject.Inject +private const val NO_MATCHING_TRACKED_ENTITY_UID = "" + class FilterRepository @Inject constructor( @@ -148,6 +151,33 @@ constructor( fun applyFollowUp(repository: TrackedEntitySearchCollectionRepository): TrackedEntitySearchCollectionRepository = repository.byFollowUp().isTrue + fun applyTransferredPatientFilter( + repository: TrackedEntitySearchCollectionRepository, + programUid: String, + ): TrackedEntitySearchCollectionRepository { + val enrollments = + d2 + .enrollmentModule() + .enrollments() + .byProgram() + .eq(programUid) + .orderByEnrollmentDate(RepositoryScope.OrderByDirection.DESC) + .blockingGet() + val trackedEntities = + d2 + .trackedEntityModule() + .trackedEntityInstances() + .byProgramUids(listOf(programUid)) + .withProgramOwners() + .blockingGet() + val transferredUids = transferredTrackedEntityUids(programUid, enrollments, trackedEntities) + + // SDK treats an empty UID list as no filter + return repository + .byTrackedEntities() + .`in`(transferredUids.ifEmpty { listOf(NO_MATCHING_TRACKED_ENTITY_UID) }) + } + fun sortByPeriod( repository: TrackedEntitySearchCollectionRepository, orderDirection: RepositoryScope.OrderByDirection, @@ -541,12 +571,21 @@ constructor( observableOpenFilter, resources.filterFollowUpLabel(teTypeName), ) + val transferredFilter = + TransferredFilter( + ProgramType.TRACKER, + observableSortingInject, + observableOpenFilter, + resources.filterTransferredPatientLabel(), + ) if (filtersToShow.any { it.type == Filters.ASSIGNED_TO_ME }) { val index = filtersToShow.indexOfFirst { it.type == Filters.ASSIGNED_TO_ME } filtersToShow.add(index, followUpFilter) + filtersToShow.add(index + 1, transferredFilter) } else { filtersToShow.add(followUpFilter) + filtersToShow.add(transferredFilter) } return filtersToShow.toList() } diff --git a/commons/src/main/java/org/dhis2/commons/filters/data/TrackerFilterSearchHelper.kt b/commons/src/main/java/org/dhis2/commons/filters/data/TrackerFilterSearchHelper.kt index 21f77b9aba..1f42f471a7 100644 --- a/commons/src/main/java/org/dhis2/commons/filters/data/TrackerFilterSearchHelper.kt +++ b/commons/src/main/java/org/dhis2/commons/filters/data/TrackerFilterSearchHelper.kt @@ -18,7 +18,13 @@ class TrackerFilterSearchHelper fun getFilteredProgramRepository(programUid: String): TrackedEntitySearchCollectionRepository = applyFiltersTo( filterRepository.trackedEntityInstanceQueryByProgram(programUid), - ) + ).withFilter { + if (filterManager.transferredFilter) { + filterRepository.applyTransferredPatientFilter(it, programUid) + } else { + it + } + } fun getFilteredTrackedEntityTypeRepository(trackedEntityTypeUid: String): TrackedEntitySearchCollectionRepository = applyFiltersTo( diff --git a/commons/src/main/java/org/dhis2/commons/filters/data/TransferredPatientFilter.kt b/commons/src/main/java/org/dhis2/commons/filters/data/TransferredPatientFilter.kt new file mode 100644 index 0000000000..3b17564068 --- /dev/null +++ b/commons/src/main/java/org/dhis2/commons/filters/data/TransferredPatientFilter.kt @@ -0,0 +1,30 @@ +package org.dhis2.commons.filters.data + +import org.hisp.dhis.android.core.enrollment.Enrollment +import org.hisp.dhis.android.core.enrollment.EnrollmentStatus +import org.hisp.dhis.android.core.trackedentity.TrackedEntityInstance + +internal fun transferredTrackedEntityUids( + programUid: String, + enrollments: List, + trackedEntities: List, +): List { + val enrollmentOrgUnitByTrackedEntity = + enrollments + .groupBy { it.trackedEntityInstance() } + .mapValues { (_, values) -> + (values.firstOrNull { it.status() == EnrollmentStatus.ACTIVE } ?: values.first()).organisationUnit() + } + + return trackedEntities.mapNotNull { trackedEntity -> + val enrollmentOrgUnit = enrollmentOrgUnitByTrackedEntity[trackedEntity.uid()] ?: return@mapNotNull null + val ownerOrgUnit = + trackedEntity + .programOwners() + ?.firstOrNull { it.program() == programUid } + ?.ownerOrgUnit() + trackedEntity.uid().takeIf { + ownerOrgUnit != null && ownerOrgUnit != enrollmentOrgUnit + } + } +} diff --git a/commons/src/main/java/org/dhis2/commons/ui/ListCardProvider.kt b/commons/src/main/java/org/dhis2/commons/ui/ListCardProvider.kt index 15361681d8..785e95da3a 100644 --- a/commons/src/main/java/org/dhis2/commons/ui/ListCardProvider.kt +++ b/commons/src/main/java/org/dhis2/commons/ui/ListCardProvider.kt @@ -1,16 +1,39 @@ package org.dhis2.commons.ui import androidx.annotation.StringRes +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.text.InlineTextContent +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.Placeholder +import androidx.compose.ui.text.PlaceholderVerticalAlign +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import org.dhis2.commons.ui.model.ListCardUiModel import org.hisp.dhis.mobile.ui.designsystem.component.AdditionalInfoItem import org.hisp.dhis.mobile.ui.designsystem.component.ListCard import org.hisp.dhis.mobile.ui.designsystem.component.ListCardDescriptionModel import org.hisp.dhis.mobile.ui.designsystem.component.ListCardTitleModel +import org.hisp.dhis.mobile.ui.designsystem.component.ProvideKeyValueItem +import org.hisp.dhis.mobile.ui.designsystem.component.ToggleInfoTextButton +import org.hisp.dhis.mobile.ui.designsystem.component.getKeyValueAnnotatedString import org.hisp.dhis.mobile.ui.designsystem.component.state.rememberAdditionalInfoColumnState import org.hisp.dhis.mobile.ui.designsystem.component.state.rememberListCardState +import org.hisp.dhis.mobile.ui.designsystem.theme.Spacing +import org.hisp.dhis.mobile.ui.designsystem.theme.TextColor @Composable fun ListCardProvider( @@ -19,6 +42,8 @@ fun ListCardProvider( title: ListCardTitleModel = ListCardTitleModel(text = card.title), @StringRes syncingResourceId: Int, ) { + val emphasizedKey = card.emphasizedAdditionalInfoKey + ListCard( modifier = modifier, listCardState = @@ -28,7 +53,7 @@ fun ListCardProvider( lastUpdated = card.lastUpdated, additionalInfoColumnState = rememberAdditionalInfoColumnState( - additionalInfoList = card.additionalInfo, + additionalInfoList = card.additionalInfo.takeIf { emphasizedKey == null } ?: emptyList(), syncProgressItem = AdditionalInfoItem( key = stringResource(id = syncingResourceId), @@ -42,6 +67,107 @@ fun ListCardProvider( ), listAvatar = card.avatar, onCardClick = card.onCardCLick, - actionButton = card.actionButton, + actionButton = + if (emphasizedKey == null) { + card.actionButton + } else { + { + EmphasizedAdditionalInfoColumn( + additionalInfo = card.additionalInfo, + emphasizedKey = emphasizedKey, + expandLabelText = card.expandLabelText, + shrinkLabelText = card.shrinkLabelText, + ) + card.actionButton() + } + }, ) } + +@Composable +fun EmphasizedAdditionalInfoColumn( + additionalInfo: List, + emphasizedKey: String, + expandLabelText: String, + shrinkLabelText: String, + isDetailCard: Boolean = false, +) { + val columnState = + rememberAdditionalInfoColumnState( + additionalInfoList = additionalInfo, + syncProgressItem = AdditionalInfoItem(value = ""), + expandLabelText = expandLabelText, + shrinkLabelText = shrinkLabelText, + ) + val visibleItems = + if (columnState.isExpanded()) columnState.expandableItemList() else columnState.visibleExpandableItemList() + + Column(Modifier.testTag("LIST_CARD_ADDITIONAL_INFO_COLUMN")) { + visibleItems.forEach { + AdditionalInfoRow(it, false, isDetailCard) + } + } + Column(Modifier.testTag("LIST_CARD_ADDITIONAL_INFO_CONSTANT_COLUMN")) { + columnState.constantItemList().forEach { + AdditionalInfoRow(it, it.key == emphasizedKey, isDetailCard) + } + } + if (columnState.showExpandableContent()) { + ToggleInfoTextButton( + sectionState = columnState.currentSectionState(), + shrinkLabelText = shrinkLabelText, + expandLabelText = expandLabelText, + onClick = columnState::updateSectionState, + ) + } +} + +@Composable +private fun AdditionalInfoRow( + item: AdditionalInfoItem, + emphasized: Boolean, + isDetailCard: Boolean, +) { + BoxWithConstraints(Modifier.fillMaxWidth()) { + if (emphasized) { + val text = + buildAnnotatedString { + append(getKeyValueAnnotatedString(item.key ?: "", item, false)) + addStyle(SpanStyle(fontWeight = FontWeight.Bold), 0, length) + } + Text( + text = text, + inlineContent = + mapOf( + "ItemIcon" to + InlineTextContent( + Placeholder( + width = 20.sp, + height = 20.sp, + placeholderVerticalAlign = PlaceholderVerticalAlign.Center, + ), + ) { + Box(Modifier.size(20.dp)) { + item.icon?.invoke() + } + }, + ), + color = item.color ?: TextColor.OnSurface, + style = + MaterialTheme.typography.bodyMedium.copy( + fontWeight = FontWeight.Bold, + lineHeight = 20.sp, + ), + overflow = if (item.truncate) TextOverflow.Ellipsis else TextOverflow.Clip, + maxLines = if (item.truncate) 2 else Int.MAX_VALUE, + ) + } else { + ProvideKeyValueItem( + item, + maxWidth / 2 - Spacing.Spacing16, + isDetailCard, + ) + } + } + Spacer(Modifier.size(if (isDetailCard) Spacing.Spacing8 else Spacing.Spacing4)) +} diff --git a/commons/src/main/java/org/dhis2/commons/ui/model/ListCardUiModel.kt b/commons/src/main/java/org/dhis2/commons/ui/model/ListCardUiModel.kt index c926332d90..b0f36bf8e6 100644 --- a/commons/src/main/java/org/dhis2/commons/ui/model/ListCardUiModel.kt +++ b/commons/src/main/java/org/dhis2/commons/ui/model/ListCardUiModel.kt @@ -9,6 +9,7 @@ data class ListCardUiModel( val description: String? = null, val lastUpdated: String? = null, val additionalInfo: List, + val emphasizedAdditionalInfoKey: String? = null, val actionButton: @Composable (() -> Unit), val expandLabelText: String, val shrinkLabelText: String, diff --git a/app/src/main/res/drawable/ic_transfer.xml b/commons/src/main/res/drawable/ic_transfer.xml similarity index 100% rename from app/src/main/res/drawable/ic_transfer.xml rename to commons/src/main/res/drawable/ic_transfer.xml diff --git a/commons/src/main/res/layout/item_filter_transferred.xml b/commons/src/main/res/layout/item_filter_transferred.xml new file mode 100644 index 0000000000..b289c12587 --- /dev/null +++ b/commons/src/main/res/layout/item_filter_transferred.xml @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + diff --git a/commons/src/main/res/values/strings.xml b/commons/src/main/res/values/strings.xml index 8576d77ed4..017c2f9db1 100644 --- a/commons/src/main/res/values/strings.xml +++ b/commons/src/main/res/values/strings.xml @@ -41,6 +41,7 @@ %s status @string/enrollment_status Followed up %s + Transferred patient Select period type Select my organization unit Weekly diff --git a/commons/src/test/kotlin/org/dhis2/commons/filters/data/TrackerFilterSearchHelperTest.kt b/commons/src/test/kotlin/org/dhis2/commons/filters/data/TrackerFilterSearchHelperTest.kt index 2d3f8aeffe..fc83f49eec 100644 --- a/commons/src/test/kotlin/org/dhis2/commons/filters/data/TrackerFilterSearchHelperTest.kt +++ b/commons/src/test/kotlin/org/dhis2/commons/filters/data/TrackerFilterSearchHelperTest.kt @@ -38,6 +38,17 @@ class TrackerFilterSearchHelperTest { ).applyOrgUnitFilter(repository, OrganisationUnitMode.SELECTED, selectedOrgUnits) } + @Test + fun `should apply transferred patient filter to program search`() { + defaultSetup() + whenever(filterManager.transferredFilter) doReturn true + whenever(filterRepository.applyTransferredPatientFilter(any(), any())) doReturn mock() + + trackerFilterSearchHelper.getFilteredProgramRepository("programUid") + + verify(filterRepository).applyTransferredPatientFilter(any(), org.mockito.kotlin.eq("programUid")) + } + private fun defaultSetup(orgUnitFilterList: List = emptyList()) { whenever(filterRepository.trackedEntityInstanceQueryByProgram(any())) doReturn mock() whenever(filterRepository.trackedEntityInstanceQueryByType(any())) doReturn mock() diff --git a/commons/src/test/kotlin/org/dhis2/commons/filters/data/TransferredPatientFilterTest.kt b/commons/src/test/kotlin/org/dhis2/commons/filters/data/TransferredPatientFilterTest.kt new file mode 100644 index 0000000000..af3b089951 --- /dev/null +++ b/commons/src/test/kotlin/org/dhis2/commons/filters/data/TransferredPatientFilterTest.kt @@ -0,0 +1,87 @@ +package org.dhis2.commons.filters.data + +import org.hisp.dhis.android.core.enrollment.Enrollment +import org.hisp.dhis.android.core.enrollment.EnrollmentStatus +import org.hisp.dhis.android.core.trackedentity.TrackedEntityInstance +import org.hisp.dhis.android.core.trackedentity.ownership.ProgramOwner +import org.junit.Assert.assertEquals +import org.junit.Test + +class TransferredPatientFilterTest { + @Test + fun `should compare selected program ownership with active enrollment org unit`() { + val programUid = "programUid" + val transferred = trackedEntity("transferred", programUid, "destination") + val notTransferred = trackedEntity("notTransferred", programUid, "enrollment") + val otherProgramOwnerFirst = + TrackedEntityInstance + .builder() + .uid("multipleOwners") + .programOwners( + listOf( + programOwner("multipleOwners", "otherProgram", "enrollment"), + programOwner("multipleOwners", programUid, "destination"), + ), + ).build() + + val result = + transferredTrackedEntityUids( + programUid = programUid, + enrollments = + listOf( + enrollment("transferred", "enrollment"), + enrollment("notTransferred", "enrollment"), + enrollment("multipleOwners", "enrollment"), + enrollment("multipleEnrollments", "completedOrgUnit", EnrollmentStatus.COMPLETED), + enrollment("multipleEnrollments", "activeOrgUnit"), + ), + trackedEntities = + listOf( + transferred, + notTransferred, + otherProgramOwnerFirst, + trackedEntity("multipleEnrollments", programUid, "activeOrgUnit"), + ), + ) + + assertEquals(listOf("transferred", "multipleOwners"), result) + } + + private fun enrollment( + trackedEntityUid: String, + orgUnitUid: String, + status: EnrollmentStatus = EnrollmentStatus.ACTIVE, + ): Enrollment = + Enrollment + .builder() + .uid("enrollment-$trackedEntityUid") + .trackedEntityInstance(trackedEntityUid) + .program("programUid") + .organisationUnit(orgUnitUid) + .status(status) + .attributeOptionCombo("attributeOptionCombo") + .build() + + private fun trackedEntity( + trackedEntityUid: String, + programUid: String, + ownerOrgUnitUid: String, + ): TrackedEntityInstance = + TrackedEntityInstance + .builder() + .uid(trackedEntityUid) + .programOwners(listOf(programOwner(trackedEntityUid, programUid, ownerOrgUnitUid))) + .build() + + private fun programOwner( + trackedEntityUid: String, + programUid: String, + ownerOrgUnitUid: String, + ): ProgramOwner = + ProgramOwner + .builder() + .trackedEntityInstance(trackedEntityUid) + .program(programUid) + .ownerOrgUnit(ownerOrgUnitUid) + .build() +} diff --git a/tracker/src/androidHostTest/kotlin/org/dhis2/tracker/search/data/SearchTrackedEntityRepositoryImplTest.kt b/tracker/src/androidHostTest/kotlin/org/dhis2/tracker/search/data/SearchTrackedEntityRepositoryImplTest.kt index 8fe4610f73..6c968d3bee 100644 --- a/tracker/src/androidHostTest/kotlin/org/dhis2/tracker/search/data/SearchTrackedEntityRepositoryImplTest.kt +++ b/tracker/src/androidHostTest/kotlin/org/dhis2/tracker/search/data/SearchTrackedEntityRepositoryImplTest.kt @@ -452,6 +452,24 @@ class SearchTrackedEntityRepositoryImplTest { verify(mockQuery).allowOnlineCache() } + // SDK doesn't propagate the transferred TEI UID filter to online queries anyway + @Test + fun `fetchImmediateResults should stay offline when transferred filter is active`() = + runTest { + val query: TrackedEntitySearchCollectionRepository = mock(defaultAnswer = Mockito.RETURNS_DEEP_STUBS) + whenever(filterPresenter.filteredTrackedEntityInstances(programUid, teType)) doReturn query + whenever(filterPresenter.requiresOfflineSearch(programUid)) doReturn true + repository.addFiltersToQuery(programUid, teType) + + repository.fetchImmediateResults( + isOnline = true, + hasStateFilters = false, + selectedProgram = programUid, + ) + + verify(query).offlineOnly() + } + @Test(expected = IllegalStateException::class) fun `fetchResults should throw exception when query not initialized`() = runTest { diff --git a/tracker/src/androidMain/kotlin/org/dhis2/tracker/search/data/SearchTrackedEntityRepositoryImpl.kt b/tracker/src/androidMain/kotlin/org/dhis2/tracker/search/data/SearchTrackedEntityRepositoryImpl.kt index 0c1306a445..88218204eb 100644 --- a/tracker/src/androidMain/kotlin/org/dhis2/tracker/search/data/SearchTrackedEntityRepositoryImpl.kt +++ b/tracker/src/androidMain/kotlin/org/dhis2/tracker/search/data/SearchTrackedEntityRepositoryImpl.kt @@ -124,7 +124,7 @@ class SearchTrackedEntityRepositoryImpl( ): Flow> { // if the device is online and there are no state filters, we can use online cache val pagerFlow = - if (isOnline && !hasStateFilters) { + if (isOnline && !hasStateFilters && !filterPresenter.requiresOfflineSearch(selectedProgram)) { trackedEntityInstanceQuery?.allowOnlineCache()?.eq(allowCache)?.offlineFirst() } else { // otherwise we use offline only @@ -146,7 +146,7 @@ class SearchTrackedEntityRepositoryImpl( ): List { // if the device is online and there are no state filters, we can use online cache val results = - if (isOnline && !hasStateFilters) { + if (isOnline && !hasStateFilters && !filterPresenter.requiresOfflineSearch(selectedProgram)) { trackedEntityInstanceQuery?.offlineFirst()?.blockingGet() } else { // otherwise we use offline only @@ -192,7 +192,11 @@ class SearchTrackedEntityRepositoryImpl( orgUnitName(item.organisationUnit) } - val ownerOrgUnit = item.programOwners?.firstOrNull()?.ownerOrgUnit + val ownerOrgUnit = + item.programOwners + ?.firstOrNull { + selectedProgram.isNullOrEmpty() || it.program == selectedProgram + }?.ownerOrgUnit val ownerOrgUnitName = if (ownerOrgUnit != selectedEnrollment?.orgUnit) { orgUnitName(ownerOrgUnit) From b63ab23d3ce3c66197e84e4f4e74dcdcb679b885 Mon Sep 17 00:00:00 2001 From: Alex Date: Wed, 15 Jul 2026 13:22:00 +0100 Subject: [PATCH 04/13] Simprints RAMP: Charts removed in all programs --- .../eventCapture/EventPageConfigurator.kt | 2 +- .../teiDashboard/TeiDashboardPageConfigurator.kt | 2 +- .../eventCapture/EventPageConfiguratorTest.kt | 13 +------------ .../TeiDashboardPageConfiguratorTest.kt | 6 ++++++ 4 files changed, 9 insertions(+), 14 deletions(-) diff --git a/app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventCapture/EventPageConfigurator.kt b/app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventCapture/EventPageConfigurator.kt index 7c7033af45..d2e96d8aa8 100644 --- a/app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventCapture/EventPageConfigurator.kt +++ b/app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventCapture/EventPageConfigurator.kt @@ -10,7 +10,7 @@ class EventPageConfigurator( override fun displayDataEntry(): Boolean = isPortrait - override fun displayAnalytics(): Boolean = eventCaptureRepository.hasAnalytics() && !displayTableView() + override fun displayAnalytics(): Boolean = false override fun displayRelationships(): Boolean = eventCaptureRepository.hasRelationships() diff --git a/app/src/main/java/org/dhis2/usescases/teiDashboard/TeiDashboardPageConfigurator.kt b/app/src/main/java/org/dhis2/usescases/teiDashboard/TeiDashboardPageConfigurator.kt index 23a2acf6c0..8bc9009804 100644 --- a/app/src/main/java/org/dhis2/usescases/teiDashboard/TeiDashboardPageConfigurator.kt +++ b/app/src/main/java/org/dhis2/usescases/teiDashboard/TeiDashboardPageConfigurator.kt @@ -8,7 +8,7 @@ class TeiDashboardPageConfigurator( ) : NavigationPageConfigurator { override fun displayDetails(): Boolean = isPortrait - override fun displayAnalytics(): Boolean = !displayTableView() + override fun displayAnalytics(): Boolean = false override fun displayRelationships(): Boolean = dashboardRepository.programHasRelationships() diff --git a/app/src/test/java/org/dhis2/usescases/eventsWithoutRegistration/eventCapture/EventPageConfiguratorTest.kt b/app/src/test/java/org/dhis2/usescases/eventsWithoutRegistration/eventCapture/EventPageConfiguratorTest.kt index 87f0538189..b4023e1df1 100644 --- a/app/src/test/java/org/dhis2/usescases/eventsWithoutRegistration/eventCapture/EventPageConfiguratorTest.kt +++ b/app/src/test/java/org/dhis2/usescases/eventsWithoutRegistration/eventCapture/EventPageConfiguratorTest.kt @@ -26,18 +26,7 @@ class EventPageConfiguratorTest { } @Test - fun `displayAnalytics should be true when analytics are configured and Simprints RAMP history table is not configured`() { - whenever(eventCaptureRepository.hasAnalytics()) doReturn true - whenever(eventCaptureRepository.hasSimprintsRampProgramStageHistoryTable()) doReturn false - - assertTrue(pageConfigurator.displayAnalytics()) - } - - @Test - fun `displayAnalytics should be false when Simprints RAMP history table is configured`() { - whenever(eventCaptureRepository.hasAnalytics()) doReturn true - whenever(eventCaptureRepository.hasSimprintsRampProgramStageHistoryTable()) doReturn true - + fun `displayAnalytics should always be false`() { assertFalse(pageConfigurator.displayAnalytics()) } diff --git a/app/src/test/java/org/dhis2/usescases/teiDashboard/TeiDashboardPageConfiguratorTest.kt b/app/src/test/java/org/dhis2/usescases/teiDashboard/TeiDashboardPageConfiguratorTest.kt index 6ca5598b90..68a48dacff 100644 --- a/app/src/test/java/org/dhis2/usescases/teiDashboard/TeiDashboardPageConfiguratorTest.kt +++ b/app/src/test/java/org/dhis2/usescases/teiDashboard/TeiDashboardPageConfiguratorTest.kt @@ -1,6 +1,7 @@ package org.dhis2.usescases.teiDashboard import org.dhis2.utils.customviews.navigationbar.NavigationPageConfigurator +import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test import org.mockito.kotlin.doReturn @@ -18,6 +19,11 @@ class TeiDashboardPageConfiguratorTest { assertTrue(pageConfigurator.displayDetails()) } + @Test + fun `Should not display the analytics screen`() { + assertFalse(pageConfigurator.displayAnalytics()) + } + @Test fun `Should display relationships screen if the program is configured`() { whenever(dashboardRepository.programHasRelationships()) doReturn true From a93ce939bd41820a07866b58315c575915c4b755 Mon Sep 17 00:00:00 2001 From: Alex Date: Wed, 15 Jul 2026 13:55:02 +0100 Subject: [PATCH 05/13] Simprints RAMP: Org unit code as moduleId in all relevant SID callouts --- .../customintents/CustomIntentRepositoryImpl.kt | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/commonskmm/src/androidMain/kotlin/org/dhis2/mobile/commons/customintents/CustomIntentRepositoryImpl.kt b/commonskmm/src/androidMain/kotlin/org/dhis2/mobile/commons/customintents/CustomIntentRepositoryImpl.kt index 91e426a87b..a20295b055 100644 --- a/commonskmm/src/androidMain/kotlin/org/dhis2/mobile/commons/customintents/CustomIntentRepositoryImpl.kt +++ b/commonskmm/src/androidMain/kotlin/org/dhis2/mobile/commons/customintents/CustomIntentRepositoryImpl.kt @@ -100,27 +100,27 @@ class CustomIntentRepositoryImpl( .settingModule() .customIntentService() .blockingEvaluateRequestParams(customIntent, context) - .overrideSimprintsIdentifyModuleIdWithUserOrgUnit(customIntent) + .overrideSimprintsIdentifyModuleIdWithUserOrgUnitCode(customIntent) - private fun Map.overrideSimprintsIdentifyModuleIdWithUserOrgUnit(customIntent: CustomIntent): Map { + private fun Map.overrideSimprintsIdentifyModuleIdWithUserOrgUnitCode(customIntent: CustomIntent): Map { val simprintsIdentifyAction = "com.simprints.id.IDENTIFY" val simprintsModuleIdKey = "moduleId" if (customIntent.packageName() != simprintsIdentifyAction) { return this } - val orgUnitName = - currentUserOrgUnitName() + val orgUnitCode = + currentUserOrgUnitCode() ?: return this - return this + (simprintsModuleIdKey to orgUnitName) + return this + (simprintsModuleIdKey to orgUnitCode) } - private fun currentUserOrgUnitName(): String? = + private fun currentUserOrgUnitCode(): String? = d2 .organisationUnitModule() .organisationUnits() .byRootOrganisationUnit(true) .blockingGet() - .firstNotNullOfOrNull { it.name()?.takeUnless(String::isBlank) } + .firstNotNullOfOrNull { it.code()?.takeUnless(String::isBlank) } override fun reEvaluateCustomIntentRequestParams( orgUnitUid: String, From c9e2b9e233a43694a28bc17155e8dd4112b429ab Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 23 Jul 2026 13:24:46 +0100 Subject: [PATCH 06/13] Simprints RAMP: ACF-54 Visit number in front of date for follow-up visits --- .../ramp/ui/EventHistoryTableScreen.kt | 2 +- .../teidata/TEIDataModule.kt | 2 + .../teidata/TeiDataRepositoryImpl.kt | 38 ++++++++++++++++++- .../teievents/ui/mapper/TEIEventCardMapper.kt | 10 ++++- app/src/main/res/values/strings.xml | 2 +- .../java/org/dhis2/commons/data/EventModel.kt | 1 + .../ramp/model/RampDatastoreConfig.kt | 4 ++ .../repository/RampDatastoreRepository.kt | 5 ++- .../repository/RampDatastoreRepositoryTest.kt | 4 ++ 9 files changed, 61 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/org/dhis2/simprints/ramp/ui/EventHistoryTableScreen.kt b/app/src/main/java/org/dhis2/simprints/ramp/ui/EventHistoryTableScreen.kt index abe8a8bb2c..26581912df 100644 --- a/app/src/main/java/org/dhis2/simprints/ramp/ui/EventHistoryTableScreen.kt +++ b/app/src/main/java/org/dhis2/simprints/ramp/ui/EventHistoryTableScreen.kt @@ -130,7 +130,7 @@ private fun VisitHeaderRow( ) { Row(modifier = Modifier.heightIntrinsicRow()) { HeaderCell( - text = stringResource(R.string.simprints_ramp_history_table_visit), + text = stringResource(R.string.simprints_ramp_visit), width = RowHeaderWidth, textAlign = TextAlign.Start, ) diff --git a/app/src/main/java/org/dhis2/usescases/teiDashboard/dashboardfragments/teidata/TEIDataModule.kt b/app/src/main/java/org/dhis2/usescases/teiDashboard/dashboardfragments/teidata/TEIDataModule.kt index 58a18a59bb..5becbd71df 100644 --- a/app/src/main/java/org/dhis2/usescases/teiDashboard/dashboardfragments/teidata/TEIDataModule.kt +++ b/app/src/main/java/org/dhis2/usescases/teiDashboard/dashboardfragments/teidata/TEIDataModule.kt @@ -99,6 +99,7 @@ class TEIDataModule( periodUtils: DhisPeriodUtils, metadataIconProvider: MetadataIconProvider, dateUtils: DateUtils, + rampDatastoreRepository: RampDatastoreRepository, ): TeiDataRepository = TeiDataRepositoryImpl( d2, @@ -108,6 +109,7 @@ class TEIDataModule( periodUtils, metadataIconProvider, dateUtils, + rampDatastoreRepository, ) @Provides diff --git a/app/src/main/java/org/dhis2/usescases/teiDashboard/dashboardfragments/teidata/TeiDataRepositoryImpl.kt b/app/src/main/java/org/dhis2/usescases/teiDashboard/dashboardfragments/teidata/TeiDataRepositoryImpl.kt index 2b3fe8ad12..114bf3dd77 100644 --- a/app/src/main/java/org/dhis2/usescases/teiDashboard/dashboardfragments/teidata/TeiDataRepositoryImpl.kt +++ b/app/src/main/java/org/dhis2/usescases/teiDashboard/dashboardfragments/teidata/TeiDataRepositoryImpl.kt @@ -10,6 +10,8 @@ import org.dhis2.commons.data.StageSection import org.dhis2.commons.date.DateUtils import org.dhis2.commons.resources.DhisPeriodUtils import org.dhis2.commons.resources.MetadataIconProvider +import org.dhis2.commons.simprints.ramp.model.RampDatastoreConfig +import org.dhis2.commons.simprints.ramp.repository.RampDatastoreRepository import org.dhis2.mobile.commons.extensions.toColor import org.hisp.dhis.android.core.D2 import org.hisp.dhis.android.core.arch.repositories.scope.RepositoryScope @@ -34,6 +36,7 @@ class TeiDataRepositoryImpl( private val periodUtils: DhisPeriodUtils, private val metadataIconProvider: MetadataIconProvider, private val dateUtils: DateUtils, + private val rampDatastoreRepository: RampDatastoreRepository, ) : TeiDataRepository { override fun getTEIEnrollmentEvents( selectedStage: StageSection, @@ -45,11 +48,12 @@ class TeiDataRepositoryImpl( .events() .byEnrollmentUid() .eq(enrollmentUid) + val rampDatastoreConfig = rampDatastoreRepository.getConfig() return if (groupedByStage) { - getGroupedEvents(eventRepo, selectedStage) + getGroupedEvents(eventRepo, selectedStage, rampDatastoreConfig) } else { - getTimelineEvents(eventRepo, selectedStage.showAllEvents) + getTimelineEvents(eventRepo, selectedStage.showAllEvents, rampDatastoreConfig) } } @@ -192,6 +196,7 @@ class TeiDataRepositoryImpl( private fun getGroupedEvents( eventRepository: EventCollectionRepository, selectedStage: StageSection, + rampDatastoreConfig: RampDatastoreConfig, ): Single> { val eventModels = mutableListOf() var eventRepo: EventCollectionRepository @@ -293,6 +298,7 @@ class TeiDataRepositoryImpl( programStage.style(), program?.style()?.color()?.toColor() ?: SurfaceColor.Primary, ), + followUpVisitNumber = getFollowUpVisitNumber(event, rampDatastoreConfig), ), ) } @@ -331,6 +337,7 @@ class TeiDataRepositoryImpl( private fun getTimelineEvents( eventRepository: EventCollectionRepository, showAllEvents: Boolean, + rampDatastoreConfig: RampDatastoreConfig, ): Single> { val eventModels = mutableListOf() val maxEventToShow = 5 @@ -384,6 +391,7 @@ class TeiDataRepositoryImpl( programUid?.let { displayOrganisationUnit(it) } ?: false, + followUpVisitNumber = getFollowUpVisitNumber(event, rampDatastoreConfig), ), ) } @@ -525,6 +533,32 @@ class TeiDataRepositoryImpl( else -> true } + private fun getFollowUpVisitNumber( + event: Event, + rampDatastoreConfig: RampDatastoreConfig, + ): Int? { + val dataElementId = + rampDatastoreConfig.programStageSpecificSettings + .firstOrNull { setting -> + setting.programStageId == event.programStage() && + setting.hasVisitNumberPrefixForDateInList == true + }?.visitNumberDataElementId ?: return null + val valueRepository = + d2 + .trackedEntityModule() + .trackedEntityDataValues() + .value(event.uid(), dataElementId) + + return if (valueRepository.blockingExists()) { + valueRepository.blockingGet()?.value()?.toVisitNumber() + } else { + null + } + } + + private fun String.toVisitNumber(): Int? = + trim().toIntOrNull()?.takeIf { it >= 0 } + private fun getCatOptionComboName(categoryOptionComboUid: String?): String? = categoryOptionComboUid?.let { d2 diff --git a/app/src/main/java/org/dhis2/usescases/teiDashboard/dashboardfragments/teidata/teievents/ui/mapper/TEIEventCardMapper.kt b/app/src/main/java/org/dhis2/usescases/teiDashboard/dashboardfragments/teidata/teievents/ui/mapper/TEIEventCardMapper.kt index 721f88102c..3cb01ba76f 100644 --- a/app/src/main/java/org/dhis2/usescases/teiDashboard/dashboardfragments/teidata/teievents/ui/mapper/TEIEventCardMapper.kt +++ b/app/src/main/java/org/dhis2/usescases/teiDashboard/dashboardfragments/teidata/teievents/ui/mapper/TEIEventCardMapper.kt @@ -82,8 +82,13 @@ class TEIEventCardMapper( ) } - private fun getTitle(event: EventModel): String = - when (event.event?.status()) { + private fun getTitle(event: EventModel): String { + val visitNumber = event.followUpVisitNumber + if (visitNumber != null) { + return "${resourceManager.getString(R.string.simprints_ramp_visit)} $visitNumber: ${event.displayDate.orEmpty()}" + } + + return when (event.event?.status()) { EventStatus.SCHEDULE -> { resourceManager .getString(R.string.scheduled_for) @@ -92,6 +97,7 @@ class TEIEventCardMapper( else -> event.displayDate ?: "" } + } private fun getDescription(event: EventModel): String? = if (event.groupedByStage == true) { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5edee5b596..8d77975e9b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -441,7 +441,7 @@ Data entry Form Details - Visit + Visit Date No history available View as Bar diff --git a/commons/src/main/java/org/dhis2/commons/data/EventModel.kt b/commons/src/main/java/org/dhis2/commons/data/EventModel.kt index 1fd84cd758..888b209eeb 100644 --- a/commons/src/main/java/org/dhis2/commons/data/EventModel.kt +++ b/commons/src/main/java/org/dhis2/commons/data/EventModel.kt @@ -28,6 +28,7 @@ data class EventModel( val metadataIconData: MetadataIconData, val editable: Boolean = true, val displayOrgUnit: Boolean = true, + val followUpVisitNumber: Int? = null, ) { var isClicked: Boolean = false diff --git a/commons/src/main/java/org/dhis2/commons/simprints/ramp/model/RampDatastoreConfig.kt b/commons/src/main/java/org/dhis2/commons/simprints/ramp/model/RampDatastoreConfig.kt index c9e808c88d..17869e275e 100644 --- a/commons/src/main/java/org/dhis2/commons/simprints/ramp/model/RampDatastoreConfig.kt +++ b/commons/src/main/java/org/dhis2/commons/simprints/ramp/model/RampDatastoreConfig.kt @@ -68,6 +68,10 @@ data class ProgramSpecificSetting( data class ProgramStageSpecificSetting( @SerializedName("programStageId") val programStageId: String? = null, + @SerializedName("hasVisitNumberPrefixForDateInList") + val hasVisitNumberPrefixForDateInList: Boolean? = null, + @SerializedName("visitNumberDataElementId") + val visitNumberDataElementId: String? = null, @SerializedName("isScheduleOptionEnabled") val isScheduleOptionEnabled: Boolean? = null, @SerializedName("isReferOptionEnabled") diff --git a/commons/src/main/java/org/dhis2/commons/simprints/ramp/repository/RampDatastoreRepository.kt b/commons/src/main/java/org/dhis2/commons/simprints/ramp/repository/RampDatastoreRepository.kt index 2d3cf5dba5..aca198f2c2 100644 --- a/commons/src/main/java/org/dhis2/commons/simprints/ramp/repository/RampDatastoreRepository.kt +++ b/commons/src/main/java/org/dhis2/commons/simprints/ramp/repository/RampDatastoreRepository.kt @@ -198,7 +198,10 @@ class RampDatastoreRepository( copy(programId = programId.trimToValue()) private fun ProgramStageSpecificSetting.normalized(): ProgramStageSpecificSetting = - copy(programStageId = programStageId.trimToValue()) + copy( + programStageId = programStageId.trimToValue(), + visitNumberDataElementId = visitNumberDataElementId.trimToValue(), + ) private fun String?.trimToValue(): String? = this?.trim()?.takeIf { it.isNotEmpty() } diff --git a/commons/src/test/kotlin/org/dhis2/commons/simprints/ramp/repository/RampDatastoreRepositoryTest.kt b/commons/src/test/kotlin/org/dhis2/commons/simprints/ramp/repository/RampDatastoreRepositoryTest.kt index f134168683..93e8b361d5 100644 --- a/commons/src/test/kotlin/org/dhis2/commons/simprints/ramp/repository/RampDatastoreRepositoryTest.kt +++ b/commons/src/test/kotlin/org/dhis2/commons/simprints/ramp/repository/RampDatastoreRepositoryTest.kt @@ -114,6 +114,8 @@ class RampDatastoreRepositoryTest { "programStageSpecificSettings": [ { "programStageId": " disabledStage ", + "hasVisitNumberPrefixForDateInList": true, + "visitNumberDataElementId": " visit-number ", "isScheduleOptionEnabled": false, "isReferOptionEnabled": false } @@ -138,6 +140,8 @@ class RampDatastoreRepositoryTest { } assertEquals("disabledProgram", config.programSpecificSettings.single().programId) assertEquals("disabledStage", config.programStageSpecificSettings.single().programStageId) + assertEquals(true, config.programStageSpecificSettings.single().hasVisitNumberPrefixForDateInList) + assertEquals("visit-number", config.programStageSpecificSettings.single().visitNumberDataElementId) } @Test From 56d6418d53e209f20cd6c4da5150024365cfea74 Mon Sep 17 00:00:00 2001 From: Alex Date: Mon, 27 Jul 2026 19:56:01 +0100 Subject: [PATCH 07/13] Simprints RAMP: ACF-57 Datastore-configurable enrolment listings in TEI list in a Program --- .../ramp/data/DetailedEnrollmentRepository.kt | 132 ++++++++ .../ramp/model/DetailedEnrollment.kt | 11 + .../SearchRepositoryImplKt.kt | 17 ++ .../searchTrackEntity/SearchTeiModel.java | 13 + .../ui/mapper/TEICardMapper.kt | 95 +++++- app/src/main/res/values/strings.xml | 4 + .../data/DetailedEnrollmentRepositoryTest.kt | 281 ++++++++++++++++++ .../ui/mapper/TEICardMapperTest.kt | 115 +++++++ .../ramp/model/RampDatastoreConfig.kt | 14 + .../repository/RampDatastoreRepository.kt | 45 ++- .../org/dhis2/commons/ui/ListCardProvider.kt | 62 +++- .../dhis2/commons/ui/model/ListCardUiModel.kt | 2 + .../repository/RampDatastoreRepositoryTest.kt | 73 ++++- 13 files changed, 844 insertions(+), 20 deletions(-) create mode 100644 app/src/main/java/org/dhis2/simprints/ramp/data/DetailedEnrollmentRepository.kt create mode 100644 app/src/main/java/org/dhis2/simprints/ramp/model/DetailedEnrollment.kt create mode 100644 app/src/test/java/org/dhis2/simprints/ramp/data/DetailedEnrollmentRepositoryTest.kt diff --git a/app/src/main/java/org/dhis2/simprints/ramp/data/DetailedEnrollmentRepository.kt b/app/src/main/java/org/dhis2/simprints/ramp/data/DetailedEnrollmentRepository.kt new file mode 100644 index 0000000000..cabf5cab7c --- /dev/null +++ b/app/src/main/java/org/dhis2/simprints/ramp/data/DetailedEnrollmentRepository.kt @@ -0,0 +1,132 @@ +package org.dhis2.simprints.ramp.data + +import org.dhis2.bindings.userFriendlyValue +import org.dhis2.commons.simprints.ramp.model.DetailedEnrollmentListingSettings +import org.dhis2.simprints.ramp.model.DetailedEnrollment +import org.dhis2.tracker.search.model.DomainEnrollment +import org.dhis2.tracker.search.model.DomainProgram +import org.hisp.dhis.android.core.D2 +import org.hisp.dhis.android.core.event.Event +import java.util.Date + +internal class DetailedEnrollmentRepository( + private val d2: D2, +) { + fun get( + enrollments: List?, + programs: List?, + settings: DetailedEnrollmentListingSettings, + excludedProgramUid: String? = null, + ): List { + val includedEnrollments = enrollments?.filter { it.program != excludedProgramUid }.orEmpty() + if (includedEnrollments.isEmpty() || programs.isNullOrEmpty()) return emptyList() + + val programNames = programs.associate { it.uid to it.displayName } + val siteNames = getSiteNames(includedEnrollments) + val eventDetails = getEventDetails(includedEnrollments, settings) + + return includedEnrollments.mapNotNull { enrollment -> + programNames[enrollment.program] + ?.takeIf { it.isNotBlank() } + ?.let { programName -> + val details = eventDetails[enrollment.uid] + DetailedEnrollment( + programName = programName, + admitted = details?.admitted, + discharge = details?.discharge, + outcome = details?.outcome, + site = siteNames[enrollment.orgUnit], + ) + } + } + } + + private fun getSiteNames(enrollments: List): Map { + val orgUnitIds = enrollments.mapNotNull { it.orgUnit }.distinct() + if (orgUnitIds.isEmpty()) return emptyMap() + + return d2 + .organisationUnitModule() + .organisationUnits() + .byUid() + .`in`(orgUnitIds) + .blockingGet() + .mapNotNull { orgUnit -> + val uid = orgUnit.uid() + val name = orgUnit.displayName() + if (uid.isNullOrBlank() || name.isNullOrBlank()) null else uid to name + }.toMap() + } + + private fun getEventDetails( + enrollments: List, + settings: DetailedEnrollmentListingSettings, + ): Map { + if ( + settings.admissionProgramStageIds.isEmpty() && + settings.dischargeProgramStageIds.isEmpty() + ) { + return emptyMap() + } + + val enrollmentIds = enrollments.map { it.uid }.distinct() + if (enrollmentIds.isEmpty()) return emptyMap() + + return d2 + .eventModule() + .events() + .withTrackedEntityDataValues() + .byEnrollmentUid() + .`in`(enrollmentIds) + .byDeleted() + .isFalse + .blockingGet() + .groupBy { it.enrollment() } + .mapNotNull { (enrollmentId, events) -> + enrollmentId + ?.takeIf { it.isNotBlank() } + ?.let { it to events.toEnrollmentEventDetails(settings) } + }.toMap() + } + + private fun List.toEnrollmentEventDetails( + settings: DetailedEnrollmentListingSettings, + ): EnrollmentEventDetails { + val admitted = + filter { it.programStage() in settings.admissionProgramStageIds } + .mapNotNull { it.eventDate() } + .minOrNull() + val dischargeEvents = filter { it.programStage() in settings.dischargeProgramStageIds } + val discharge = dischargeEvents.mapNotNull { it.eventDate() }.maxOrNull() + + return EnrollmentEventDetails( + admitted = admitted, + discharge = discharge, + outcome = dischargeEvents.latestOutcome(settings.dischargeOutcomeDataElementIds), + ) + } + + private fun List.latestOutcome(dischargeOutcomeDataElementIds: Set): String? = + mapNotNull { event -> + event + .trackedEntityDataValues() + .orEmpty() + .firstOrNull { dataValue -> + dataValue.dataElement() in dischargeOutcomeDataElementIds && + !dataValue.value().isNullOrBlank() + }?.let { dataValue -> event to dataValue } + }.maxWithOrNull( + compareBy( + { (event) -> event.eventDate() ?: event.dueDate() ?: event.created() ?: Date(0) }, + { (event) -> event.uid().orEmpty() }, + ), + )?.second + ?.userFriendlyValue(d2) + ?.takeIf { it.isNotBlank() } + + private data class EnrollmentEventDetails( + val admitted: Date?, + val discharge: Date?, + val outcome: String?, + ) +} diff --git a/app/src/main/java/org/dhis2/simprints/ramp/model/DetailedEnrollment.kt b/app/src/main/java/org/dhis2/simprints/ramp/model/DetailedEnrollment.kt new file mode 100644 index 0000000000..76c333b056 --- /dev/null +++ b/app/src/main/java/org/dhis2/simprints/ramp/model/DetailedEnrollment.kt @@ -0,0 +1,11 @@ +package org.dhis2.simprints.ramp.model + +import java.util.Date + +data class DetailedEnrollment( + val programName: String, + val admitted: Date?, + val discharge: Date?, + val outcome: String?, + val site: String?, +) diff --git a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchRepositoryImplKt.kt b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchRepositoryImplKt.kt index 3b720c7656..e842dc36d8 100644 --- a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchRepositoryImplKt.kt +++ b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchRepositoryImplKt.kt @@ -7,11 +7,14 @@ import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.withContext import org.dhis2.commons.filters.FilterManager import org.dhis2.commons.filters.sorting.SortingItem +import org.dhis2.commons.simprints.ramp.model.DetailedEnrollmentListingSettings +import org.dhis2.commons.simprints.ramp.repository.RampDatastoreRepository import org.dhis2.commons.viewmodel.DispatcherProvider import org.dhis2.data.search.SearchParametersModel import org.dhis2.maps.model.MapItemModel import org.dhis2.mobile.commons.customintents.CustomIntentRepository import org.dhis2.mobile.commons.model.CustomIntentActionTypeModel +import org.dhis2.simprints.ramp.data.DetailedEnrollmentRepository import org.dhis2.tracker.input.model.TrackerInputType import org.dhis2.tracker.input.ui.action.FieldUid import org.dhis2.tracker.search.model.TrackedEntitySearchItemResult @@ -38,6 +41,9 @@ class SearchRepositoryImplKt( private val eventInfoProvider: EventInfoProvider, private val customIntentRepository: CustomIntentRepository, ) : SearchRepositoryKt { + private val rampDatastoreRepository = RampDatastoreRepository(d2) + private val detailedEnrollmentRepository = DetailedEnrollmentRepository(d2) + private var detailedEnrollmentListingSettings: DetailedEnrollmentListingSettings? = null private lateinit var savedSearchParameters: SearchParametersModel private lateinit var savedFilters: FilterManager @@ -50,6 +56,8 @@ class SearchRepositoryImplKt( queryData: MutableMap?>?, programUid: String?, ): Boolean { + detailedEnrollmentListingSettings = + rampDatastoreRepository.detailedEnrollmentListingSettings(programUid) if (!this::savedSearchParameters.isInitialized) { savedSearchParameters = SearchParametersModel( @@ -223,6 +231,15 @@ class SearchRepositoryImplKt( ): SearchTeiModel { val searchTeiModel = SearchTeiModel() searchTeiModel.tei = searchItemResult + detailedEnrollmentListingSettings?.let { listingSettings -> + searchTeiModel.detailedEnrollments = + detailedEnrollmentRepository.get( + enrollments = searchItemResult.enrollments, + programs = searchItemResult.enrolledPrograms, + settings = listingSettings, + excludedProgramUid = searchItemResult.selectedEnrollment?.program, + ) + } searchItemResult.enrolledPrograms?.forEach { searchTeiModel.addProgramInfo( it.uid, diff --git a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTeiModel.java b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTeiModel.java index 04573bbf6b..d4adcb42b5 100644 --- a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTeiModel.java +++ b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTeiModel.java @@ -1,6 +1,7 @@ package org.dhis2.usescases.searchTrackEntity; import org.dhis2.mobile.commons.model.MetadataIconData; +import org.dhis2.simprints.ramp.model.DetailedEnrollment; import org.dhis2.tracker.search.model.DomainEnrollment; import org.dhis2.tracker.search.model.TrackedEntitySearchItemAttributeDomain; import org.dhis2.tracker.search.model.TrackedEntitySearchItemResult; @@ -9,6 +10,7 @@ import java.util.HashMap; import java.util.LinkedHashMap; +import java.util.List; public class SearchTeiModel{ @@ -22,6 +24,7 @@ public class SearchTeiModel{ private boolean openedAttributeList = false; private String sortingKey; private String sortingValue; + private List detailedEnrollments; @Nullable public String onlineErrorMessage; @@ -34,6 +37,7 @@ public SearchTeiModel() { this.textAttributeValues = new LinkedHashMap<>(); this.sortingKey = null; this.sortingValue = null; + this.detailedEnrollments = null; this.onlineErrorMessage = null; this.metadataIconDataMap = new HashMap<>(); } @@ -140,5 +144,14 @@ public String getSortingValue() { return sortingValue; } + @Nullable + public List getDetailedEnrollments() { + return detailedEnrollments; + } + + public void setDetailedEnrollments(@Nullable List detailedEnrollments) { + this.detailedEnrollments = detailedEnrollments; + } + } diff --git a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/ui/mapper/TEICardMapper.kt b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/ui/mapper/TEICardMapper.kt index 25fe407e39..e8bae4a5e4 100644 --- a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/ui/mapper/TEICardMapper.kt +++ b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/ui/mapper/TEICardMapper.kt @@ -17,13 +17,20 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.painter.BitmapPainter import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.withStyle import org.dhis2.R import org.dhis2.commons.bindings.isFilePathValid +import org.dhis2.commons.date.DateUtils import org.dhis2.commons.date.toDateSpan import org.dhis2.commons.date.toOverdueOrScheduledUiText import org.dhis2.commons.resources.ResourceManager import org.dhis2.commons.ui.model.ListCardUiModel import org.dhis2.mobile.commons.extensions.toJavaDate +import org.dhis2.simprints.ramp.model.DetailedEnrollment import org.dhis2.tracker.search.model.DomainEnrollment import org.dhis2.tracker.search.model.DomainProgram import org.dhis2.tracker.search.model.EnrollmentStatus @@ -51,12 +58,18 @@ class TEICardMapper( onSyncIconClick: () -> Unit, onCardClick: () -> Unit, onImageClick: (String) -> Unit, - ): ListCardUiModel = - ListCardUiModel( + ): ListCardUiModel { + val programsKey = resourceManager.getString(R.string.programs) + val detailedEnrollmentListing = + searchTEIModel.detailedEnrollments?.toDetailedEnrollmentListing() + + return ListCardUiModel( avatar = { ProvideAvatar(searchTEIModel, onImageClick) }, title = getTitle(searchTEIModel), lastUpdated = searchTEIModel.tei.lastUpdated?.toJavaDate().toDateSpan(context), - additionalInfo = getAdditionalInfoList(searchTEIModel), + additionalInfo = getAdditionalInfoList(searchTEIModel, detailedEnrollmentListing), + styledAdditionalInfoValues = + detailedEnrollmentListing?.let { mapOf(programsKey to it) }.orEmpty(), emphasizedAdditionalInfoKey = resourceManager.getString(R.string.transferredTo).takeIf { searchTEIModel.tei.ownerOrgUnit != null && @@ -67,6 +80,7 @@ class TEICardMapper( shrinkLabelText = resourceManager.getString(R.string.show_less), onCardCLick = onCardClick, ) + } @Composable private fun ProvideAvatar( @@ -130,7 +144,10 @@ class TEICardMapper( "$key: $value" } - private fun getAdditionalInfoList(searchTEIModel: SearchTeiModel): List { + private fun getAdditionalInfoList( + searchTEIModel: SearchTeiModel, + detailedEnrollmentListing: AnnotatedString?, + ): List { val attributeList = searchTEIModel.tei.attributeValues .map { @@ -160,11 +177,19 @@ class TEICardMapper( enrolledOrgUnit = searchTEIModel.tei.enrollmentOrgUnit, ) } - - checkEnrolledPrograms( - list = list, - enrolledPrograms = searchTEIModel.tei.enrolledPrograms, - ) + if (searchTEIModel.detailedEnrollments != null) { + detailedEnrollmentListing?.let { + addDetailedEnrollmentListing( + list = list, + detailedEnrollmentListing = it.text, + ) + } + } else { + checkEnrolledPrograms( + list = list, + enrolledPrograms = searchTEIModel.tei.enrolledPrograms, + ) + } val programUid: String? = if (searchTEIModel.selectedEnrollment != null) { searchTEIModel.selectedEnrollment.program @@ -292,6 +317,58 @@ class TEICardMapper( } } + private fun addDetailedEnrollmentListing( + list: MutableList, + detailedEnrollmentListing: String, + ) { + list.add( + AdditionalInfoItem( + key = resourceManager.getString(R.string.programs), + value = detailedEnrollmentListing, + isConstantItem = true, + truncate = false, + ), + ) + } + + private fun List.toDetailedEnrollmentListing(): AnnotatedString? { + val namedEnrollments = filter { it.programName.isNotBlank() } + if (namedEnrollments.isEmpty()) return null + val admittedLabel = resourceManager.getString(R.string.simprints_ramp_admitted) + val dischargedLabel = resourceManager.getString(R.string.simprints_ramp_discharged) + val outcomeLabel = resourceManager.getString(R.string.simprints_ramp_outcome) + val siteLabel = resourceManager.getString(R.string.simprints_ramp_site) + val dateFormat = DateUtils.oldUiDateFormat() + + // attributes are optional, so we are not using a string resource with params here + return buildAnnotatedString { + namedEnrollments.forEachIndexed { index, enrollment -> + if (index > 0) append(". ") + withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { + append(enrollment.programName) + } + + val details = + buildList { + enrollment.admitted + ?.let { add("$admittedLabel: ${dateFormat.format(it)}") } + enrollment.discharge + ?.let { add("$dischargedLabel: ${dateFormat.format(it)}") } + enrollment.outcome + ?.takeIf { it.isNotBlank() } + ?.let { add("$outcomeLabel: $it") } + enrollment.site + ?.takeIf { it.isNotBlank() } + ?.let { add("$siteLabel: $it") } + } + if (details.isNotEmpty()) { + append(": ${details.joinToString(", ")}") + } + } + append(".") + } + } + private fun checkEnrolledIn( list: MutableList, enrolledOrgUnit: String?, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8d77975e9b..fe4cb18338 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -98,6 +98,10 @@ Enrollment list Active programs + admitted + discharged + outcome + site Enroll diff --git a/app/src/test/java/org/dhis2/simprints/ramp/data/DetailedEnrollmentRepositoryTest.kt b/app/src/test/java/org/dhis2/simprints/ramp/data/DetailedEnrollmentRepositoryTest.kt new file mode 100644 index 0000000000..c88504fb2d --- /dev/null +++ b/app/src/test/java/org/dhis2/simprints/ramp/data/DetailedEnrollmentRepositoryTest.kt @@ -0,0 +1,281 @@ +package org.dhis2.simprints.ramp.data + +import org.dhis2.commons.simprints.ramp.model.DetailedEnrollmentListingSettings +import org.dhis2.simprints.ramp.model.DetailedEnrollment +import org.dhis2.tracker.search.model.DomainEnrollment +import org.dhis2.tracker.search.model.DomainObjectStyle +import org.dhis2.tracker.search.model.DomainProgram +import org.dhis2.tracker.search.model.EnrollmentStatus +import org.hisp.dhis.android.core.D2 +import org.hisp.dhis.android.core.arch.repositories.filters.internal.BooleanFilterConnector +import org.hisp.dhis.android.core.arch.repositories.filters.internal.StringFilterConnector +import org.hisp.dhis.android.core.common.ObjectWithUid +import org.hisp.dhis.android.core.common.ValueType +import org.hisp.dhis.android.core.dataelement.DataElement +import org.hisp.dhis.android.core.event.Event +import org.hisp.dhis.android.core.event.EventCollectionRepository +import org.hisp.dhis.android.core.organisationunit.OrganisationUnit +import org.hisp.dhis.android.core.organisationunit.OrganisationUnitCollectionRepository +import org.hisp.dhis.android.core.trackedentity.TrackedEntityDataValue +import org.junit.Assert.assertEquals +import org.junit.Test +import org.mockito.Mockito +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import java.util.Date +import kotlin.time.Instant + +class DetailedEnrollmentRepositoryTest { + private val d2: D2 = Mockito.mock(D2::class.java, Mockito.RETURNS_DEEP_STUBS) + private val organisationUnits: OrganisationUnitCollectionRepository = mock() + private val organisationUnitFilter: + StringFilterConnector = mock() + private val matchedOrganisationUnits: OrganisationUnitCollectionRepository = mock() + private val events: EventCollectionRepository = mock() + private val eventsWithDataValues: EventCollectionRepository = mock() + private val enrollmentFilter: StringFilterConnector = mock() + private val enrollmentEvents: EventCollectionRepository = mock() + private val deletedFilter: BooleanFilterConnector = mock() + private val activeEvents: EventCollectionRepository = mock() + private val settings = + DetailedEnrollmentListingSettings( + dischargeOutcomeDataElementIds = + setOf(OTHER_OUTCOME_DATA_ELEMENT_UID, OUTCOME_DATA_ELEMENT_UID), + admissionProgramStageIds = setOf(ADMISSION_PROGRAM_STAGE_UID, OTHER_ADMISSION_STAGE_UID), + dischargeProgramStageIds = setOf(DISCHARGE_PROGRAM_STAGE_UID, OTHER_DISCHARGE_STAGE_UID), + ) + + @Test + fun `should return assembled enrollment data from d2`() { + val admitted = Date(1_000) + val discharge = Date(4_000) + val enrollment = + DomainEnrollment( + uid = ENROLLMENT_UID, + orgUnit = ORG_UNIT_UID, + program = PROGRAM_UID, + enrollmentDate = Instant.parse("2020-01-01T00:00:00Z"), + incidentDate = null, + completedDate = Instant.parse("2030-01-01T00:00:00Z"), + followUp = false, + status = EnrollmentStatus.COMPLETED, + trackedEntityInstance = "tei", + ) + val program = + DomainProgram( + uid = PROGRAM_UID, + displayName = "OTP", + style = DomainObjectStyle(icon = null, color = null), + ) + stubOrganisationUnits() + stubEvents() + whenever( + d2 + .dataElementModule() + .dataElements() + .uid(OUTCOME_DATA_ELEMENT_UID) + .blockingGet(), + ) doReturn + DataElement + .builder() + .uid(OUTCOME_DATA_ELEMENT_UID) + .categoryCombo(ObjectWithUid.create("category-combo")) + .valueType(ValueType.TEXT) + .build() + + val result = + DetailedEnrollmentRepository(d2).get( + enrollments = listOf(enrollment), + programs = listOf(program), + settings = settings, + ) + + assertEquals( + listOf( + DetailedEnrollment( + programName = "OTP", + admitted = admitted, + discharge = discharge, + outcome = "Cured", + site = "Site1", + ), + ), + result, + ) + } + + @Test + fun `should return no details without enrollments`() { + assertEquals( + emptyList(), + DetailedEnrollmentRepository(d2).get(null, null, settings), + ) + } + + @Test + fun `should omit event details without configured ids`() { + val enrollment = + DomainEnrollment( + uid = ENROLLMENT_UID, + orgUnit = null, + program = PROGRAM_UID, + enrollmentDate = null, + incidentDate = null, + completedDate = null, + followUp = false, + status = EnrollmentStatus.ACTIVE, + trackedEntityInstance = "tei", + ) + val program = + DomainProgram( + uid = PROGRAM_UID, + displayName = "OTP", + style = DomainObjectStyle(icon = null, color = null), + ) + + assertEquals( + listOf( + DetailedEnrollment( + programName = "OTP", + admitted = null, + discharge = null, + outcome = null, + site = null, + ), + ), + DetailedEnrollmentRepository(d2).get( + enrollments = listOf(enrollment), + programs = listOf(program), + settings = + DetailedEnrollmentListingSettings( + dischargeOutcomeDataElementIds = emptySet(), + admissionProgramStageIds = emptySet(), + dischargeProgramStageIds = emptySet(), + ), + ), + ) + } + + @Test + fun `should exclude enrollments in viewed program`() { + val enrollment = + DomainEnrollment( + uid = ENROLLMENT_UID, + orgUnit = ORG_UNIT_UID, + program = PROGRAM_UID, + enrollmentDate = null, + incidentDate = null, + completedDate = null, + followUp = false, + status = EnrollmentStatus.ACTIVE, + trackedEntityInstance = "tei", + ) + val program = + DomainProgram( + uid = PROGRAM_UID, + displayName = "General Registration", + style = DomainObjectStyle(icon = null, color = null), + ) + + assertEquals( + emptyList(), + DetailedEnrollmentRepository(d2).get( + enrollments = listOf(enrollment), + programs = listOf(program), + settings = settings, + excludedProgramUid = PROGRAM_UID, + ), + ) + } + + private fun stubOrganisationUnits() { + whenever(d2.organisationUnitModule().organisationUnits()) doReturn organisationUnits + whenever(organisationUnits.byUid()) doReturn organisationUnitFilter + whenever(organisationUnitFilter.`in`(listOf(ORG_UNIT_UID))) doReturn matchedOrganisationUnits + whenever(matchedOrganisationUnits.blockingGet()) doReturn + listOf( + OrganisationUnit + .builder() + .uid(ORG_UNIT_UID) + .displayName("Site1") + .build(), + ) + } + + private fun stubEvents() { + whenever(d2.eventModule().events()) doReturn events + whenever(events.withTrackedEntityDataValues()) doReturn eventsWithDataValues + whenever(eventsWithDataValues.byEnrollmentUid()) doReturn enrollmentFilter + whenever(enrollmentFilter.`in`(listOf(ENROLLMENT_UID))) doReturn enrollmentEvents + whenever(enrollmentEvents.byDeleted()) doReturn deletedFilter + whenever(deletedFilter.isFalse) doReturn activeEvents + whenever(activeEvents.blockingGet()) doReturn + listOf( + event( + uid = "later-admission", + programStage = OTHER_ADMISSION_STAGE_UID, + date = Date(2_000), + ), + event( + uid = "earlier-admission", + programStage = ADMISSION_PROGRAM_STAGE_UID, + date = Date(1_000), + ), + event( + uid = "earlier-discharge", + programStage = OTHER_DISCHARGE_STAGE_UID, + date = Date(3_000), + outcome = "Relapse", + ), + event( + uid = "later-discharge", + programStage = DISCHARGE_PROGRAM_STAGE_UID, + date = Date(4_000), + outcome = "Cured", + ), + event( + uid = "ignored-stage", + programStage = "ignoredStage", + date = Date(5_000), + outcome = "Ignored", + ), + ) + } + + private fun event( + uid: String, + programStage: String, + date: Date, + outcome: String? = null, + ): Event = + Event + .builder() + .uid(uid) + .enrollment(ENROLLMENT_UID) + .programStage(programStage) + .eventDate(date) + .trackedEntityDataValues( + outcome?.let { + listOf( + TrackedEntityDataValue + .builder() + .dataElement(OUTCOME_DATA_ELEMENT_UID) + .value(it) + .build(), + ) + }.orEmpty(), + ).build() + + private companion object { + const val ENROLLMENT_UID = "enrollment" + const val PROGRAM_UID = "program" + const val ORG_UNIT_UID = "org-unit" + const val OUTCOME_DATA_ELEMENT_UID = "outcome1" + const val OTHER_OUTCOME_DATA_ELEMENT_UID = "outcome2" + const val ADMISSION_PROGRAM_STAGE_UID = "admissionStage1" + const val OTHER_ADMISSION_STAGE_UID = "admissionStage2" + const val DISCHARGE_PROGRAM_STAGE_UID = "dischargeStage1" + const val OTHER_DISCHARGE_STAGE_UID = "dischargeStage2" + } +} diff --git a/app/src/test/java/org/dhis2/usescases/searchTrackEntity/ui/mapper/TEICardMapperTest.kt b/app/src/test/java/org/dhis2/usescases/searchTrackEntity/ui/mapper/TEICardMapperTest.kt index 33389fd06d..6207d99479 100644 --- a/app/src/test/java/org/dhis2/usescases/searchTrackEntity/ui/mapper/TEICardMapperTest.kt +++ b/app/src/test/java/org/dhis2/usescases/searchTrackEntity/ui/mapper/TEICardMapperTest.kt @@ -1,6 +1,8 @@ package org.dhis2.usescases.searchTrackEntity.ui.mapper import android.content.Context +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontWeight import org.dhis2.R import org.dhis2.commons.date.DateUtils import org.dhis2.commons.date.toDateSpan @@ -8,6 +10,7 @@ import org.dhis2.commons.date.toOverdueOrScheduledUiText import org.dhis2.commons.resources.ResourceManager import org.dhis2.mobile.commons.extensions.toJavaDate import org.dhis2.mobile.commons.extensions.toKtxInstant +import org.dhis2.simprints.ramp.model.DetailedEnrollment import org.dhis2.tracker.input.model.TrackerInputType import org.dhis2.tracker.search.model.DomainEnrollment import org.dhis2.tracker.search.model.DomainObjectStyle @@ -21,6 +24,7 @@ import org.dhis2.tracker.search.model.TrackedEntityTypeAttributeDomain import org.dhis2.tracker.search.model.TrackedEntityTypeDomain import org.dhis2.usescases.searchTrackEntity.SearchTeiModel import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull import org.junit.Before @@ -65,6 +69,11 @@ class TEICardMapperTest { whenever(resourceManager.getString(R.string.show_less)) doReturn "Show less" whenever(resourceManager.getString(R.string.completed)) doReturn "Completed" whenever(resourceManager.getString(R.string.enrolledIn)) doReturn "Enrolled in" + whenever(resourceManager.getString(R.string.programs)) doReturn "Programs" + whenever(resourceManager.getString(R.string.simprints_ramp_admitted)) doReturn "admitted" + whenever(resourceManager.getString(R.string.simprints_ramp_discharged)) doReturn "discharged" + whenever(resourceManager.getString(R.string.simprints_ramp_outcome)) doReturn "outcome" + whenever(resourceManager.getString(R.string.simprints_ramp_site)) doReturn "site" whenever(resourceManager.getString(R.string.transferredTo)) doReturn "Transferred to" whenever( resourceManager.formatWithEnrollmentLabel(any(), any(), any(), any()), @@ -134,6 +143,112 @@ class TEICardMapperTest { assertEquals(result.additionalInfo[1].value, model.tei.enrollmentOrgUnit) } + @Test + fun shouldShowDetailedEnrollmentListingInsteadOfDefaultProgramsRow() { + val model = createFakeModel() + model.detailedEnrollments = + listOf( + DetailedEnrollment( + programName = "OTP", + admitted = Instant.parse("2025-12-12T00:00:00Z").toJavaDate(), + discharge = Instant.parse("2026-12-12T00:00:00Z").toJavaDate(), + outcome = "Cured", + site = "Site1", + ), + DetailedEnrollment( + programName = "RAMP", + admitted = Instant.parse("2025-12-14T00:00:00Z").toJavaDate(), + discharge = Instant.parse("2026-12-13T00:00:00Z").toJavaDate(), + outcome = "Cured", + site = "Site1", + ), + ) + + val result = + mapper.map( + searchTEIModel = model, + onSyncIconClick = {}, + onCardClick = {}, + onImageClick = {}, + ) + + assertEquals("Enrolled in", result.additionalInfo[2].key) + assertEquals(model.tei.enrollmentOrgUnit, result.additionalInfo[2].value) + assertEquals("Programs", result.additionalInfo[3].key) + assertEquals( + "OTP: admitted: 2025-12-12, discharged: 2026-12-12, " + + "outcome: Cured, site: Site1. RAMP: admitted: 2025-12-14, " + + "discharged: 2026-12-13, outcome: Cured, site: Site1.", + result.additionalInfo[3].value, + ) + val styledValue = result.styledAdditionalInfoValues.getValue("Programs") + assertEquals(result.additionalInfo[3].value, styledValue.text) + assertEquals( + listOf("OTP", "RAMP"), + styledValue.spanStyles + .filter { it.item.fontWeight == FontWeight.Bold } + .map { styledValue.text.substring(it.start, it.end) }, + ) + assertFalse(result.additionalInfo[3].truncate) + assertFalse( + result.additionalInfo.any { + it.key == "Programs" && it.value == "Program 1, Program 2" + }, + ) + } + + @Test + fun shouldOmitUnavailableDetailedEnrollmentFields() { + val model = createFakeModel() + model.detailedEnrollments = + listOf( + DetailedEnrollment( + programName = "OTP", + admitted = Instant.parse("2025-12-12T00:00:00Z").toJavaDate(), + discharge = null, + outcome = "", + site = "Site1", + ), + DetailedEnrollment( + programName = "RAMP", + admitted = null, + discharge = null, + outcome = null, + site = null, + ), + ) + + val result = + mapper.map( + searchTEIModel = model, + onSyncIconClick = {}, + onCardClick = {}, + onImageClick = {}, + ) + + assertEquals( + "OTP: admitted: 2025-12-12, site: Site1. RAMP.", + result.additionalInfo[3].value, + ) + } + + @Test + fun shouldNotFallBackToDefaultProgramsWhenNoOtherEnrollmentExists() { + val model = createFakeModel() + model.detailedEnrollments = emptyList() + + val result = + mapper.map( + searchTEIModel = model, + onSyncIconClick = {}, + onCardClick = {}, + onImageClick = {}, + ) + + assertFalse(result.additionalInfo.any { it.key == "Programs" }) + assertEquals(emptyMap(), result.styledAdditionalInfoValues) + } + @Test fun shouldShowOverDueLabel() { val overdueDate = DateUtils.getInstance().calendar diff --git a/commons/src/main/java/org/dhis2/commons/simprints/ramp/model/RampDatastoreConfig.kt b/commons/src/main/java/org/dhis2/commons/simprints/ramp/model/RampDatastoreConfig.kt index 17869e275e..195cf2c2a5 100644 --- a/commons/src/main/java/org/dhis2/commons/simprints/ramp/model/RampDatastoreConfig.kt +++ b/commons/src/main/java/org/dhis2/commons/simprints/ramp/model/RampDatastoreConfig.kt @@ -61,10 +61,24 @@ data class ProgramSpecificSetting( val programId: String? = null, @SerializedName("isSearchEnabled") val isSearchEnabled: Boolean? = null, + @SerializedName("hasDetailedEnrollmentListing") + val hasDetailedEnrollmentListing: Boolean? = null, + @SerializedName("detailedEnrollmentListingDischargeOutcomeDataElementIds") + val detailedEnrollmentListingDischargeOutcomeDataElementIds: List? = null, + @SerializedName("detailedEnrollmentListingAdmissionProgramStageIds") + val detailedEnrollmentListingAdmissionProgramStageIds: List? = null, + @SerializedName("detailedEnrollmentListingDischargeProgramStageIds") + val detailedEnrollmentListingDischargeProgramStageIds: List? = null, ) { fun isValid(): Boolean = !programId.isNullOrBlank() } +data class DetailedEnrollmentListingSettings( + val dischargeOutcomeDataElementIds: Set, + val admissionProgramStageIds: Set, + val dischargeProgramStageIds: Set, +) + data class ProgramStageSpecificSetting( @SerializedName("programStageId") val programStageId: String? = null, diff --git a/commons/src/main/java/org/dhis2/commons/simprints/ramp/repository/RampDatastoreRepository.kt b/commons/src/main/java/org/dhis2/commons/simprints/ramp/repository/RampDatastoreRepository.kt index aca198f2c2..986cbba084 100644 --- a/commons/src/main/java/org/dhis2/commons/simprints/ramp/repository/RampDatastoreRepository.kt +++ b/commons/src/main/java/org/dhis2/commons/simprints/ramp/repository/RampDatastoreRepository.kt @@ -5,6 +5,7 @@ import com.google.gson.JsonElement import com.google.gson.JsonParseException import com.google.gson.JsonParser import org.dhis2.commons.simprints.ramp.model.DataElementHistoryChartConfig +import org.dhis2.commons.simprints.ramp.model.DetailedEnrollmentListingSettings import org.dhis2.commons.simprints.ramp.model.ProgramSpecificSetting import org.dhis2.commons.simprints.ramp.model.ProgramStageHistoryTableConfig import org.dhis2.commons.simprints.ramp.model.ProgramStageSpecificSetting @@ -53,6 +54,37 @@ class RampDatastoreRepository( } } + fun detailedEnrollmentListingSettings(programId: String?): DetailedEnrollmentListingSettings? { + val normalizedProgramId = programId.trimToValue() ?: return null + + return try { + getConfig() + .programSpecificSettings + .firstOrNull { + it.programId == normalizedProgramId && + it.hasDetailedEnrollmentListing == true + }?.let { + DetailedEnrollmentListingSettings( + dischargeOutcomeDataElementIds = + it.detailedEnrollmentListingDischargeOutcomeDataElementIds + .orEmpty() + .toSet(), + admissionProgramStageIds = + it.detailedEnrollmentListingAdmissionProgramStageIds + .orEmpty() + .toSet(), + dischargeProgramStageIds = + it.detailedEnrollmentListingDischargeProgramStageIds + .orEmpty() + .toSet(), + ) + } + } catch (exception: RuntimeException) { + Timber.e(exception, RAMP_DATASTORE_PARSE_ERROR) + null + } + } + fun isScheduleOptionEnabled(programStageId: String?): Boolean = isProgramStageOptionEnabled(programStageId) { isScheduleOptionEnabled } fun isReferOptionEnabled(programStageId: String?): Boolean = isProgramStageOptionEnabled(programStageId) { isReferOptionEnabled } @@ -195,7 +227,18 @@ class RampDatastoreRepository( ) private fun ProgramSpecificSetting.normalized(): ProgramSpecificSetting = - copy(programId = programId.trimToValue()) + copy( + programId = programId.trimToValue(), + detailedEnrollmentListingDischargeOutcomeDataElementIds = + detailedEnrollmentListingDischargeOutcomeDataElementIds + ?.mapNotNull { it.trimToValue() }, + detailedEnrollmentListingAdmissionProgramStageIds = + detailedEnrollmentListingAdmissionProgramStageIds + ?.mapNotNull { it.trimToValue() }, + detailedEnrollmentListingDischargeProgramStageIds = + detailedEnrollmentListingDischargeProgramStageIds + ?.mapNotNull { it.trimToValue() }, + ) private fun ProgramStageSpecificSetting.normalized(): ProgramStageSpecificSetting = copy( diff --git a/commons/src/main/java/org/dhis2/commons/ui/ListCardProvider.kt b/commons/src/main/java/org/dhis2/commons/ui/ListCardProvider.kt index 785e95da3a..c46d4359a4 100644 --- a/commons/src/main/java/org/dhis2/commons/ui/ListCardProvider.kt +++ b/commons/src/main/java/org/dhis2/commons/ui/ListCardProvider.kt @@ -14,11 +14,13 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.Placeholder import androidx.compose.ui.text.PlaceholderVerticalAlign import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.rememberTextMeasurer import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -29,6 +31,7 @@ import org.hisp.dhis.mobile.ui.designsystem.component.ListCardDescriptionModel import org.hisp.dhis.mobile.ui.designsystem.component.ListCardTitleModel import org.hisp.dhis.mobile.ui.designsystem.component.ProvideKeyValueItem import org.hisp.dhis.mobile.ui.designsystem.component.ToggleInfoTextButton +import org.hisp.dhis.mobile.ui.designsystem.component.getKeyTrimmedText import org.hisp.dhis.mobile.ui.designsystem.component.getKeyValueAnnotatedString import org.hisp.dhis.mobile.ui.designsystem.component.state.rememberAdditionalInfoColumnState import org.hisp.dhis.mobile.ui.designsystem.component.state.rememberListCardState @@ -43,6 +46,8 @@ fun ListCardProvider( @StringRes syncingResourceId: Int, ) { val emphasizedKey = card.emphasizedAdditionalInfoKey + val hasCustomAdditionalInfo = + emphasizedKey != null || card.styledAdditionalInfoValues.isNotEmpty() ListCard( modifier = modifier, @@ -53,7 +58,8 @@ fun ListCardProvider( lastUpdated = card.lastUpdated, additionalInfoColumnState = rememberAdditionalInfoColumnState( - additionalInfoList = card.additionalInfo.takeIf { emphasizedKey == null } ?: emptyList(), + additionalInfoList = + card.additionalInfo.takeUnless { hasCustomAdditionalInfo } ?: emptyList(), syncProgressItem = AdditionalInfoItem( key = stringResource(id = syncingResourceId), @@ -68,13 +74,14 @@ fun ListCardProvider( listAvatar = card.avatar, onCardClick = card.onCardCLick, actionButton = - if (emphasizedKey == null) { + if (!hasCustomAdditionalInfo) { card.actionButton } else { { EmphasizedAdditionalInfoColumn( additionalInfo = card.additionalInfo, emphasizedKey = emphasizedKey, + styledValues = card.styledAdditionalInfoValues, expandLabelText = card.expandLabelText, shrinkLabelText = card.shrinkLabelText, ) @@ -87,7 +94,8 @@ fun ListCardProvider( @Composable fun EmphasizedAdditionalInfoColumn( additionalInfo: List, - emphasizedKey: String, + emphasizedKey: String?, + styledValues: Map = emptyMap(), expandLabelText: String, shrinkLabelText: String, isDetailCard: Boolean = false, @@ -104,12 +112,22 @@ fun EmphasizedAdditionalInfoColumn( Column(Modifier.testTag("LIST_CARD_ADDITIONAL_INFO_COLUMN")) { visibleItems.forEach { - AdditionalInfoRow(it, false, isDetailCard) + AdditionalInfoRow( + item = it, + emphasized = false, + styledValue = it.key?.let(styledValues::get), + isDetailCard = isDetailCard, + ) } } Column(Modifier.testTag("LIST_CARD_ADDITIONAL_INFO_CONSTANT_COLUMN")) { columnState.constantItemList().forEach { - AdditionalInfoRow(it, it.key == emphasizedKey, isDetailCard) + AdditionalInfoRow( + item = it, + emphasized = it.key == emphasizedKey, + styledValue = it.key?.let(styledValues::get), + isDetailCard = isDetailCard, + ) } } if (columnState.showExpandableContent()) { @@ -126,14 +144,35 @@ fun EmphasizedAdditionalInfoColumn( private fun AdditionalInfoRow( item: AdditionalInfoItem, emphasized: Boolean, + styledValue: AnnotatedString?, isDetailCard: Boolean, ) { BoxWithConstraints(Modifier.fillMaxWidth()) { - if (emphasized) { + if (emphasized || styledValue != null) { + val keyText = + getKeyTrimmedText( + item.key.orEmpty(), + maxWidth / 2 - Spacing.Spacing16, + rememberTextMeasurer(), + ) + val keyValueText = getKeyValueAnnotatedString(keyText, item, isDetailCard) val text = buildAnnotatedString { - append(getKeyValueAnnotatedString(item.key ?: "", item, false)) - addStyle(SpanStyle(fontWeight = FontWeight.Bold), 0, length) + append(keyValueText) + if (emphasized) { + addStyle(SpanStyle(fontWeight = FontWeight.Bold), 0, length) + } else if (styledValue?.text == item.value) { + val valueStart = keyValueText.text.lastIndexOf(item.value) + if (valueStart >= 0) { + styledValue.spanStyles.forEach { range -> + addStyle( + style = range.item, + start = valueStart + range.start, + end = valueStart + range.end, + ) + } + } + } } Text( text = text, @@ -155,7 +194,12 @@ private fun AdditionalInfoRow( color = item.color ?: TextColor.OnSurface, style = MaterialTheme.typography.bodyMedium.copy( - fontWeight = FontWeight.Bold, + fontWeight = + if (emphasized) { + FontWeight.Bold + } else { + MaterialTheme.typography.bodyMedium.fontWeight + }, lineHeight = 20.sp, ), overflow = if (item.truncate) TextOverflow.Ellipsis else TextOverflow.Clip, diff --git a/commons/src/main/java/org/dhis2/commons/ui/model/ListCardUiModel.kt b/commons/src/main/java/org/dhis2/commons/ui/model/ListCardUiModel.kt index b0f36bf8e6..f54d7cf728 100644 --- a/commons/src/main/java/org/dhis2/commons/ui/model/ListCardUiModel.kt +++ b/commons/src/main/java/org/dhis2/commons/ui/model/ListCardUiModel.kt @@ -1,6 +1,7 @@ package org.dhis2.commons.ui.model import androidx.compose.runtime.Composable +import androidx.compose.ui.text.AnnotatedString import org.hisp.dhis.mobile.ui.designsystem.component.AdditionalInfoItem data class ListCardUiModel( @@ -9,6 +10,7 @@ data class ListCardUiModel( val description: String? = null, val lastUpdated: String? = null, val additionalInfo: List, + val styledAdditionalInfoValues: Map = emptyMap(), val emphasizedAdditionalInfoKey: String? = null, val actionButton: @Composable (() -> Unit), val expandLabelText: String, diff --git a/commons/src/test/kotlin/org/dhis2/commons/simprints/ramp/repository/RampDatastoreRepositoryTest.kt b/commons/src/test/kotlin/org/dhis2/commons/simprints/ramp/repository/RampDatastoreRepositoryTest.kt index 93e8b361d5..5a34961cd7 100644 --- a/commons/src/test/kotlin/org/dhis2/commons/simprints/ramp/repository/RampDatastoreRepositoryTest.kt +++ b/commons/src/test/kotlin/org/dhis2/commons/simprints/ramp/repository/RampDatastoreRepositoryTest.kt @@ -108,7 +108,11 @@ class RampDatastoreRepositoryTest { "programSpecificSettings": [ { "programId": " disabledProgram ", - "isSearchEnabled": false + "isSearchEnabled": false, + "hasDetailedEnrollmentListing": true, + "detailedEnrollmentListingDischargeOutcomeDataElementIds": [" outcome ", " "], + "detailedEnrollmentListingAdmissionProgramStageIds": [" admission ", " "], + "detailedEnrollmentListingDischargeProgramStageIds": [" discharge ", " "] } ], "programStageSpecificSettings": [ @@ -139,6 +143,22 @@ class RampDatastoreRepositoryTest { assertEquals(listOf("excluded"), table.excludedFollowUpVisitDataElementIds) } assertEquals("disabledProgram", config.programSpecificSettings.single().programId) + assertEquals(true, config.programSpecificSettings.single().hasDetailedEnrollmentListing) + assertEquals( + listOf("outcome"), + config.programSpecificSettings.single() + .detailedEnrollmentListingDischargeOutcomeDataElementIds, + ) + assertEquals( + listOf("admission"), + config.programSpecificSettings.single() + .detailedEnrollmentListingAdmissionProgramStageIds, + ) + assertEquals( + listOf("discharge"), + config.programSpecificSettings.single() + .detailedEnrollmentListingDischargeProgramStageIds, + ) assertEquals("disabledStage", config.programStageSpecificSettings.single().programStageId) assertEquals(true, config.programStageSpecificSettings.single().hasVisitNumberPrefixForDateInList) assertEquals("visit-number", config.programStageSpecificSettings.single().visitNumberDataElementId) @@ -175,6 +195,57 @@ class RampDatastoreRepositoryTest { assertEquals(true, repository.isSearchEnabled(" ")) } + @Test + fun `detailed enrollment listing should be enabled only for configured program`() { + stubRampConfigRawValue( + """ + { + "programSpecificSettings": [ + { + "programId": "program", + "hasDetailedEnrollmentListing": true, + "detailedEnrollmentListingDischargeOutcomeDataElementIds": [ + "outcome1", + "outcome2" + ], + "detailedEnrollmentListingAdmissionProgramStageIds": [ + "admissionStage1", + "admissionStage2" + ], + "detailedEnrollmentListingDischargeProgramStageIds": [ + "dischargeStage1", + "dischargeStage2" + ] + }, + { + "programId": "disabledProgram", + "hasDetailedEnrollmentListing": false + } + ] + } + """.trimIndent(), + ) + + val settings = repository.detailedEnrollmentListingSettings("program") + + assertEquals( + setOf("outcome1", "outcome2"), + settings?.dischargeOutcomeDataElementIds, + ) + assertEquals( + setOf("admissionStage1", "admissionStage2"), + settings?.admissionProgramStageIds, + ) + assertEquals( + setOf("dischargeStage1", "dischargeStage2"), + settings?.dischargeProgramStageIds, + ) + assertEquals(null, repository.detailedEnrollmentListingSettings("disabledProgram")) + assertEquals(null, repository.detailedEnrollmentListingSettings("missingProgram")) + assertEquals(null, repository.detailedEnrollmentListingSettings(null)) + assertEquals(null, repository.detailedEnrollmentListingSettings(" ")) + } + @Test fun `program stage options should return false only when stage disables them`() { stubRampConfigRawValue( From 55a3bcd033fe0887131a446616d3005e24876bc7 Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 28 Jul 2026 11:48:44 +0100 Subject: [PATCH 08/13] Simprints RAMP: ACF-63 Forms non-collapsible when mandatory fields not completed --- .../org/dhis2/form/data/FormRepositoryImpl.kt | 62 ++++---- form/src/main/java/org/dhis2/form/ui/Form.kt | 4 +- .../dhis2/form/data/FormRepositoryImplTest.kt | 135 +++++++++++++++++- .../data/FormRepositoryIntegrationTest.kt | 19 ++- .../form/integration/ProgramRulesTest.kt | 13 +- .../dhis2/form/ui/DataEntryIntegrationTest.kt | 4 +- 6 files changed, 183 insertions(+), 54 deletions(-) diff --git a/form/src/main/java/org/dhis2/form/data/FormRepositoryImpl.kt b/form/src/main/java/org/dhis2/form/data/FormRepositoryImpl.kt index d478049660..3302950804 100644 --- a/form/src/main/java/org/dhis2/form/data/FormRepositoryImpl.kt +++ b/form/src/main/java/org/dhis2/form/data/FormRepositoryImpl.kt @@ -41,7 +41,7 @@ class FormRepositoryImpl( private var completionPercentage: Float = 0f private val itemsWithError: MutableList = mutableListOf() private val mandatoryItemsWithoutValue: MutableMap = mutableMapOf() - private var openedSectionUid: String? = null + private val collapsedSectionUids: MutableSet = mutableSetOf() private var itemList: List = emptyList() private var focusedItemId: String? = null private var ruleEffects: List = emptyList() @@ -64,31 +64,17 @@ class FormRepositoryImpl( override suspend fun fetchFormItems(shouldOpenErrorLocation: Boolean): List { itemList = dataEntryRepository.list().blockingFirst() ?: emptyList() - openedSectionUid = getInitialOpenedSection(shouldOpenErrorLocation) backupList = itemList return composeList() } - private fun getInitialOpenedSection(shouldOpenErrorLocation: Boolean) = - when { - disableCollapsableSections == true -> - null - - shouldOpenErrorLocation -> - itemList.firstOrNull { it.error != null || it.warning != null }?.programStageSection - ?: dataEntryRepository.firstSectionToOpen() - - else -> - dataEntryRepository.firstSectionToOpen() - } - override suspend fun composeList(skipProgramRules: Boolean): List { return itemList .applyRuleEffects(skipProgramRules) .mergeListWithErrorFields(itemsWithError) .also { calculateCompletionPercentage(it) - }.setOpenedSection() + }.setSectionStates() .setFocusedItem() .setLastItem() } @@ -545,8 +531,11 @@ class FormRepositoryImpl( } ?: this } ?: this - private suspend fun List.setOpenedSection(): List = - map { field -> + private suspend fun List.setSectionStates(): List { + val sectionUids = filterIsInstance().mapTo(mutableSetOf()) { it.uid } + collapsedSectionUids.retainAll(sectionUids) + + return map { field -> if (field.isSection()) { updateSection(field, this) } else { @@ -559,28 +548,31 @@ class FormRepositoryImpl( else -> useCompose || - disableCollapsableSections == true || - field.programStageSection == openedSectionUid + field.programStageSection !in collapsedSectionUids } } + } private fun updateSection( sectionFieldUiModel: FieldUiModel, fields: List, ): FieldUiModel { - var total = 0 - var values = 0 - val isOpen = - (sectionFieldUiModel.uid == openedSectionUid) - .takeIf { disableCollapsableSections != true } - fields - .filter { - it.programStageSection.equals(sectionFieldUiModel.uid) && it.valueType != null - }.forEach { - total++ - if (!it.value.isNullOrEmpty() || hasPendingSimprintsRegisterLastValue(it)) { - values++ + val sectionFields = + fields + .filter { + it.programStageSection.equals(sectionFieldUiModel.uid) && it.valueType != null } + val values = + sectionFields.count { + !it.value.isNullOrEmpty() || hasPendingSimprintsRegisterLastValue(it) + } + val hasMissingMandatoryFields = sectionFields.any(::hasMandatoryWarnings) + val isOpen = + if (hasMissingMandatoryFields) { + collapsedSectionUids.remove(sectionFieldUiModel.uid) + null + } else { + sectionFieldUiModel.uid !in collapsedSectionUids } val warningCount = @@ -617,7 +609,7 @@ class FormRepositoryImpl( return dataEntryRepository.updateSection( sectionFieldUiModel, isOpen, - total, + sectionFields.size, values, errorCount + mandatoryCount + errorFields, warningCount, @@ -891,8 +883,8 @@ class FormRepositoryImpl( override fun currentFocusedItem(): FieldUiModel? = itemList.find { focusedItemId == it.uid } override fun updateSectionOpened(action: RowAction) { - if (disableCollapsableSections != true) { - openedSectionUid = action.id + if (!collapsedSectionUids.add(action.id)) { + collapsedSectionUids.remove(action.id) } } diff --git a/form/src/main/java/org/dhis2/form/ui/Form.kt b/form/src/main/java/org/dhis2/form/ui/Form.kt index d9cc15a9ff..a36ca191b3 100644 --- a/form/src/main/java/org/dhis2/form/ui/Form.kt +++ b/form/src/main/java/org/dhis2/form/ui/Form.kt @@ -100,7 +100,9 @@ fun Form( val onNextSection: () -> Unit = { getNextSection(section, sections)?.let { - intentHandler.invoke(FormIntent.OnSection(it.uid)) + if (it.state == SectionState.CLOSE) { // only toggle next section when it needs opening + intentHandler.invoke(FormIntent.OnSection(it.uid)) + } scope.launch { scrollState.animateScrollToItem(sections.indexOf(it)) } diff --git a/form/src/test/java/org/dhis2/form/data/FormRepositoryImplTest.kt b/form/src/test/java/org/dhis2/form/data/FormRepositoryImplTest.kt index 721ce308a8..9bffcd39b7 100644 --- a/form/src/test/java/org/dhis2/form/data/FormRepositoryImplTest.kt +++ b/form/src/test/java/org/dhis2/form/data/FormRepositoryImplTest.kt @@ -32,6 +32,7 @@ import org.hisp.dhis.rules.models.RuleAction import org.hisp.dhis.rules.models.RuleEffect import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Assert.fail import org.junit.Before @@ -79,14 +80,20 @@ class FormRepositoryImplTest { whenever( dataEntryRepository.updateSection( any(), - any(), + anyOrNull(), any(), any(), any(), any(), ), ).thenAnswer { invocationOnMock -> - invocationOnMock.getArgument(0) as FieldUiModel + (invocationOnMock.getArgument(0) as SectionUiModelImpl).copy( + isOpen = invocationOnMock.getArgument(1), + totalFields = invocationOnMock.getArgument(2), + completedFields = invocationOnMock.getArgument(3), + errors = invocationOnMock.getArgument(4), + warnings = invocationOnMock.getArgument(5), + ) } repository = @@ -310,6 +317,74 @@ class FormRepositoryImplTest { assertTrue(repository.runDataIntegrityCheck(false) is SuccessfulResult) } + @Test + fun `Should have all sections open initially when they have no mandatory fields`() = + runBlocking { + whenever(dataEntryRepository.list()) doReturn Flowable.just(provideTwoSectionItemList()) + + val sections = repository.fetchFormItems().filterIsInstance() + + assertEquals(listOf(true, true), sections.map { it.isOpen }) + } + + @Test + fun `Should keep mandatory section forced open until its mandatory fields are completed`() = + runBlocking { + whenever(dataEntryRepository.list()) doReturn + Flowable.just(provideSectionItemList(mandatoryValue = null)) + + val initialSection = repository.fetchFormItems().section("section1") + assertNull(initialSection.isOpen) + + repository.updateSectionOpened(sectionAction("section1")) + assertNull(repository.composeList().section("section1").isOpen) + + repository.updateValueOnList("mandatory1", "completed", ValueType.TEXT) + assertTrue(repository.composeList().section("section1").isOpen == true) + } + + @Test + fun `Should close and open completed sections independently`() = + runBlocking { + whenever(dataEntryRepository.list()) doReturn Flowable.just(provideTwoSectionItemList()) + repository.fetchFormItems() + + repository.updateSectionOpened(sectionAction("section1")) + assertEquals( + mapOf("section1" to false, "section2" to true), + repository.composeList().sectionStates(), + ) + + repository.updateSectionOpened(sectionAction("section2")) + assertEquals( + mapOf("section1" to false, "section2" to false), + repository.composeList().sectionStates(), + ) + + repository.updateSectionOpened(sectionAction("section1")) + assertEquals( + mapOf("section1" to true, "section2" to false), + repository.composeList().sectionStates(), + ) + } + + @Test + fun `Should forget section openness state when a mandatory value is cleared`() = + runBlocking { + whenever(dataEntryRepository.list()) doReturn + Flowable.just(provideSectionItemList(mandatoryValue = "completed")) + repository.fetchFormItems() + + repository.updateSectionOpened(sectionAction("section1")) + assertEquals(false, repository.composeList().section("section1").isOpen) + + repository.updateValueOnList("mandatory1", null, ValueType.TEXT) + assertNull(repository.composeList().section("section1").isOpen) + + repository.updateValueOnList("mandatory1", "completed again", ValueType.TEXT) + assertTrue(repository.composeList().section("section1").isOpen == true) + } + @Test fun `Should allow to complete only uncompleted events`() = runBlocking { @@ -449,12 +524,14 @@ class FormRepositoryImplTest { whenever( dataEntryRepository.list(), ) doReturn Flowable.just(provideMandatoryListWithCategoryCombo("option1")) - repository.fetchFormItems() + val incompleteSection = repository.fetchFormItems().section("section1") + assertNull(incompleteSection.isOpen) assertTrue(repository.runDataIntegrityCheck(false) is MissingMandatoryResult) whenever( dataEntryRepository.list(), ) doReturn Flowable.just(provideMandatoryListWithCategoryCombo("option1,option2")) - repository.fetchFormItems() + val completeSection = repository.fetchFormItems().section("section1") + assertTrue(completeSection.isOpen == true) assertTrue(repository.runDataIntegrityCheck(false) is SuccessfulResult) } @@ -494,6 +571,7 @@ class FormRepositoryImplTest { assertEquals(1f, repository.completedFieldsPercentage(result), 0f) assertEquals(1, section.completedFields) + assertTrue(section.isOpen == true) assertTrue(repository.runDataIntegrityCheck(false) is SuccessfulResult) } @@ -566,6 +644,55 @@ class FormRepositoryImplTest { selectedField = ObservableField(""), ) + private fun provideSectionItemList(mandatoryValue: String?) = + listOf( + section1(), + FieldUiModelImpl( + uid = "mandatory1", + value = mandatoryValue, + label = "Mandatory field", + valueType = ValueType.TEXT, + programStageSection = "section1", + mandatory = true, + optionSetConfiguration = null, + autocompleteList = null, + ), + ) + + private fun provideTwoSectionItemList() = + listOf( + section1(), + sectionField("field1", "section1"), + section2(), + sectionField("field2", "section2"), + ) + + private fun sectionField( + uid: String, + sectionUid: String, + ) = + FieldUiModelImpl( + uid = uid, + value = "value", + label = uid, + valueType = ValueType.TEXT, + programStageSection = sectionUid, + optionSetConfiguration = null, + autocompleteList = null, + ) + + private fun sectionAction(sectionUid: String) = + RowAction( + id = sectionUid, + type = ActionType.ON_SECTION_CHANGE, + ) + + private fun List.section(sectionUid: String) = + filterIsInstance().first { it.uid == sectionUid } + + private fun List.sectionStates() = + filterIsInstance().associate { it.uid to it.isOpen } + private fun provideEmptySectionItemList() = listOf( section1(), diff --git a/form/src/test/java/org/dhis2/form/data/FormRepositoryIntegrationTest.kt b/form/src/test/java/org/dhis2/form/data/FormRepositoryIntegrationTest.kt index e89650e27c..dc0e1ca107 100644 --- a/form/src/test/java/org/dhis2/form/data/FormRepositoryIntegrationTest.kt +++ b/form/src/test/java/org/dhis2/form/data/FormRepositoryIntegrationTest.kt @@ -26,6 +26,7 @@ import org.hisp.dhis.android.core.program.Program import org.hisp.dhis.android.core.program.ProgramTrackedEntityAttribute import org.hisp.dhis.android.core.trackedentity.TrackedEntityAttribute import org.hisp.dhis.android.core.trackedentity.TrackedEntityType +import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test @@ -103,7 +104,7 @@ class FormRepositoryIntegrationTest { } @Test - fun shouldOpenEnrollmentDetailSectionIfIsNewAndNotCompleted() = + fun shouldKeepEnrollmentDetailSectionForcedOpenIfIsNewAndNotCompleted() = runTest { mockUncompletedEnrollment() whenever(conf.disableCollapsableSectionsInProgram(any())) doReturn false @@ -111,14 +112,14 @@ class FormRepositoryIntegrationTest { val repository = mockFormRepository() val fields = repository.fetchFormItems() - assertTrue((fields.first { it.isSection() } as SectionUiModelImpl).isOpen == true) + assertNull((fields.first { it.isSection() } as SectionUiModelImpl).isOpen) } @Test - fun shouldOpenEnrollmentDetailSectionIfIsNewAndCompleted() = + fun shouldOpenEnrollmentDetailSectionIfIsNewAndCompletedRegardlessOfAppearanceSetting() = runTest { mockCompletedEnrollment() - whenever(conf.disableCollapsableSectionsInProgram(any())) doReturn false + whenever(conf.disableCollapsableSectionsInProgram(any())) doReturn true val repository = mockFormRepository(EnrollmentMode.NEW) @@ -127,7 +128,7 @@ class FormRepositoryIntegrationTest { } @Test - fun shouldOpenEnrollmentDetailSectionIfNotCompleted() = + fun shouldKeepEnrollmentDetailSectionOpenIfNotCompleted() = runTest { mockUncompletedEnrollment() whenever(conf.disableCollapsableSectionsInProgram(any())) doReturn false @@ -135,11 +136,11 @@ class FormRepositoryIntegrationTest { val repository = mockFormRepository(EnrollmentMode.CHECK) val fields = repository.fetchFormItems() - assertTrue((fields.first { it.isSection() } as SectionUiModelImpl).isOpen == true) + assertNull((fields.first { it.isSection() } as SectionUiModelImpl).isOpen) } @Test - fun shouldNotOpenEnrollmentDetailSectionIfCompleted() = + fun shouldOpenAllSectionsIfCompleted() = runTest { mockCompletedEnrollment() whenever(conf.disableCollapsableSectionsInProgram(any())) doReturn false @@ -147,9 +148,7 @@ class FormRepositoryIntegrationTest { val repository = mockFormRepository(EnrollmentMode.CHECK) val fields = repository.fetchFormItems() - assertTrue( - (fields.filter { it.isSection() }[1] as SectionUiModelImpl).isOpen == true, - ) + assertTrue(fields.filterIsInstance().all { it.isOpen == true }) } private fun mockUncompletedEnrollment() { diff --git a/form/src/test/java/org/dhis2/form/integration/ProgramRulesTest.kt b/form/src/test/java/org/dhis2/form/integration/ProgramRulesTest.kt index 252ec89ee8..3e88628d61 100644 --- a/form/src/test/java/org/dhis2/form/integration/ProgramRulesTest.kt +++ b/form/src/test/java/org/dhis2/form/integration/ProgramRulesTest.kt @@ -38,6 +38,7 @@ import org.dhis2.mobileProgramRules.RuleEngineHelper import org.hisp.dhis.android.core.D2 import org.hisp.dhis.android.core.common.ValueType import org.hisp.dhis.android.core.program.ProgramRuleActionType +import org.hisp.dhis.mobile.ui.designsystem.component.SectionState import org.hisp.dhis.rules.models.RuleAction import org.hisp.dhis.rules.models.RuleEffect import org.junit.After @@ -101,14 +102,20 @@ class ProgramRulesTest { whenever( dataEntryRepository.updateSection( any(), - any(), + anyOrNull(), any(), any(), any(), any(), ), ).thenAnswer { invocationOnMock -> - invocationOnMock.getArgument(0) as FieldUiModel + (invocationOnMock.getArgument(0) as SectionUiModelImpl).copy( + isOpen = invocationOnMock.getArgument(1), + totalFields = invocationOnMock.getArgument(2), + completedFields = invocationOnMock.getArgument(3), + errors = invocationOnMock.getArgument(4), + warnings = invocationOnMock.getArgument(5), + ) } whenever(formValueStore.save(any(), anyOrNull(), anyOrNull())) doReturn @@ -331,6 +338,7 @@ class ProgramRulesTest { @Test fun `Should set mandatory field`() = runTest { + repository.updateValueOnList("uid003", null, ValueType.TEXT) whenever(ruleEngineHelper.evaluate()) doReturn listOf( RuleEffect( @@ -358,6 +366,7 @@ class ProgramRulesTest { formViewModel.submitIntent(intent) val sections = awaitItem() assertTrue(sections.size == 1) + assertEquals(SectionState.FIXED, sections.first().state) sections.first().fields.forEach { if (it.uid == "uid003") { assertTrue(it.mandatory) diff --git a/form/src/test/java/org/dhis2/form/ui/DataEntryIntegrationTest.kt b/form/src/test/java/org/dhis2/form/ui/DataEntryIntegrationTest.kt index 88b11944b1..6334b9e2c3 100644 --- a/form/src/test/java/org/dhis2/form/ui/DataEntryIntegrationTest.kt +++ b/form/src/test/java/org/dhis2/form/ui/DataEntryIntegrationTest.kt @@ -120,7 +120,7 @@ class DataEntryIntegrationTest { whenever( dataEntryRepository.updateSection( any(), - any(), + anyOrNull(), any(), any(), any(), @@ -129,7 +129,7 @@ class DataEntryIntegrationTest { ).thenAnswer { invocationOnMock -> val sectionUiModel = invocationOnMock.getArgument(0) as SectionUiModelImpl sectionUiModel.copy( - isOpen = invocationOnMock.getArgument(1) as Boolean, + isOpen = invocationOnMock.getArgument(1), totalFields = invocationOnMock.getArgument(2) as Int, completedFields = invocationOnMock.getArgument(3) as Int, errors = invocationOnMock.getArgument(4) as Int, From 327ec474a1b75d1086339ee72899fa02b7db0ad2 Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 28 Jul 2026 14:36:45 +0100 Subject: [PATCH 09/13] Simprints RAMP: ACF-63 Forms non-collapsible when mandatory fields not completed: performance improvements --- form/src/main/java/org/dhis2/form/ui/Form.kt | 157 +++++++++--------- .../java/org/dhis2/form/ui/FormViewModel.kt | 19 ++- .../form/integration/ProgramRulesTest.kt | 7 +- .../org/dhis2/form/ui/FormViewModelTest.kt | 38 +++-- 4 files changed, 117 insertions(+), 104 deletions(-) diff --git a/form/src/main/java/org/dhis2/form/ui/Form.kt b/form/src/main/java/org/dhis2/form/ui/Form.kt index a36ca191b3..e42dfe77fc 100644 --- a/form/src/main/java/org/dhis2/form/ui/Form.kt +++ b/form/src/main/java/org/dhis2/form/ui/Form.kt @@ -4,19 +4,20 @@ import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues 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.lazy.LazyColumn -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.relocation.BringIntoViewRequester +import androidx.compose.foundation.relocation.bringIntoViewRequester import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll 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.runtime.key import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier @@ -47,9 +48,13 @@ fun Form( intentHandler: (FormIntent) -> Unit, uiEventHandler: (RecyclerViewUiEvents) -> Unit, ) { - val scrollState = rememberLazyListState() + val scrollState = rememberScrollState() val focusManager = LocalFocusManager.current val scope = rememberCoroutineScope() + val sectionRequesters = + remember(sections.map { it.uid }) { + sections.associate { it.uid to BringIntoViewRequester() } + } val callback = remember { object : FieldUiModel.Callback { @@ -62,7 +67,7 @@ fun Form( } } } - LazyColumn( + Column( modifier = Modifier .testTag("FORM_VIEW") @@ -77,6 +82,7 @@ fun Form( bottomEnd = Spacing.Spacing0, ), ) + .verticalScroll(scrollState) .clickable( interactionSource = remember { @@ -84,84 +90,83 @@ fun Form( }, indication = null, onClick = { focusManager.clearFocus() }, + ) + .padding( + horizontal = Spacing.Spacing16, + vertical = Spacing.Spacing16, ), - contentPadding = - PaddingValues( - horizontal = Spacing.Spacing16, - vertical = Spacing.Spacing16, - ), - state = scrollState, ) { if (sections.isNotEmpty()) { - this.itemsIndexed( - items = sections, - key = { _, fieldUiModel -> fieldUiModel.uid }, - ) { _, section -> - - val onNextSection: () -> Unit = { - getNextSection(section, sections)?.let { - if (it.state == SectionState.CLOSE) { // only toggle next section when it needs opening - intentHandler.invoke(FormIntent.OnSection(it.uid)) - } - scope.launch { - scrollState.animateScrollToItem(sections.indexOf(it)) + sections.forEach { section -> + key(section.uid) { + val nextSection = getNextSection(section, sections) + val onNextSection: () -> Unit = { + nextSection?.let { + if (it.state == SectionState.CLOSE) { // only toggle next section when it needs opening + intentHandler.invoke(FormIntent.OnSection(it.uid)) + } + scope.launch { + sectionRequesters[it.uid]?.bringIntoView() + } + } ?: run { + intentHandler.invoke(FormIntent.OnFocus("", null)) + focusManager.clearFocus() } - } ?: run { - intentHandler.invoke(FormIntent.OnFocus("", null)) - focusManager.clearFocus() } - } - Section( - title = section.title, - isLastSection = getNextSection(section, sections) == null, - description = if (section.fields.isNotEmpty()) section.description else null, - completedFields = section.completeFields, - totalFields = section.totalFields, - state = section.state, - errorCount = section.errors, - warningCount = section.warnings, - warningMessage = section.warningMessage?.let { stringResource(it) }, - onNextSection = onNextSection, - onSectionClick = { - intentHandler.invoke(FormIntent.OnSection(section.uid)) - }, - content = { - if (section.fields.isNotEmpty()) { + Section( + modifier = + Modifier.bringIntoViewRequester( + sectionRequesters.getValue(section.uid), + ), + title = section.title, + isLastSection = nextSection == null, + description = if (section.fields.isNotEmpty()) section.description else null, + completedFields = section.completeFields, + totalFields = section.totalFields, + state = section.state, + errorCount = section.errors, + warningCount = section.warnings, + warningMessage = section.warningMessage?.let { stringResource(it) }, + onNextSection = onNextSection, + onSectionClick = { + intentHandler.invoke(FormIntent.OnSection(section.uid)) + }, + content = { section.fields.forEachIndexed { index, fieldUiModel -> - fieldUiModel.setCallback(callback) - FieldProvider( - modifier = Modifier, - fieldUiModel = fieldUiModel, - uiEventHandler = uiEventHandler, - intentHandler = intentHandler, - focusManager = focusManager, - onNextClicked = { - manageOnNextEvent( - focusManager, - index, - section, - onNextSection, - ) - }, - onFileSelected = { path -> - intentHandler.invoke( - FormIntent.OnStoreFile( - uid = fieldUiModel.uid, - filePath = path, - valueType = fieldUiModel.valueType, - ), - ) - }, - reEvaluateCustomIntentRequestParameters = true, - ) + key(fieldUiModel.uid) { + fieldUiModel.setCallback(callback) + FieldProvider( + modifier = Modifier, + fieldUiModel = fieldUiModel, + uiEventHandler = uiEventHandler, + intentHandler = intentHandler, + focusManager = focusManager, + onNextClicked = { + manageOnNextEvent( + focusManager, + index, + section, + onNextSection, + ) + }, + onFileSelected = { path -> + intentHandler.invoke( + FormIntent.OnStoreFile( + uid = fieldUiModel.uid, + filePath = path, + valueType = fieldUiModel.valueType, + ), + ) + }, + reEvaluateCustomIntentRequestParameters = true, + ) + } } - } - }, - ) - } - item(sections.size - 1) { - Spacer(modifier = Modifier.height(Spacing.Spacing120)) + }, + ) + } } + Spacer(modifier = Modifier.height(Spacing.Spacing120)) } } if (shouldDisplayNoFieldsWarning(sections)) { diff --git a/form/src/main/java/org/dhis2/form/ui/FormViewModel.kt b/form/src/main/java/org/dhis2/form/ui/FormViewModel.kt index 3c92166b13..de35103e31 100644 --- a/form/src/main/java/org/dhis2/form/ui/FormViewModel.kt +++ b/form/src/main/java/org/dhis2/form/ui/FormViewModel.kt @@ -127,6 +127,7 @@ class FormViewModel( private val handler = Handler(Looper.getMainLooper()) private var textChangeDebounceRunnable: Runnable? = null + private var legendRefreshRunnable: Runnable? = null var filePath: String? = null @@ -134,11 +135,9 @@ class FormViewModel( pendingIntents .distinctUntilChanged { old, new -> - if (old is FormIntent.OnFinish && new is FormIntent.OnFinish) { - false - } else { - old == new - } + old == new && + old !is FormIntent.OnFinish && + old !is FormIntent.OnSection }.onEach { intent -> FormCountingIdlingResource.increment() val result = createRowActionStore(intent) @@ -164,6 +163,7 @@ class FormViewModel( override fun onCleared() { textChangeDebounceRunnable?.let { handler.removeCallbacks(it) } + legendRefreshRunnable?.let { handler.removeCallbacks(it) } super.onCleared() } @@ -210,10 +210,11 @@ class FormViewModel( _queryData.postValue(it) } if (repository.hasLegendSet(result.first.id)) { - handler.removeCallbacksAndMessages(null) - handler.postDelayed({ - processCalculatedItems(skipProgramRules = true) - }, 500L) + legendRefreshRunnable?.let { handler.removeCallbacks(it) } + legendRefreshRunnable = + Runnable { + processCalculatedItems(skipProgramRules = true) + }.also { handler.postDelayed(it, 500L) } } } diff --git a/form/src/test/java/org/dhis2/form/integration/ProgramRulesTest.kt b/form/src/test/java/org/dhis2/form/integration/ProgramRulesTest.kt index 3e88628d61..4892d1b32c 100644 --- a/form/src/test/java/org/dhis2/form/integration/ProgramRulesTest.kt +++ b/form/src/test/java/org/dhis2/form/integration/ProgramRulesTest.kt @@ -226,8 +226,8 @@ class ProgramRulesTest { formViewModel.submitIntent(intent) val sections = awaitItem() + assertEquals(6, sections.sumOf { it.fields.size }) sections.forEach { - assert(it.fields.size == 6) it.fields.forEach { field -> assert(field.uid != "uid001") } @@ -319,9 +319,7 @@ class ProgramRulesTest { formViewModel.submitIntent(intent) val sections = awaitItem() - assertTrue(sections.size == 1) - - sections.first().fields.forEach { + sections.flatMap { it.fields }.forEach { if (it.uid == "uid002") { assertNotNull(it.warning) assertEquals(it.warning, "content warning message") @@ -365,7 +363,6 @@ class ProgramRulesTest { formViewModel.items.test { formViewModel.submitIntent(intent) val sections = awaitItem() - assertTrue(sections.size == 1) assertEquals(SectionState.FIXED, sections.first().state) sections.first().fields.forEach { if (it.uid == "uid003") { diff --git a/form/src/test/java/org/dhis2/form/ui/FormViewModelTest.kt b/form/src/test/java/org/dhis2/form/ui/FormViewModelTest.kt index 3337d43c2a..086676fdb1 100644 --- a/form/src/test/java/org/dhis2/form/ui/FormViewModelTest.kt +++ b/form/src/test/java/org/dhis2/form/ui/FormViewModelTest.kt @@ -1,8 +1,8 @@ package org.dhis2.form.ui import android.content.Intent +import app.cash.turbine.test import androidx.arch.core.executor.testing.InstantTaskExecutorRule -import androidx.lifecycle.Observer import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.runBlocking @@ -32,6 +32,7 @@ import org.junit.Test import org.mockito.kotlin.any import org.mockito.kotlin.doReturn import org.mockito.kotlin.mock +import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import java.time.LocalDate @@ -162,22 +163,31 @@ class FormViewModelTest { dispatcher, geometryController, resultDialogUiProvider = resultDialogUiProvider, - ) - advanceUntilIdle() - whenever(repository.updateValueOnList("weight", "", ValueType.NUMBER)) doReturn updatedField - val emittedItems = mutableListOf>() - val itemsObserver = Observer> { emittedItems.add(it) } - viewModel.items.observeForever(itemsObserver) - emittedItems.clear() + formSectionMapper = formSectionMapper, + ) + viewModel.items.test { + advanceUntilIdle() + assertEquals(initialField, awaitItem().single().fields.single()) + whenever(repository.updateValueOnList("weight", "", ValueType.NUMBER)) doReturn updatedField - viewModel.submitIntent(FormIntent.OnTextChange("weight", "", ValueType.NUMBER)) - advanceUntilIdle() + viewModel.submitIntent(FormIntent.OnTextChange("weight", "", ValueType.NUMBER)) + advanceUntilIdle() + + expectNoEvents() + verify(repository).updateValueOnList("weight", "", ValueType.NUMBER) + } + } - assertTrue(emittedItems.isEmpty()) - assertEquals(initialField, viewModel.items.value?.first()) - verify(repository).updateValueOnList("weight", "", ValueType.NUMBER) + @Test + fun `Should process consecutive section toggles`() = + runTest { + val intent = FormIntent.OnSection("section") + + viewModel.submitIntent(intent) + viewModel.submitIntent(intent) + advanceUntilIdle() - viewModel.items.removeObserver(itemsObserver) + verify(repository, times(2)).updateSectionOpened(any()) } private val futureDate: String = LocalDate.now().plusDays(1).format(DateTimeFormatter.ISO_DATE) From 9d08ef7f0315beb7705fdc69c9cc0c9fd1e530cd Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 28 Jul 2026 15:07:55 +0100 Subject: [PATCH 10/13] Simprints RAMP: README updated --- README.md | 43 +++++++++++++++++++++++++++++++++---------- 1 file changed, 33 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 300ab3c400..2bc941690f 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ upstream [dhis2/dhis2-android-capture-app](https://github.com/dhis2/dhis2-androi | Infra: signed APK releases | [simcapture-github-release-signed-apk.yml](.github/workflows/simcapture-github-release-signed-apk.yml) | New file | GitHub Action to create a GitHub Release on a merge to `main` | | Infra: signed APK releases | [app/build.gradle.kts#L121](app/build.gradle.kts#L121) | Code change | App's Package ID set to `com.simprints.simcapture` | | Infra: GitHub Actions filtering | [simcapture-disable-upstream-workflows.yml](.github/workflows/simcapture-disable-upstream-workflows.yml) | New file | GitHub Action to limit allowed Actions to the ones starting with `simcapture-` or `copilot-` | -| Docs: fork-specific README | [README.md#L1-L125](README.md#L1-L125) | Code change | This section in README | +| Docs: fork-specific README | [README.md#L1-L148](README.md#L1-L148) | Code change | This section in README | | Enrolment+ Possible Duplicates | [CustomIntentActivityResultContract.kt#L95](form/src/main/java/org/dhis2/form/ui/customintent/CustomIntentActivityResultContract.kt#L95) | Code addition | Treats non-identify Simprints callouts that return identification matches as `PossibleDuplicates` results | | Enrolment+ Possible Duplicates | [FormView.kt#L627](form/src/main/java/org/dhis2/form/ui/FormView.kt#L627) | Code addition | Stores the returned SID session and hands the enrolment form off to the possible duplicates search flow | | Enrolment+ Possible Duplicates | [EnrollmentActivity.kt#L296](app/src/main/java/org/dhis2/usescases/enrollment/EnrollmentActivity.kt#L296) | Code addition | Launches possible duplicates search from the enrolment form, carrying the biometric field and returned GUID matches | @@ -24,7 +24,7 @@ upstream [dhis2/dhis2-android-capture-app](https://github.com/dhis2/dhis2-androi | Enrolment+ Possible Duplicates | [SearchTEIViewModel.kt#L727](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEIViewModel.kt#L727) | Code addition | Switches search loading into possible-duplicates mode and auto-falls back to Enrol Last when no matching TEIs exist in DHIS2 | | Enrolment+ Possible Duplicates | [SearchTEList.kt#L317](app/src/main/java/org/dhis2/usescases/searchTrackEntity/listView/SearchTEList.kt#L317) | Code addition | Replaces the default "+ New" action button with `None of the above` while reviewing possible duplicates | | Enrolment+ Enrol Last | [SearchTEIViewModel.kt#L659](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEIViewModel.kt#L659) | Code addition | Marks `None of the above` as a pending enrolment action and closes the possible duplicates search | -| Enrolment+ Enrol Last | [SearchTEActivity.kt#L674](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEActivity.kt#L674) | Code addition | Returns possible duplicates search with an auto-enrol-last signal when the flow should continue to `REGISTER_LAST_BIOMETRICS` | +| Enrolment+ Enrol Last | [SearchTEActivity.kt#L675](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEActivity.kt#L675) | Code addition | Returns possible duplicates search with an auto-enrol-last signal when the flow should continue to `REGISTER_LAST_BIOMETRICS` | | Enrolment+ Enrol Last | [EnrollmentActivity.kt#L150](app/src/main/java/org/dhis2/usescases/enrollment/EnrollmentActivity.kt#L150) | Code addition | Receives the possible duplicates return flow and launches save-time Enrol Last before enrolment finishes | | Enrolment+ Enrol Last | [SimprintsEnrollmentViewModel.kt#L41](app/src/main/java/org/dhis2/simprints/SimprintsEnrollmentViewModel.kt#L41) | Code addition | Builds the auto-enrol-last intent from the stored session and restores pending actions across lifecycle interruptions | | Enrolment+ Enrol Last | [SimprintsSessionRepository.kt#L30](commons/src/main/java/org/dhis2/commons/simprints/repository/SimprintsSessionRepository.kt#L30) | Code addition | Keeps separate pending Enrol Last source state for possible-duplicates returns | @@ -33,7 +33,7 @@ upstream [dhis2/dhis2-android-capture-app](https://github.com/dhis2/dhis2-androi | Identification+ Enrol Last | [EnrollmentPresenterImpl.kt#L163](app/src/main/java/org/dhis2/usescases/enrollment/EnrollmentPresenterImpl.kt#L163) | Code addition | Delegates save-time Enrol Last resolution and result handling | | Identification+ Enrol Last | [EnrollmentModule.kt#L181](app/src/main/java/org/dhis2/usescases/enrollment/EnrollmentModule.kt#L181) | Code addition | DI for separate Simprints-specific components for Identification+ Enrol Last | | Identification+ Enrol Last | [SimprintsEnrollmentViewModelFactory.kt](app/src/main/java/org/dhis2/simprints/di/SimprintsEnrollmentViewModelFactory.kt) | New file | Creates activity-scoped Simprints Enrol Last ViewModel so pending save-time state survives configuration changes | -| Identification+ Enrol Last | [SearchTEActivity.kt#L596](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEActivity.kt#L596) | Code change | Carries biometric search context into create/enroll actions via prepared enrollment query data | +| Identification+ Enrol Last | [SearchTEActivity.kt#L597](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEActivity.kt#L597) | Code change | Carries biometric search context into create/enroll actions via prepared enrollment query data | | Identification+ Enrol Last | [SimprintsSearchViewModel.kt#L103](app/src/main/java/org/dhis2/simprints/SimprintsSearchViewModel.kt#L103) | Code addition | Prepares Simprints enrollment query data and tracks whether the create action should use last biometrics | | Identification+ Enrol Last | [SearchTEIViewModel.kt#L1592](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEIViewModel.kt#L1592) | Code change | Preserves existing search parameter values when fields reload so pending Enrol Last state survives configuration changes | | Identification+ Enrol Last | [SearchTEList.kt#L299](app/src/main/java/org/dhis2/usescases/searchTrackEntity/listView/SearchTEList.kt#L299) | Code addition | Observes the Simprints create-action label state for if the new TEI enrollment button in the result list should use Enrol Last | @@ -58,6 +58,7 @@ upstream [dhis2/dhis2-android-capture-app](https://github.com/dhis2/dhis2-androi | Identification Confirm Identity | [SimprintsSearchViewModel.kt](app/src/main/java/org/dhis2/simprints/SimprintsSearchViewModel.kt) | New file | Coordinates biometric search handoff for Enrol Last labels and Confirm Identity navigation | | Identification Confirm Identity | [SimprintsResolveConfirmIdentityCalloutUseCase.kt](commons/src/main/java/org/dhis2/commons/simprints/usecases/SimprintsResolveConfirmIdentityCalloutUseCase.kt) | New file | Resolves the selected TEI's biometric GUID and prepares the `CONFIRM_IDENTITY` callout | | ModuleID in Identification | [CustomIntentRepositoryImpl.kt#L105](commonskmm/src/androidMain/kotlin/org/dhis2/mobile/commons/customintents/CustomIntentRepositoryImpl.kt#L105) | Code addition | Overrides `moduleId` for Simprints identification intents with the current user's Org Unit value | +| ModuleID in Identification | [CustomIntentRepositoryImpl.kt#L103](commonskmm/src/androidMain/kotlin/org/dhis2/mobile/commons/customintents/CustomIntentRepositoryImpl.kt#L103) | Code change | Uses the current user's root organisation unit code, rather than its name, as SID's `moduleId` | | Identification result ordering by score | [SearchTEIViewModel.kt#L752](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEIViewModel.kt#L752) | Code addition | Routes biometric search loading through SID-order-aware lookup so results follow identification match score order | | Identification result ordering by score | [SearchTEModule.java#L379](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEModule.java#L379) | Code change | DI for separate Simprints-specific components for Identification result ordering by score | | Identification result ordering by score | [SearchTeiViewModelFactory.kt#L56](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTeiViewModelFactory.kt#L56) | Code addition | Passes the ordered-result use case into `SearchTEIViewModel` | @@ -66,7 +67,7 @@ upstream [dhis2/dhis2-android-capture-app](https://github.com/dhis2/dhis2-androi | Identification with less buttons | [SearchTEUi.kt#L214](app/src/main/java/org/dhis2/usescases/searchTrackEntity/ui/SearchTEUi.kt#L214) | Code change | Keeps the Biometric search label read-only | | Identification with less buttons | [SearchParametersScreenProvider.kt#L70](app/src/main/java/org/dhis2/usescases/searchTrackEntity/searchparameters/SearchParametersScreenProvider.kt#L70) | Code addition | Treats a successful Simprints identification return as an immediate search command instead of waiting for a manual extra step | | Identification with less buttons | [SearchTEIViewModel.kt#L1551](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEIViewModel.kt#L1551) | Code addition | Rehydrates carried query values and performs the search immediately when biometric query data is present | -| Identification with less buttons | [SearchTEActivity.kt#L636](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEActivity.kt#L636) | Code addition | Skips the transient search editor and jumps straight to the biometric result list without backdrop animation | +| Identification with less buttons | [SearchTEActivity.kt#L637](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEActivity.kt#L637) | Code addition | Skips the transient search editor and jumps straight to the biometric result list without backdrop animation | | MFID Auto-Open Record | [SimprintsMapBiometricSearchResultUseCase.kt#L46](app/src/main/java/org/dhis2/simprints/SimprintsMapBiometricSearchResultUseCase.kt#L46) | Code addition | Detects credential-linked identification results that qualify for the MFID direct-open shortcut | | MFID Auto-Open Record | [SimprintsSearchViewModel.kt#L185](app/src/main/java/org/dhis2/simprints/SimprintsSearchViewModel.kt#L185) | Code addition | Consumes an eligible single MFID identification to open the matched record directly instead of showing the result list | | MFID Auto-Open Record | [SimprintsResolveSingleBiometricSearchNavigationUseCase.kt](app/src/main/java/org/dhis2/simprints/SimprintsResolveSingleBiometricSearchNavigationUseCase.kt) | New file | Resolves the biometric search to exactly one TEI before auto-opening the record | @@ -76,17 +77,18 @@ upstream [dhis2/dhis2-android-capture-app](https://github.com/dhis2/dhis2-androi | RAMP Infra: version info | [ContextExtensions.kt#L17](app/src/main/java/org/dhis2/bindings/ContextExtensions.kt#L17) | Code addition | Formats the RAMPcapture build version for About, including commit SHA if a non-release build | | RAMP Infra: version info | [fragment_about.xml#L31](app/src/main/res/layout/fragment_about.xml#L31) | Code addition | Adds a RAMPcapture version row above the upstream DHIS2 app and SDK version labels on the About screen | | RAMP Infra: version info | [AboutFragment.kt#L45](app/src/main/java/org/dhis2/usescases/about/AboutFragment.kt#L45) | Code addition | Binds the RAMPcapture version text while keeping the DHIS2 app and SDK version | -| RAMP Infra: version info | [strings.xml#L222](app/src/main/res/values/strings.xml#L222) | Code addition | Adds the `RAMPcapture Version: %s` label for the new About screen row | +| RAMP Infra: version info | [strings.xml#L226](app/src/main/res/values/strings.xml#L226) | Code addition | Adds the `RAMPcapture Version: %s` label for the new About screen row | +| RAMP Infra: application ID | [app/build.gradle.kts#L121](app/build.gradle.kts#L121) | Code change | Sets RAMPcapture's package ID to `com.acf.dhis.androidnutritioncapture` | | RAMP ACF-1,39,40,41: datastore config | [RampDatastoreConfig.kt#L5](commons/src/main/java/org/dhis2/commons/simprints/ramp/model/RampDatastoreConfig.kt#L5) | New file | Defines the RAMP datastore config schema for enabled data-element history charts and program-stage history tables | | RAMP ACF-1,39,40,41: datastore config | [RampDatastoreConfig.kt#L29](commons/src/main/java/org/dhis2/commons/simprints/ramp/model/RampDatastoreConfig.kt#L29) | Code addition | Adds optional per-chart decimal-place limits to the datastore config | -| RAMP ACF-1,39,40,41: datastore config | [RampDatastoreRepository.kt#L15](commons/src/main/java/org/dhis2/commons/simprints/ramp/repository/RampDatastoreRepository.kt#L15) | New file | Reads, validates, caches, and downloads the `simprints/ramp` DHIS2 datastore config used by RAMP features | +| RAMP ACF-1,39,40,41: datastore config | [RampDatastoreRepository.kt#L16](commons/src/main/java/org/dhis2/commons/simprints/ramp/repository/RampDatastoreRepository.kt#L16) | New file | Reads, validates, caches, and downloads the `simprints/ramp` DHIS2 datastore config used by RAMP features | | RAMP ACF-1,39,40,41: datastore config | [AndroidSyncRepository.kt#L308](sync/src/androidMain/kotlin/org/dhis2/mobile/sync/data/AndroidSyncRepository.kt#L308) | Code addition | Syncs the RAMP datastore config after metadata download so charts and tables use current server configuration | | RAMP ACF-1: field history charts | [EventRepository.kt#L775](form/src/main/java/org/dhis2/form/data/EventRepository.kt#L775) | Code addition | Attaches a configured RAMP history chart to matching event fields when form items load or rules update fields | | RAMP ACF-1: field history charts | [FormHistoryChartRepository.kt#L10](form/src/main/java/org/dhis2/form/simprints/ramp/data/FormHistoryChartRepository.kt#L10) | New file | Builds per-field visit history chart data from the current enrollment's configured follow-up visit events | | RAMP ACF-1: field history charts | [FormHistoryChart.kt#L8](form/src/main/java/org/dhis2/form/simprints/ramp/model/FormHistoryChart.kt#L8) | Code addition | Carries the configured decimal-place limit with chart state | | RAMP ACF-1: field history charts | [FormHistoryChartView.kt#L49](form/src/main/java/org/dhis2/form/simprints/ramp/ui/FormHistoryChartView.kt#L49) | New file | Renders the configured field history chart as a line chart with visit-number X-axis labels | | RAMP ACF-1: field history charts | [FieldProvider.kt#L177](form/src/main/java/org/dhis2/form/ui/provider/inputfield/FieldProvider.kt#L177) | Code change | Displays the RAMP history chart directly under the matching input field | -| RAMP ACF-1: field history charts | [FormViewModel.kt#L316](form/src/main/java/org/dhis2/form/ui/FormViewModel.kt#L316) | Code change | Keeps the plotted current-event value live while the user edits a field | +| RAMP ACF-1: field history charts | [FormViewModel.kt#L317](form/src/main/java/org/dhis2/form/ui/FormViewModel.kt#L317) | Code change | Keeps the plotted current-event value live while the user edits a field | | RAMP ACF-39,40,41: history tables | [EventHistoryTableRepository.kt#L20](app/src/main/java/org/dhis2/simprints/ramp/data/EventHistoryTableRepository.kt#L20) | New file | Builds visit-column history tables from configured follow-up visit events, grouped by program-stage sections | | RAMP ACF-39,40,41: history tables | [EventHistoryTableRepository.kt#L212](app/src/main/java/org/dhis2/simprints/ramp/data/EventHistoryTableRepository.kt#L212) | Code change | Orders table rows to follow the configured program-stage section data-element order | | RAMP ACF-39,40,41: history tables | [EventHistoryTableScreen.kt#L43](app/src/main/java/org/dhis2/simprints/ramp/ui/EventHistoryTableScreen.kt#L43) | New file | Renders the history table with visit columns, date row, section headers, loading, empty, and error states | @@ -96,12 +98,12 @@ upstream [dhis2/dhis2-android-capture-app](https://github.com/dhis2/dhis2-androi | RAMP ACF-39,40,41: history tables | [EventCapturePresenterImpl.kt#L167](app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventCapture/EventCapturePresenterImpl.kt#L167) | Code change | Keeps the RAMP Chart navigation item visible as a single tab when no other right-pane tabs are configured | | RAMP ACF-39,40,41: history tables | [EventPageConfigurator.kt#L13](app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventCapture/EventPageConfigurator.kt#L13) | Code change | Hides Analytics and Notes when a program-stage history table is configured, leaving only Form and Chart | | RAMP ACF-39,40,41: history tables | [EventCaptureActivity.kt#L268](app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventCapture/EventCaptureActivity.kt#L268) | Code change | Expands the event-capture history table to full width in landscape and restores the split layout afterwards | -| RAMP ACF-39,40,41: history tables | [DashboardRepositoryImpl.kt#L824](app/src/main/java/org/dhis2/usescases/teiDashboard/DashboardRepositoryImpl.kt#L824) | Code addition | Detects whether the current enrollment program has a configured RAMP history table | +| RAMP ACF-39,40,41: history tables | [DashboardRepositoryImpl.kt#L827](app/src/main/java/org/dhis2/usescases/teiDashboard/DashboardRepositoryImpl.kt#L827) | Code addition | Detects whether the current enrollment program has a configured RAMP history table | | RAMP ACF-39,40,41: history tables | [DashboardViewModel.kt#L218](app/src/main/java/org/dhis2/usescases/teiDashboard/DashboardViewModel.kt#L218) | Code addition | Adds the RAMP Chart navigation item and returns portrait TEI enrollment navigation to Details after landscape Chart viewing | | RAMP ACF-39,40,41: history tables | [TeiDashboardPageConfigurator.kt#L11](app/src/main/java/org/dhis2/usescases/teiDashboard/TeiDashboardPageConfigurator.kt#L11) | Code change | Hides Analytics and Notes when an enrollment history table is configured, leaving only Details and Chart | | RAMP ACF-39,40,41: history tables | [TeiDashboardMobileActivity.kt#L378](app/src/main/java/org/dhis2/usescases/teiDashboard/TeiDashboardMobileActivity.kt#L378) | Code addition | Expands the enrollment history table only after Chart is pressed in landscape, with Details as restore tab | | RAMP Search restriction in a program | [RampDatastoreConfig.kt#L8](commons/src/main/java/org/dhis2/commons/simprints/ramp/model/RampDatastoreConfig.kt#L8) | Code addition | Adds `programSpecificSettings` to the RAMP datastore schema, with per-program `isSearchEnabled` settings | -| RAMP Search restriction in a program | [SearchRepositoryImpl.java#L495](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchRepositoryImpl.java#L495) | Code addition | Resolves the current program's search-enabled flag through the RAMP datastore repository | +| RAMP Search restriction in a program | [SearchRepositoryImpl.java#L496](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchRepositoryImpl.java#L496) | Code addition | Resolves the current program's search-enabled flag through the RAMP datastore repository | | RAMP Search restriction in a program | [SearchTEIViewModel.kt#L254](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEIViewModel.kt#L254) | Code change | Loads the restriction into screen state, prevents opening search, and suppresses initial search prompts when disabled | | RAMP Search restriction in a program | [SearchTEUi.kt#L249](app/src/main/java/org/dhis2/usescases/searchTrackEntity/ui/SearchTEUi.kt#L249) | Code change | Lets shared search/create controls hide search actions while preserving create actions where allowed | | RAMP Search restriction in a program | [SearchTEList.kt#L234](app/src/main/java/org/dhis2/usescases/searchTrackEntity/listView/SearchTEList.kt#L234) | Code change | Applies the restriction to the list screen's search controls | @@ -109,8 +111,29 @@ upstream [dhis2/dhis2-android-capture-app](https://github.com/dhis2/dhis2-androi | RAMP Search restriction in a program | [SearchJavaToCompose.kt#L22](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchJavaToCompose.kt#L22) | Code change | Hides the landscape open-search button when the selected program disables search | | RAMP Search restriction in a program | [SearchScreenConfigurator.kt#L54](app/src/main/java/org/dhis2/usescases/searchTrackEntity/ui/SearchScreenConfigurator.kt#L54) | Code change | Keeps the landscape search panel closed when search is disabled while still allowing filters to open | | RAMP Menu option off in a program stage | [RampDatastoreConfig.kt#L9](commons/src/main/java/org/dhis2/commons/simprints/ramp/model/RampDatastoreConfig.kt#L9) | Code addition | Adds `programStageSpecificSettings` to the RAMP datastore schema, with per-stage schedule and refer option flags | -| RAMP Menu option off in a program stage | [RampDatastoreRepository.kt#L56](commons/src/main/java/org/dhis2/commons/simprints/ramp/repository/RampDatastoreRepository.kt#L56) | Code addition | Resolves per-stage schedule and refer option flags from the RAMP datastore | +| RAMP Menu option off in a program stage | [RampDatastoreRepository.kt#L88](commons/src/main/java/org/dhis2/commons/simprints/ramp/repository/RampDatastoreRepository.kt#L88) | Code addition | Resolves per-stage schedule and refer option flags from the RAMP datastore | | RAMP Menu option off in a program stage | [GetNewEventCreationTypeOptions.kt#L21](app/src/main/java/org/dhis2/usescases/teiDashboard/domain/GetNewEventCreationTypeOptions.kt#L21) | Code change | Applies datastore-controlled stage option flags when building the tracker dashboard add-event menu | +| RAMP Org unit filter shortcut | [FilterRepository.kt#L68](commons/src/main/java/org/dhis2/commons/filters/data/FilterRepository.kt#L68) | Code addition | Resolves a program's single data-capture organisation unit for the one-tap filter shortcut | +| RAMP Org unit filter shortcut | [FilterItem.kt#L34](commons/src/main/java/org/dhis2/commons/filters/FilterItem.kt#L34) | Code addition | Applies the user's organisation unit directly unless a working-list filter controls the selection | +| RAMP Org unit filter shortcut | [item_header_filter.xml#L112](commons/src/main/res/layout/item_header_filter.xml#L112) | Code addition | Adds the one-tap user organisation unit action to the organisation-unit filter header | +| RAMP Filter dialog upstream crash fix | [MainActivity.kt#L317](app/src/main/java/org/dhis2/usescases/main/MainActivity.kt#L317) | Code addition | Avoids opening period or organisation-unit filter dialogs after FragmentManager state is saved | +| RAMP Transferred patients | [TransferredPatientFilter.kt](commons/src/main/java/org/dhis2/commons/filters/data/TransferredPatientFilter.kt) | New file | Finds transferred TEIs when the program owner organisation unit differs from the active or latest enrolment unit | +| RAMP Transferred patients | [FilterRepository.kt#L154](commons/src/main/java/org/dhis2/commons/filters/data/FilterRepository.kt#L154) | Code addition | Adds and applies the transferred-patient filter, including an empty-result-safe SDK query | +| RAMP Transferred patients | [TEICardMapper.kt#L73](app/src/main/java/org/dhis2/usescases/searchTrackEntity/ui/mapper/TEICardMapper.kt#L73) | Code change | Marks result-card ownership as an emphasized `Transferred to` row with the transfer icon | +| RAMP Transferred patients | [ListCardProvider.kt#L48](commons/src/main/java/org/dhis2/commons/ui/ListCardProvider.kt#L48) | Code addition | Renders emphasized result-card information in bold while preserving its icon and expand/collapse behavior | +| RAMP Transferred patients | [TeiDashboardCardMapper.kt#L59](app/src/main/java/org/dhis2/usescases/teiDashboard/ui/mapper/TeiDashboardCardMapper.kt#L59) | Code change | Maps transferred ownership to an emphasized `Transferred to` row with the transfer icon | +| RAMP Transferred patients | [TeiDetailDashboard.kt#L61](app/src/main/java/org/dhis2/usescases/teiDashboard/ui/TeiDetailDashboard.kt#L61) | Code addition | Renders the emphasized `Transferred to` row on the TEI details card | +| RAMP ACF-54: visit numbers before dates | [RampDatastoreConfig.kt#L82](commons/src/main/java/org/dhis2/commons/simprints/ramp/model/RampDatastoreConfig.kt#L82) | Code addition | Adds per-stage opt-in for visit-number date prefixes and selects the visit-number data element | +| RAMP ACF-54: visit numbers before dates | [TeiDataRepositoryImpl.kt#L536](app/src/main/java/org/dhis2/usescases/teiDashboard/dashboardfragments/teidata/TeiDataRepositoryImpl.kt#L536) | Code addition | Reads a visit number from the configured data element for follow-up events | +| RAMP ACF-54: visit numbers before dates | [TEIEventCardMapper.kt#L85](app/src/main/java/org/dhis2/usescases/teiDashboard/dashboardfragments/teidata/teievents/ui/mapper/TEIEventCardMapper.kt#L85) | Code change | Prefixes configured follow-up event dates with `Visit :` in event cards | +| RAMP ACF-57: enrolment listings | [RampDatastoreConfig.kt#L59](commons/src/main/java/org/dhis2/commons/simprints/ramp/model/RampDatastoreConfig.kt#L59) | Code addition | Adds per-program detailed-listing settings for admission, discharge, and outcome metadata | +| RAMP ACF-57: enrolment listings | [RampDatastoreRepository.kt#L57](commons/src/main/java/org/dhis2/commons/simprints/ramp/repository/RampDatastoreRepository.kt#L57) | Code addition | Resolves normalized detailed-enrolment settings and disables the listing when it is not enabled | +| RAMP ACF-57: enrolment listings | [DetailedEnrollmentRepository.kt](app/src/main/java/org/dhis2/simprints/ramp/data/DetailedEnrollmentRepository.kt) | New file | Builds other-enrolment summaries with program, site, admission, discharge, and latest outcome | +| RAMP ACF-57: enrolment listings | [SearchRepositoryImplKt.kt#L228](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchRepositoryImplKt.kt#L228) | Code addition | Attaches configured summaries for other enrolments to TEI search results | +| RAMP ACF-57: enrolment listings | [TEICardMapper.kt#L334](app/src/main/java/org/dhis2/usescases/searchTrackEntity/ui/mapper/TEICardMapper.kt#L334) | Code change | Replaces the Programs value with a multiline listing whose program names are bold | +| RAMP ACF-63: mandatory forms force open | [FormRepositoryImpl.kt#L534](form/src/main/java/org/dhis2/form/data/FormRepositoryImpl.kt#L534) | Code change | Keeps sections with blank mandatory fields open while allowing completed sections to collapse independently | +| RAMP ACF-63: mandatory forms force open | [Form.kt#L51](form/src/main/java/org/dhis2/form/ui/Form.kt#L51) | Code change | Uses keyed, fully rendered sections and bring-into-view scrolling to keep multi-open forms responsive | +| RAMP ACF-63: mandatory forms force open | [FormViewModel.kt#L136](form/src/main/java/org/dhis2/form/ui/FormViewModel.kt#L136) | Code change | Preserves consecutive section-toggle intents so rapid expand/collapse actions are not coalesced | ### Releases From 562a583418dbf66c9c5930e44deb0289ba5c9acc Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 28 Jul 2026 15:33:45 +0100 Subject: [PATCH 11/13] Simprints RAMP: README updated --- README.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 2bc941690f..2d65610b57 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ upstream [dhis2/dhis2-android-capture-app](https://github.com/dhis2/dhis2-androi | Infra: signed APK releases | [simcapture-github-release-signed-apk.yml](.github/workflows/simcapture-github-release-signed-apk.yml) | New file | GitHub Action to create a GitHub Release on a merge to `main` | | Infra: signed APK releases | [app/build.gradle.kts#L121](app/build.gradle.kts#L121) | Code change | App's Package ID set to `com.simprints.simcapture` | | Infra: GitHub Actions filtering | [simcapture-disable-upstream-workflows.yml](.github/workflows/simcapture-disable-upstream-workflows.yml) | New file | GitHub Action to limit allowed Actions to the ones starting with `simcapture-` or `copilot-` | -| Docs: fork-specific README | [README.md#L1-L148](README.md#L1-L148) | Code change | This section in README | +| Docs: fork-specific README | [README.md#L1-L151](README.md#L1-L151) | Code change | This section in README | | Enrolment+ Possible Duplicates | [CustomIntentActivityResultContract.kt#L95](form/src/main/java/org/dhis2/form/ui/customintent/CustomIntentActivityResultContract.kt#L95) | Code addition | Treats non-identify Simprints callouts that return identification matches as `PossibleDuplicates` results | | Enrolment+ Possible Duplicates | [FormView.kt#L627](form/src/main/java/org/dhis2/form/ui/FormView.kt#L627) | Code addition | Stores the returned SID session and hands the enrolment form off to the possible duplicates search flow | | Enrolment+ Possible Duplicates | [EnrollmentActivity.kt#L296](app/src/main/java/org/dhis2/usescases/enrollment/EnrollmentActivity.kt#L296) | Code addition | Launches possible duplicates search from the enrolment form, carrying the biometric field and returned GUID matches | @@ -73,12 +73,15 @@ upstream [dhis2/dhis2-android-capture-app](https://github.com/dhis2/dhis2-androi | MFID Auto-Open Record | [SimprintsResolveSingleBiometricSearchNavigationUseCase.kt](app/src/main/java/org/dhis2/simprints/SimprintsResolveSingleBiometricSearchNavigationUseCase.kt) | New file | Resolves the biometric search to exactly one TEI before auto-opening the record | | RAMP Infra: signed APK releases | [rampcapture-github-release-signed-apk.yml](.github/workflows/rampcapture-github-release-signed-apk.yml) | New file | GitHub Action to create a RAMPcapture GitHub Release on a merge to `ramp-main` | | RAMP Infra: version info | [rampcapture-github-release-signed-apk.yml#L90](.github/workflows/rampcapture-github-release-signed-apk.yml#L90) | Code addition | Passes the computed RAMPcapture fork number into the signed release APK build as `RAMP_CAPTURE_VERSION` | -| RAMP Infra: version info | [app/build.gradle.kts#L130](app/build.gradle.kts#L130) | Code addition | Reads `RAMP_CAPTURE_VERSION`, falls back to `local build`, and exposes it as `BuildConfig.RAMP_CAPTURE_VERSION` | +| RAMP Infra: version info | [app/build.gradle.kts#L129](app/build.gradle.kts#L129) | Code addition | Reads `RAMP_CAPTURE_VERSION`, falls back to `local build`, and exposes it as `BuildConfig.RAMP_CAPTURE_VERSION` | | RAMP Infra: version info | [ContextExtensions.kt#L17](app/src/main/java/org/dhis2/bindings/ContextExtensions.kt#L17) | Code addition | Formats the RAMPcapture build version for About, including commit SHA if a non-release build | | RAMP Infra: version info | [fragment_about.xml#L31](app/src/main/res/layout/fragment_about.xml#L31) | Code addition | Adds a RAMPcapture version row above the upstream DHIS2 app and SDK version labels on the About screen | | RAMP Infra: version info | [AboutFragment.kt#L45](app/src/main/java/org/dhis2/usescases/about/AboutFragment.kt#L45) | Code addition | Binds the RAMPcapture version text while keeping the DHIS2 app and SDK version | | RAMP Infra: version info | [strings.xml#L226](app/src/main/res/values/strings.xml#L226) | Code addition | Adds the `RAMPcapture Version: %s` label for the new About screen row | | RAMP Infra: application ID | [app/build.gradle.kts#L121](app/build.gradle.kts#L121) | Code change | Sets RAMPcapture's package ID to `com.acf.dhis.androidnutritioncapture` | +| RAMP Infra: Google Play releases | [rampcapture-release-google-play.yml](.github/workflows/rampcapture-release-google-play.yml) | New file | GitHub Action to build a signed AAB, deploy it to Google Play's internal track, and create a draft release with universal APK | +| RAMP Infra: Google Play releases | [app/build.gradle.kts#L131](app/build.gradle.kts#L131) | Code change | Uses the workflow's date/run-based Android version code | +| RAMP Infra: Google Play releases | [download-universal-apk.py](scripts/rampcapture-release/download-universal-apk.py) | New file | Polls Google Play for the Play-signed universal APK and downloads it for attachment to the draft GitHub Release | | RAMP ACF-1,39,40,41: datastore config | [RampDatastoreConfig.kt#L5](commons/src/main/java/org/dhis2/commons/simprints/ramp/model/RampDatastoreConfig.kt#L5) | New file | Defines the RAMP datastore config schema for enabled data-element history charts and program-stage history tables | | RAMP ACF-1,39,40,41: datastore config | [RampDatastoreConfig.kt#L29](commons/src/main/java/org/dhis2/commons/simprints/ramp/model/RampDatastoreConfig.kt#L29) | Code addition | Adds optional per-chart decimal-place limits to the datastore config | | RAMP ACF-1,39,40,41: datastore config | [RampDatastoreRepository.kt#L16](commons/src/main/java/org/dhis2/commons/simprints/ramp/repository/RampDatastoreRepository.kt#L16) | New file | Reads, validates, caches, and downloads the `simprints/ramp` DHIS2 datastore config used by RAMP features | From 9fb455e3b249825073c912daab596247e3f4c316 Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 28 Jul 2026 16:13:09 +0100 Subject: [PATCH 12/13] Simprints RAMP: Hiding user's org unit selection shortcut when unavailable --- .../main/java/org/dhis2/commons/filters/data/FilterRepository.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/commons/src/main/java/org/dhis2/commons/filters/data/FilterRepository.kt b/commons/src/main/java/org/dhis2/commons/filters/data/FilterRepository.kt index 7c5e9189de..4e04b288cc 100644 --- a/commons/src/main/java/org/dhis2/commons/filters/data/FilterRepository.kt +++ b/commons/src/main/java/org/dhis2/commons/filters/data/FilterRepository.kt @@ -616,7 +616,6 @@ constructor( observableOpenFilter, resources.filterOrgUnitLabel(), userOrgUnit = singleCaptureOrgUnit(program.uid()), - showUserOrgUnitAction = true, ) defaultTrackerFilters[ProgramFilter.SYNC_STATUS] = SyncStateFilter( From 1b633a3dc73c9806388da456a30bcdef34ac1b5c Mon Sep 17 00:00:00 2001 From: Alex Date: Wed, 29 Jul 2026 13:00:05 +0100 Subject: [PATCH 13/13] Simprints RAMP: Obsolete fork number limit & draft status removed from the release workflow --- .../rampcapture-release-google-play.yml | 32 +------------------ 1 file changed, 1 insertion(+), 31 deletions(-) diff --git a/.github/workflows/rampcapture-release-google-play.yml b/.github/workflows/rampcapture-release-google-play.yml index 3e82be4b9e..2e9c4f03e7 100644 --- a/.github/workflows/rampcapture-release-google-play.yml +++ b/.github/workflows/rampcapture-release-google-play.yml @@ -56,11 +56,6 @@ jobs: next_fork_number=$((last_fork_number + 1)) - if (( next_fork_number > 99 )); then - echo "::error::Fork number $next_fork_number exceeds 2 digits (max 99). Bump the base vCode in gradle/libs.versions.toml and reset fork numbering." >&2 - exit 1 - fi - release_tag="RAMPcapture-DHIS2-v${{ steps.read-version.outputs.vName }}-fork-${next_fork_number}" run_number=${{ github.run_number }} @@ -127,30 +122,6 @@ jobs: permissions: contents: write steps: - - name: Delete previous draft release - env: - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - - # Keep only one RAMPcapture draft release at a time: remove any - # leftover draft from a prior run before creating the new one. - # Draft releases don't create an actual git tag until published, - # so there's no tag to clean up here. Published (non-draft) - # releases are left untouched. - while IFS=$'\t' read -r tag_name is_draft; do - [[ "$tag_name" =~ ^RAMPcapture-DHIS2-v[0-9]+(\.[0-9]+)*-fork-[0-9]+$ ]] || continue - [[ "$is_draft" == "true" ]] || continue - - echo "Deleting previous draft release: $tag_name" - gh release delete "$tag_name" \ - --repo "${{ github.repository }}" \ - --yes - done < <( - gh api --paginate "repos/${{ github.repository }}/releases?per_page=100" \ - --jq '.[] | [.tag_name, .draft] | @tsv' - ) - - name: Create GitHub release env: GH_TOKEN: ${{ github.token }} @@ -171,8 +142,7 @@ jobs: --repo "${{ github.repository }}" \ --target "${{ github.sha }}" \ --title "${{ needs.build-aab.outputs.releaseTag }}" \ - --notes "$release_notes" \ - --draft + --notes "$release_notes" deploy-google-play: needs: build-aab