From 7d44d279bdbdb670fce897b45b1749984c1dd746 Mon Sep 17 00:00:00 2001 From: Alex Date: Mon, 1 Jun 2026 17:34:52 +0100 Subject: [PATCH 01/12] Simprints/RAMP biometrics behavior adjustments: automatically choosing biometric search, always showing list if empty matches, dropping out to search form if SID refusal or error. --- ...imprintsMapBiometricSearchResultUseCase.kt | 62 +++++++ .../searchTrackEntity/SearchTEActivity.kt | 2 +- .../searchTrackEntity/SearchTEIViewModel.kt | 123 ++++++++++++-- .../searchTrackEntity/SearchTEModule.java | 39 ++++- .../SearchTeiViewModelFactory.kt | 3 + .../listView/SearchTEList.kt | 2 +- .../searchTrackEntity/mapView/SearchTEMap.kt | 2 +- .../SearchParametersScreen.kt | 153 +++++------------- app/src/main/res/values/strings.xml | 1 - ...intsMapBiometricSearchResultUseCaseTest.kt | 118 ++++++++++++++ .../SearchTEIViewModelTest.kt | 101 ++++++++++-- .../simprints/utils/SimprintsIntentUtils.kt | 3 + .../utils/SimprintsIntentUtilsTest.kt | 18 +++ 13 files changed, 479 insertions(+), 148 deletions(-) create mode 100644 app/src/main/java/org/dhis2/simprints/SimprintsMapBiometricSearchResultUseCase.kt create mode 100644 app/src/test/java/org/dhis2/simprints/SimprintsMapBiometricSearchResultUseCaseTest.kt diff --git a/app/src/main/java/org/dhis2/simprints/SimprintsMapBiometricSearchResultUseCase.kt b/app/src/main/java/org/dhis2/simprints/SimprintsMapBiometricSearchResultUseCase.kt new file mode 100644 index 00000000000..972606693f6 --- /dev/null +++ b/app/src/main/java/org/dhis2/simprints/SimprintsMapBiometricSearchResultUseCase.kt @@ -0,0 +1,62 @@ +package org.dhis2.simprints + +import android.app.Activity +import android.content.Intent +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken +import org.dhis2.commons.simprints.repository.SimprintsSessionRepository +import org.dhis2.commons.simprints.usecases.SimprintsHasAutoOpenEligibleIdentificationUseCase +import org.dhis2.commons.simprints.utils.SimprintsIntentUtils +import org.dhis2.mobile.commons.model.CustomIntentResponseDataModel +import timber.log.Timber + +class SimprintsMapBiometricSearchResultUseCase( + private val sessionRepository: SimprintsSessionRepository, + private val hasAutoOpenEligibleIdentification: SimprintsHasAutoOpenEligibleIdentificationUseCase, + private val resultMapper: SimprintsCustomIntentResultMapper, +) { + sealed class Result { + data class Identification( + val value: String, + val hasAutoOpenEligibleIdentification: Boolean, + ) : Result() + + object SearchDropout : Result() + + object NoMatches : Result() + } + + operator fun invoke( + responseDataJson: String?, + resultCode: Int, + data: Intent?, + capturesSessionId: Boolean, + ): Result { + if (resultCode != Activity.RESULT_OK || !SimprintsIntentUtils.hasIdentificationResult(data)) { + return Result.SearchDropout + } + + if (capturesSessionId) { + SimprintsIntentUtils.extractSessionId(data?.extras)?.let(sessionRepository::save) + } + + val responseData = responseDataJson?.parseResponseData() ?: return Result.NoMatches + val value = resultMapper.map(responseData, data) ?: return Result.NoMatches + + return Result.Identification( + value = value, + hasAutoOpenEligibleIdentification = hasAutoOpenEligibleIdentification(data?.extras), + ) + } + + private fun String.parseResponseData(): List? = + try { + Gson().fromJson>( + this, + object : TypeToken>() {}.type, + ) + } catch (e: Exception) { + Timber.e(e, "Failed to parse CustomIntentResponseDataModel") + null + } +} 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 9c93bc87b68..d80600ff4f6 100644 --- a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEActivity.kt +++ b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEActivity.kt @@ -209,7 +209,7 @@ class SearchTEActivity : .setLandscapeOpenSearchButton( viewModel, ) { - viewModel.setSearchScreen() + viewModel.onSearchFormRequested() } setupBottomNavigation() 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 7aefc1d7cc0..807287ff535 100644 --- a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEIViewModel.kt +++ b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEIViewModel.kt @@ -58,6 +58,7 @@ import org.dhis2.maps.usecases.MapStyleConfiguration import org.dhis2.mobile.commons.coroutine.CoroutineTracker import org.dhis2.simprints.SimprintsLoadBiometricSearchResultsUseCase import org.dhis2.simprints.SimprintsLoadPossibleDuplicatesSearchResultsUseCase +import org.dhis2.simprints.SimprintsMapBiometricSearchResultUseCase import org.dhis2.simprints.SimprintsSearchViewModel import org.dhis2.tracker.NavigationBarUIState import org.dhis2.usescases.searchTrackEntity.listView.SearchResult @@ -73,6 +74,7 @@ import org.maplibre.geojson.Feature import timber.log.Timber const val TEI_TYPE_SEARCH_MAX_RESULTS = 5 +private const val SIMPRINTS_BIOMETRIC_NO_MATCH_QUERY_VALUE = "__SIMPRINTS_BIOMETRIC_NO_MATCH__" sealed class SimprintsNavigationAction { data class LaunchConfirmIdentity( @@ -109,6 +111,7 @@ class SearchTEIViewModel( private val filterManager: FilterManager, private val simprintsSearchViewModel: SimprintsSearchViewModel, private val loadSimprintsBiometricSearchResultsUseCase: SimprintsLoadBiometricSearchResultsUseCase, + private val mapSimprintsBiometricSearchResult: SimprintsMapBiometricSearchResultUseCase, ) : ViewModel() { private var layersVisibility: Map = emptyMap() @@ -148,6 +151,9 @@ class SearchTEIViewModel( val isScrollingDown = MutableLiveData(false) val simprintsBiometricSearchNavigation: Flow = simprintsSearchViewModel.simprintsBiometricSearchNavigation + private val _simprintsBiometricIdentificationLaunch = Channel(Channel.BUFFERED) + val simprintsBiometricIdentificationLaunch: Flow = + _simprintsBiometricIdentificationLaunch.receiveAsFlow() val isSimprintsBiometricSearch: LiveData = simprintsSearchViewModel.isSimprintsBiometricSearch val isSimprintsUseLastBiometricsLabel: LiveData = @@ -182,7 +188,6 @@ class SearchTEIViewModel( private val onNewSearch = MutableSharedFlow(extraBufferCapacity = 1) private var simprintsPossibleDuplicatesAutoNoneOfAboveTriggered: Boolean = false - private var keepSearchScreenOpenForSimprintsBiometricNoMatches: Boolean = false private val loadSimprintsPossibleDuplicatesSearchResultsUseCase = SimprintsLoadPossibleDuplicatesSearchResultsUseCase(searchRepository, searchRepositoryKt) @@ -286,7 +291,7 @@ class SearchTEIViewModel( fun setListScreen() { _screenState.value.takeIf { it?.screenState == SearchScreenState.MAP }?.let { - searching = (it as SearchList).isSearching + searching = (it as SearchList).isSearching || isSimprintsBiometricNoMatchesSearch() } val displayFrontPageList = searchRepository.getProgram(initialProgramUid)?.displayFrontPageList() ?: true @@ -295,9 +300,6 @@ class SearchTEIViewModel( !searchRepository.canCreateInProgramWithoutSearch() && !searching && filtersActive.value == false - val shouldOpenSearch = - shouldForceSearch || keepSearchScreenOpenForSimprintsBiometricNoMatches - createButtonScrollVisibility.postValue( if (searching) { true @@ -324,7 +326,7 @@ class SearchTEIViewModel( ?.minAttributesRequiredToSearch() ?: 1, isForced = shouldForceSearch, - isOpened = shouldOpenSearch, + isOpened = shouldForceSearch, ), searchFilters = SearchFilters( @@ -412,6 +414,24 @@ class SearchTEIViewModel( ) } + fun onSearchFormRequested() { + if (shouldLaunchSimprintsBiometricIdentification()) { + simprintsSearchViewModel.clearPendingSession() + viewModelScope.launch { + _simprintsBiometricIdentificationLaunch.send(Unit) + } + } else { + setSearchScreen() + } + } + + private fun shouldLaunchSimprintsBiometricIdentification(): Boolean = + queryData.isEmpty() && + _isSimprintsPossibleDuplicatesSearch.value != true && + searchParametersUiState.items.any { field -> + SimprintsIntentUtils.isIdentifyCallout(field.customIntent) + } + fun setPreviousScreen() { when (_screenState.value?.previousSate) { SearchScreenState.LIST -> setListScreen() @@ -472,7 +492,6 @@ class SearchTEIViewModel( } fun clearQueryData() { - clearSimprintsBiometricNoMatchesState() queryData.clear() clearSearchParameters() simprintsSearchViewModel.clearPendingSession() @@ -507,7 +526,6 @@ class SearchTEIViewModel( } fun onSimprintsBiometricSearchNavigation() { - clearSimprintsBiometricNoMatchesState() onNavigationPageChanged(NavigationPage.LIST_VIEW) setListScreen() searchRepository.clearFetchedList() @@ -569,6 +587,8 @@ class SearchTEIViewModel( } return@withContext flow { emit(PagingData.from(simprintsPossibleDuplicatesResults)) } + } else if (isSimprintsBiometricNoMatchesSearch()) { + return@withContext flow { emit(PagingData.empty()) } } else { loadSimprintsBiometricSearchResults( searchParametersModel = searchParametersModel, @@ -655,6 +675,9 @@ class SearchTEIViewModel( ) return@withContext if (searching) { + if (isSimprintsBiometricNoMatchesSearch()) { + return@withContext flow { emit(PagingData.empty()) } + } loadSimprintsBiometricSearchResults( searchParametersModel = searchParametersModel, isOnline = networkUtils.isOnline(), @@ -724,7 +747,6 @@ class SearchTEIViewModel( } fun onSearch() { - clearSimprintsBiometricNoMatchesState() searchRepository.clearFetchedList() performSearch() } @@ -782,6 +804,7 @@ class SearchTEIViewModel( private fun canPerformSearch(): Boolean = (_isSimprintsPossibleDuplicatesSearch.value == true && queryData.isNotEmpty()) || + isSimprintsBiometricNoMatchesSearch() || minAttributesToSearchCheck() || displayFrontPageList() @@ -890,7 +913,6 @@ class SearchTEIViewModel( value: String?, hasAutoOpenEligibleSimprintsIdentification: Boolean, ) { - clearSimprintsBiometricNoMatchesState() simprintsSearchViewModel.onSimprintsBiometricIdentificationResult( uid = uid, value = value, @@ -898,15 +920,83 @@ class SearchTEIViewModel( ) } - fun onSimprintsBiometricNoMatches() { - keepSearchScreenOpenForSimprintsBiometricNoMatches = true - setSearchScreen() + fun onSimprintsBiometricSearchResult( + uid: String, + valueType: ValueType?, + responseDataJson: String?, + resultCode: Int, + data: Intent?, + capturesSessionId: Boolean, + ) { + when ( + val result = + mapSimprintsBiometricSearchResult( + responseDataJson = responseDataJson, + resultCode = resultCode, + data = data, + capturesSessionId = capturesSessionId, + ) + ) { + is SimprintsMapBiometricSearchResultUseCase.Result.Identification -> { + onSimprintsBiometricIdentificationResult( + uid = uid, + value = result.value, + hasAutoOpenEligibleSimprintsIdentification = result.hasAutoOpenEligibleIdentification, + ) + onParameterIntent( + FormIntent.OnSave( + uid = uid, + value = result.value, + valueType = valueType, + ), + ) + } + + SimprintsMapBiometricSearchResultUseCase.Result.NoMatches -> + onSimprintsBiometricNoMatches(uid) + + SimprintsMapBiometricSearchResultUseCase.Result.SearchDropout -> + setSearchScreen() + } } - fun clearSimprintsBiometricNoMatchesState() { - keepSearchScreenOpenForSimprintsBiometricNoMatches = false + fun onSimprintsBiometricNoMatches(uid: String) { + queryData[uid] = listOf(SIMPRINTS_BIOMETRIC_NO_MATCH_QUERY_VALUE) + updateSimprintsBiometricNoMatchesParameter(uid) + updateSearch() + searchParametersUiState = + searchParametersUiState.copy( + clearSearchEnabled = true, + searchedItems = getFriendlyQueryData(), + ) + onNavigationPageChanged(NavigationPage.LIST_VIEW) + searching = true + setListScreen() + searchRepository.clearFetchedList() + performSearch() + } + + private fun updateSimprintsBiometricNoMatchesParameter(uid: String) { + val updatedItems = + searchParametersUiState.items.map { + if (it.uid == uid) { + (it as FieldUiModelImpl).copy( + value = SIMPRINTS_BIOMETRIC_NO_MATCH_QUERY_VALUE, + displayName = resourceManager.getString(R.string.simprints_biometric_search), + ) + } else { + it + } + } + searchParametersUiState = searchParametersUiState.copy(items = updatedItems) + refreshSimprintsUiState() } + private fun isSimprintsBiometricNoMatchesSearch(): Boolean = + queryData.values.any { values -> + values?.singleOrNull() == SIMPRINTS_BIOMETRIC_NO_MATCH_QUERY_VALUE + } + fun refreshSimprintsUiState() { simprintsSearchViewModel.refreshSimprintsUiState(searchParametersUiState.items) } @@ -1031,6 +1121,9 @@ class SearchTEIViewModel( ) { val result = when { + isSimprintsBiometricNoMatchesSearch() -> + listOf(SearchResult(SearchResult.SearchResultType.NO_RESULTS)) + !canDisplayResults -> { listOf(SearchResult(SearchResult.SearchResultType.TOO_MANY_RESULTS)) } diff --git a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEModule.java b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEModule.java index 8117a68464e..b7e7e4f7bc2 100644 --- a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEModule.java +++ b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEModule.java @@ -24,6 +24,7 @@ import org.dhis2.commons.resources.ResourceManager; import org.dhis2.commons.simprints.repository.SimprintsD2Repository; import org.dhis2.commons.simprints.repository.SimprintsSessionRepository; +import org.dhis2.commons.simprints.usecases.SimprintsHasAutoOpenEligibleIdentificationUseCase; import org.dhis2.commons.simprints.usecases.SimprintsOrderSearchResultsByIdentifyResponseUseCase; import org.dhis2.commons.simprints.usecases.SimprintsResolveConfirmIdentityCalloutUseCase; import org.dhis2.commons.schedulers.SchedulerProvider; @@ -66,11 +67,13 @@ import org.dhis2.mobile.commons.customintents.CustomIntentRepository; import org.dhis2.mobile.commons.customintents.CustomIntentRepositoryImpl; import org.dhis2.mobile.commons.reporting.CrashReportController; +import org.dhis2.simprints.SimprintsCustomIntentResultMapper; import org.dhis2.simprints.SimprintsLoadBiometricSearchResultsUseCase; -import org.dhis2.tracker.data.ProfilePictureProvider; -import org.dhis2.ui.ThemeManager; +import org.dhis2.simprints.SimprintsMapBiometricSearchResultUseCase; import org.dhis2.simprints.SimprintsResolveSingleBiometricSearchNavigationUseCase; import org.dhis2.simprints.di.SimprintsSearchViewModelFactory; +import org.dhis2.tracker.data.ProfilePictureProvider; +import org.dhis2.ui.ThemeManager; import org.dhis2.usescases.events.EventInfoProvider; import org.dhis2.usescases.searchTrackEntity.ui.mapper.TEICardMapper; import org.dhis2.usescases.tracker.TrackedEntityInstanceInfoProvider; @@ -401,6 +404,32 @@ SimprintsLoadBiometricSearchResultsUseCase provideSimprintsLoadBiometricSearchRe ); } + @Provides + @PerActivity + SimprintsHasAutoOpenEligibleIdentificationUseCase provideSimprintsHasAutoOpenEligibleIdentificationUseCase() { + return new SimprintsHasAutoOpenEligibleIdentificationUseCase(); + } + + @Provides + @PerActivity + SimprintsMapBiometricSearchResultUseCase provideSimprintsMapBiometricSearchResultUseCase( + SimprintsSessionRepository simprintsSessionRepository, + SimprintsHasAutoOpenEligibleIdentificationUseCase hasAutoOpenEligibleIdentification, + SimprintsCustomIntentResultMapper resultMapper + ) { + return new SimprintsMapBiometricSearchResultUseCase( + simprintsSessionRepository, + hasAutoOpenEligibleIdentification, + resultMapper + ); + } + + @Provides + @PerActivity + SimprintsCustomIntentResultMapper provideSimprintsCustomIntentResultMapper() { + return new SimprintsCustomIntentResultMapper(); + } + @Provides @PerActivity SearchTeiViewModelFactory providesViewModelFactory( @@ -414,7 +443,8 @@ SearchTeiViewModelFactory providesViewModelFactory( FilterManager filterManager, ProgramConfigurationRepository programConfigurationRepository, SimprintsSearchViewModelFactory simprintsSearchViewModelFactory, - SimprintsLoadBiometricSearchResultsUseCase loadSimprintsBiometricSearchResultsUseCase + SimprintsLoadBiometricSearchResultsUseCase loadSimprintsBiometricSearchResultsUseCase, + SimprintsMapBiometricSearchResultUseCase mapSimprintsBiometricSearchResult ) { return new SearchTeiViewModelFactory( searchRepository, @@ -436,7 +466,8 @@ SearchTeiViewModelFactory providesViewModelFactory( filterManager, (SearchTEActivity) moduleContext, simprintsSearchViewModelFactory, - loadSimprintsBiometricSearchResultsUseCase + loadSimprintsBiometricSearchResultsUseCase, + mapSimprintsBiometricSearchResult ); } diff --git a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTeiViewModelFactory.kt b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTeiViewModelFactory.kt index 90968c923ac..0fca0b3268d 100644 --- a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTeiViewModelFactory.kt +++ b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTeiViewModelFactory.kt @@ -9,6 +9,7 @@ import org.dhis2.commons.viewmodel.DispatcherProvider import org.dhis2.form.ui.provider.DisplayNameProvider import org.dhis2.maps.usecases.MapStyleConfiguration import org.dhis2.simprints.SimprintsLoadBiometricSearchResultsUseCase +import org.dhis2.simprints.SimprintsMapBiometricSearchResultUseCase import org.dhis2.simprints.SimprintsSearchViewModel import org.dhis2.simprints.di.SimprintsSearchViewModelFactory @@ -28,6 +29,7 @@ class SearchTeiViewModelFactory( private val searchActivity: SearchTEActivity, private val simprintsSearchViewModelFactory: SimprintsSearchViewModelFactory, private val loadSimprintsBiometricSearchResultsUseCase: SimprintsLoadBiometricSearchResultsUseCase, + private val mapSimprintsBiometricSearchResult: SimprintsMapBiometricSearchResultUseCase, ) : ViewModelProvider.Factory { override fun create(modelClass: Class): T = SearchTEIViewModel( @@ -45,5 +47,6 @@ class SearchTeiViewModelFactory( filterManager, ViewModelProvider(searchActivity, simprintsSearchViewModelFactory)[SimprintsSearchViewModel::class.java], loadSimprintsBiometricSearchResultsUseCase, + mapSimprintsBiometricSearchResult, ) as T } diff --git a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/listView/SearchTEList.kt b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/listView/SearchTEList.kt index 2fa18b89414..e2b2021cfdf 100644 --- a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/listView/SearchTEList.kt +++ b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/listView/SearchTEList.kt @@ -244,7 +244,7 @@ class SearchTEList : FragmentGlobalAbstract() { closeFilterVisibility = isFilterOpened, isLandscape = isLandscape(), queryData = queryData, - onSearchClick = { viewModel.setSearchScreen() }, + onSearchClick = { viewModel.onSearchFormRequested() }, onEnrollClick = { viewModel.onEnrollClick() }, onCloseFilters = { viewModel.onFiltersClick(isLandscape()) }, onClearSearchQuery = { diff --git a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/mapView/SearchTEMap.kt b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/mapView/SearchTEMap.kt index 27b88c4f36e..541bcb66871 100644 --- a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/mapView/SearchTEMap.kt +++ b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/mapView/SearchTEMap.kt @@ -194,7 +194,7 @@ class SearchTEMap : FragmentGlobalAbstract() { ) }, ) { - viewModel.setSearchScreen() + viewModel.onSearchFormRequested() } mapDataFinishedLoading?.let { if (it.value) { diff --git a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/searchparameters/SearchParametersScreen.kt b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/searchparameters/SearchParametersScreen.kt index 9a865c8539a..f13ee538e22 100644 --- a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/searchparameters/SearchParametersScreen.kt +++ b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/searchparameters/SearchParametersScreen.kt @@ -1,6 +1,5 @@ package org.dhis2.usescases.searchTrackEntity.searchparameters -import android.app.Activity.RESULT_OK import android.content.Intent import android.content.res.Configuration import androidx.activity.compose.rememberLauncherForActivityResult @@ -25,7 +24,6 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -47,24 +45,21 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.google.gson.Gson -import com.google.gson.reflect.TypeToken import com.journeyapps.barcodescanner.ScanOptions +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.launch import org.dhis2.R import org.dhis2.commons.Constants import org.dhis2.commons.resources.ColorUtils import org.dhis2.commons.resources.ResourceManager -import org.dhis2.commons.simprints.usecases.SimprintsHasAutoOpenEligibleIdentificationUseCase import org.dhis2.commons.simprints.utils.SimprintsIntentUtils import org.dhis2.form.data.scan.ScanContract -import org.dhis2.form.di.Injector import org.dhis2.form.model.FieldUiModel import org.dhis2.form.model.FieldUiModelImpl -import org.dhis2.form.ui.customintent.CustomIntentActivityResultContract import org.dhis2.form.ui.event.RecyclerViewUiEvents import org.dhis2.form.ui.intent.FormIntent -import org.dhis2.mobile.commons.model.CustomIntentResponseDataModel import org.dhis2.mobile.commons.orgunit.OrgUnitSelectorScope import org.dhis2.usescases.searchTrackEntity.SearchTEIViewModel import org.dhis2.usescases.searchTrackEntity.searchparameters.model.SearchParametersUiState @@ -79,15 +74,14 @@ import org.hisp.dhis.mobile.ui.designsystem.theme.Radius import org.hisp.dhis.mobile.ui.designsystem.theme.Shape import org.hisp.dhis.mobile.ui.designsystem.theme.SurfaceColor import org.hisp.dhis.mobile.ui.designsystem.theme.TextColor -import timber.log.Timber @Composable fun SearchParametersScreen( resourceManager: ResourceManager, uiState: SearchParametersUiState, intentHandler: (FormIntent) -> Unit, - onSimprintsBiometricIdentificationResult: (String, String?, Boolean) -> Unit, - onSimprintsBiometricNoMatches: () -> Unit, + onSimprintsBiometricSearchResult: (String, ValueType?, String?, Int, Intent?, Boolean) -> Unit, + simprintsBiometricIdentificationLaunch: Flow = emptyFlow(), onShowOrgUnit: ( uid: String, preselectedOrgUnits: List, @@ -101,69 +95,51 @@ fun SearchParametersScreen( val snackBarHostState = remember { SnackbarHostState() } val coroutineScope = rememberCoroutineScope() val focusManager = LocalFocusManager.current - val context = LocalContext.current.applicationContext val configuration = LocalConfiguration.current - var showSimprintsBiometricNoMatchesMessage by rememberSaveable { mutableStateOf(false) } var pendingSimprintsFieldUid by rememberSaveable { mutableStateOf(null) } var pendingSimprintsValueTypeName by rememberSaveable { mutableStateOf(null) } var pendingSimprintsResponseDataJson by rememberSaveable { mutableStateOf(null) } var pendingSimprintsCapturesSessionId by rememberSaveable { mutableStateOf(false) } + val gson = remember { Gson() } - val simprintsSessionRepository = - remember(context) { - Injector.provideSimprintsSessionRepository(context) - } - val simprintsHasAutoOpenEligibleIdentificationUseCase = - remember { - SimprintsHasAutoOpenEligibleIdentificationUseCase() - } val simprintsIdentifyLauncher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> val uid = pendingSimprintsFieldUid val valueType = pendingSimprintsValueTypeName ?.let(ValueType::valueOf) - val returnedValue = - mapPendingSimprintsSearchResult( - responseDataJson = pendingSimprintsResponseDataJson, - resultCode = result.resultCode, - data = result.data, - capturesSessionId = pendingSimprintsCapturesSessionId, - sessionRepository = simprintsSessionRepository, - ) + val responseDataJson = pendingSimprintsResponseDataJson + val capturesSessionId = pendingSimprintsCapturesSessionId pendingSimprintsFieldUid = null pendingSimprintsValueTypeName = null pendingSimprintsResponseDataJson = null pendingSimprintsCapturesSessionId = false - val shouldShowNoMatchesMessage = - shouldShowSimprintsBiometricNoMatchesMessage( - resultCode = result.resultCode, - returnedValue = returnedValue, - ) - if (uid != null && result.resultCode == RESULT_OK && returnedValue != null) { - showSimprintsBiometricNoMatchesMessage = false - onSimprintsBiometricIdentificationResult( + if (uid != null) { + onSimprintsBiometricSearchResult( uid, - returnedValue, - simprintsHasAutoOpenEligibleIdentificationUseCase(result.data?.extras), - ) - intentHandler( - FormIntent.OnSave( - uid = uid, - value = returnedValue, - valueType = valueType, - ), + valueType, + responseDataJson, + result.resultCode, + result.data, + capturesSessionId, ) - } else { - if (shouldShowNoMatchesMessage) { - onSimprintsBiometricNoMatches() - } - showSimprintsBiometricNoMatchesMessage = shouldShowNoMatchesMessage } } + fun launchSimprintsBiometricIdentification(fieldUiModel: FieldUiModel) { + val customIntent = fieldUiModel.customIntent ?: return + if (!SimprintsIntentUtils.isIdentifyCallout(customIntent)) return + + val callout = SimprintsIntentUtils.prepareCallout(customIntent) + pendingSimprintsFieldUid = fieldUiModel.uid + pendingSimprintsValueTypeName = fieldUiModel.valueType?.name + pendingSimprintsResponseDataJson = callout.responseData?.let(gson::toJson) + pendingSimprintsCapturesSessionId = true + simprintsIdentifyLauncher.launch(callout.launchIntent) + } + val scanContract = remember { ScanContract() } val qrScanLauncher = rememberLauncherForActivityResult( @@ -241,13 +217,21 @@ fun SearchParametersScreen( LaunchedEffect(uiState.isOnBackPressed) { uiState.isOnBackPressed.collectLatest { if (it) { - showSimprintsBiometricNoMatchesMessage = false focusManager.clearFocus() onClose() } } } + LaunchedEffect(simprintsBiometricIdentificationLaunch, uiState.items) { + simprintsBiometricIdentificationLaunch.collectLatest { + uiState.items + .firstOrNull { fieldUiModel -> + SimprintsIntentUtils.isIdentifyCallout(fieldUiModel.customIntent) + }?.let(::launchSimprintsBiometricIdentification) + } + } + val backgroundShape = when (configuration.orientation) { Configuration.ORIENTATION_LANDSCAPE -> @@ -343,7 +327,6 @@ fun SearchParametersScreen( capturesSessionId, launchIntent, -> - showSimprintsBiometricNoMatchesMessage = false pendingSimprintsFieldUid = uid pendingSimprintsValueTypeName = valueType?.name pendingSimprintsResponseDataJson = responseDataJson @@ -359,16 +342,6 @@ fun SearchParametersScreen( ), ) } - - if (showSimprintsBiometricNoMatchesMessage) { - item { - Text( - text = resourceManager.getString(R.string.simprints_biometric_search_no_matches), - modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp), - color = Color.Black.copy(alpha = 0.6f), - ) - } - } } if (uiState.clearSearchEnabled) { @@ -390,7 +363,6 @@ fun SearchParametersScreen( }, ) { focusManager.clearFocus() - showSimprintsBiometricNoMatchesMessage = false onClear() } } @@ -422,7 +394,6 @@ fun SearchParametersScreen( ) }, ) { - showSimprintsBiometricNoMatchesMessage = false focusManager.clearFocus() onSearch() } @@ -453,8 +424,7 @@ fun SearchFormPreview() { }, ), intentHandler = {}, - onSimprintsBiometricIdentificationResult = { _, _, _ -> }, - onSimprintsBiometricNoMatches = {}, + onSimprintsBiometricSearchResult = { _, _, _, _, _, _ -> }, onShowOrgUnit = { _, _, _, _ -> }, onSearch = {}, onClear = {}, @@ -462,49 +432,6 @@ fun SearchFormPreview() { ) } -private fun mapPendingSimprintsSearchResult( - responseDataJson: String?, - resultCode: Int, - data: Intent?, - capturesSessionId: Boolean, - sessionRepository: org.dhis2.commons.simprints.repository.SimprintsSessionRepository, -): String? { - if (resultCode != RESULT_OK) { - return null - } - - val responseData = - responseDataJson - ?.let { - try { - Gson().fromJson>( - it, - object : TypeToken>() {}.type, - ) - } catch (e: Exception) { - Timber.e(e, "Failed to parse CustomIntentResponseDataModel") - null - } - } ?: return null - - val returnedValue = - CustomIntentActivityResultContract() - .mapIntentResponseData(responseData, data) - ?.takeUnless(List::isEmpty) - ?.joinToString(separator = ",") ?: return null - - if (capturesSessionId) { - SimprintsIntentUtils.extractSessionId(data?.extras)?.let(sessionRepository::save) - } - - return returnedValue -} - -internal fun shouldShowSimprintsBiometricNoMatchesMessage( - resultCode: Int, - returnedValue: String?, -): Boolean = resultCode == RESULT_OK && returnedValue == null - @Preview(showBackground = true) @Composable fun SearchFormPreviewWithClear() { @@ -529,8 +456,7 @@ fun SearchFormPreviewWithClear() { }, ), intentHandler = {}, - onSimprintsBiometricIdentificationResult = { _, _, _ -> }, - onSimprintsBiometricNoMatches = {}, + onSimprintsBiometricSearchResult = { _, _, _, _, _, _ -> }, onShowOrgUnit = { _, _, _, _ -> }, onSearch = {}, onClear = {}, @@ -562,8 +488,8 @@ fun initSearchScreen( uiState = viewModel.searchParametersUiState, onSearch = viewModel::onSearch, intentHandler = viewModel::onParameterIntent, - onSimprintsBiometricIdentificationResult = viewModel::onSimprintsBiometricIdentificationResult, - onSimprintsBiometricNoMatches = viewModel::onSimprintsBiometricNoMatches, + onSimprintsBiometricSearchResult = viewModel::onSimprintsBiometricSearchResult, + simprintsBiometricIdentificationLaunch = viewModel.simprintsBiometricIdentificationLaunch, onShowOrgUnit = onShowOrgUnit, onClear = { onClear() @@ -571,7 +497,6 @@ fun initSearchScreen( viewModel.clearFocus() }, onClose = { - viewModel.clearSimprintsBiometricNoMatchesState() viewModel.clearFocus() }, ) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d06b6357755..1dbaf500cf1 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -84,7 +84,6 @@ Biometric search Possible duplicates No biometric match?\nSearch by name instead - No biometric search matches. Try searching by name, etc. None of the above Add new %s Enter text diff --git a/app/src/test/java/org/dhis2/simprints/SimprintsMapBiometricSearchResultUseCaseTest.kt b/app/src/test/java/org/dhis2/simprints/SimprintsMapBiometricSearchResultUseCaseTest.kt new file mode 100644 index 00000000000..6d90b431d5d --- /dev/null +++ b/app/src/test/java/org/dhis2/simprints/SimprintsMapBiometricSearchResultUseCaseTest.kt @@ -0,0 +1,118 @@ +package org.dhis2.simprints + +import android.app.Activity.RESULT_CANCELED +import android.app.Activity.RESULT_OK +import android.content.Intent +import android.os.Bundle +import com.google.gson.Gson +import org.dhis2.commons.simprints.repository.SimprintsSessionRepository +import org.dhis2.commons.simprints.usecases.SimprintsHasAutoOpenEligibleIdentificationUseCase +import org.dhis2.mobile.commons.model.CustomIntentResponseDataModel +import org.dhis2.mobile.commons.model.CustomIntentResponseExtraType +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoInteractions +import org.mockito.kotlin.whenever + +class SimprintsMapBiometricSearchResultUseCaseTest { + private val sessionRepository: SimprintsSessionRepository = mock() + private val useCase = + SimprintsMapBiometricSearchResultUseCase( + sessionRepository = sessionRepository, + hasAutoOpenEligibleIdentification = SimprintsHasAutoOpenEligibleIdentificationUseCase(), + resultMapper = SimprintsCustomIntentResultMapper(), + ) + + @Test + fun `should return dropout before parsing response data or saving session when no identifications`() { + val data = + mock { + on { hasExtra("identification") } doReturn false + } + + val result = + useCase( + responseDataJson = "{not-json", + resultCode = RESULT_OK, + data = data, + capturesSessionId = true, + ) + + assertEquals(SimprintsMapBiometricSearchResultUseCase.Result.SearchDropout, result) + verifyNoInteractions(sessionRepository) + } + + @Test + fun `should return dropout for cancelled identification`() { + val result = + useCase( + responseDataJson = identificationResponseDataJson(), + resultCode = RESULT_CANCELED, + data = mock(), + capturesSessionId = true, + ) + + assertEquals(SimprintsMapBiometricSearchResultUseCase.Result.SearchDropout, result) + verifyNoInteractions(sessionRepository) + } + + @Test + fun `should still keep no-matches session available for enrol last`() { + val data = identificationIntent("[]") + + val result = + useCase( + responseDataJson = identificationResponseDataJson(), + resultCode = RESULT_OK, + data = data, + capturesSessionId = true, + ) + + assertEquals(SimprintsMapBiometricSearchResultUseCase.Result.NoMatches, result) + verify(sessionRepository).save("session-id") + } + + @Test + fun `should return identification value`() { + val result = + useCase( + responseDataJson = identificationResponseDataJson(), + resultCode = RESULT_OK, + data = identificationIntent("""[{"guid":"guid-1"}]"""), + capturesSessionId = true, + ) + + assertTrue(result is SimprintsMapBiometricSearchResultUseCase.Result.Identification) + result as SimprintsMapBiometricSearchResultUseCase.Result.Identification + assertEquals("guid-1", result.value) + verify(sessionRepository).save("session-id") + } + + private fun identificationIntent(identification: String): Intent { + val extras: Bundle = mock() + whenever(extras.getString("sessionId")) doReturn "session-id" + whenever(extras.keySet()) doReturn setOf("identification") + whenever(extras.get("identification")) doReturn identification + + val intent: Intent = mock() + whenever(intent.hasExtra("identification")) doReturn true + whenever(intent.getStringExtra("identification")) doReturn identification + whenever(intent.extras) doReturn extras + return intent + } + + private fun identificationResponseDataJson() = + Gson().toJson( + listOf( + CustomIntentResponseDataModel( + name = "identification", + extraType = CustomIntentResponseExtraType.LIST_OF_OBJECTS, + key = "guid", + ), + ), + ) +} diff --git a/app/src/test/java/org/dhis2/usescases/searchTrackEntity/SearchTEIViewModelTest.kt b/app/src/test/java/org/dhis2/usescases/searchTrackEntity/SearchTEIViewModelTest.kt index bcc7ee39226..45dfff1bdb3 100644 --- a/app/src/test/java/org/dhis2/usescases/searchTrackEntity/SearchTEIViewModelTest.kt +++ b/app/src/test/java/org/dhis2/usescases/searchTrackEntity/SearchTEIViewModelTest.kt @@ -38,6 +38,7 @@ import org.dhis2.maps.geometry.mapper.EventsByProgramStage import org.dhis2.maps.usecases.MapStyleConfiguration import org.dhis2.mobile.commons.model.CustomIntentModel import org.dhis2.simprints.SimprintsLoadBiometricSearchResultsUseCase +import org.dhis2.simprints.SimprintsMapBiometricSearchResultUseCase import org.dhis2.simprints.SimprintsSearchViewModel import org.dhis2.usescases.searchTrackEntity.listView.SearchResult.SearchResultType import org.dhis2.utils.customviews.navigationbar.NavigationPage @@ -90,6 +91,7 @@ class SearchTEIViewModelTest { private val filterManager: FilterManager = mock() private val simprintsSearchViewModel: SimprintsSearchViewModel = mock() private val loadSimprintsBiometricSearchResultsUseCase: SimprintsLoadBiometricSearchResultsUseCase = mock() + private val mapSimprintsBiometricSearchResult: SimprintsMapBiometricSearchResultUseCase = mock() private val simprintsBiometricSearchNavigation = MutableSharedFlow(extraBufferCapacity = 1) private val simprintsBiometricSearch = MutableLiveData(false) private val simprintsUseLastBiometricsLabel = MutableLiveData(false) @@ -134,6 +136,7 @@ class SearchTEIViewModelTest { filterManager = filterManager, simprintsSearchViewModel = simprintsSearchViewModel, loadSimprintsBiometricSearchResultsUseCase = loadSimprintsBiometricSearchResultsUseCase, + mapSimprintsBiometricSearchResult = mapSimprintsBiometricSearchResult, ) testingDispatcher.scheduler.advanceUntilIdle() } @@ -207,25 +210,98 @@ class SearchTEIViewModelTest { } @Test - fun `Should keep Search screen open after Simprints biometric no matches when list screen is refreshed`() { - viewModel.onSimprintsBiometricNoMatches() + fun `Should request Simprints biometric identification launch instead of opening search form`() = + runTest { + viewModel.setListScreen() + viewModel.searchParametersUiState = + viewModel.searchParametersUiState.copy( + items = listOf(simprintsBiometricSearchField()), + ) - viewModel.setListScreen() + viewModel.simprintsBiometricIdentificationLaunch.test { + viewModel.onSearchFormRequested() + testingDispatcher.scheduler.advanceUntilIdle() + + awaitItem() + val screenState = viewModel.screenState.value + assertTrue(screenState is SearchList) + assertFalse((screenState as SearchList).searchForm.isOpened) + verify(simprintsSearchViewModel).clearPendingSession() + cancelAndIgnoreRemainingEvents() + } + } - val screenState = viewModel.screenState.value as SearchList - assertTrue(screenState.searchForm.isOpened) - assertFalse(screenState.searchForm.isForced) + @Test + fun `Should open search form when program does not have Simprints biometric search`() { + viewModel.searchParametersUiState = + viewModel.searchParametersUiState.copy( + items = + listOf( + FieldUiModelImpl( + uid = "name", + label = "Name", + autocompleteList = emptyList(), + optionSetConfiguration = null, + valueType = ValueType.TEXT, + ), + ), + ) + + viewModel.onSearchFormRequested() + testingDispatcher.scheduler.advanceUntilIdle() + + val screenState = viewModel.screenState.value + assertTrue(screenState is SearchList) + assertTrue((screenState as SearchList).searchForm.isOpened) } @Test - fun `Should stop keeping Search screen open after follow up search`() { - viewModel.onSimprintsBiometricNoMatches() + fun `Should navigate to empty result list after Simprints biometric no matches`() = + runTest { + setCurrentProgram(testingProgram(displayFrontPageList = false, minAttributesToSearch = 2)) + setAllowCreateBeforeSearch(false) + whenever(resourceManager.getString(R.string.simprints_biometric_search)) doReturn "Biometric search" + viewModel.searchParametersUiState = + viewModel.searchParametersUiState.copy( + items = listOf(simprintsBiometricSearchField()), + ) + + val snapshot = + async { + viewModel.searchPagingData + .drop(1) + .take(1) + .asSnapshot() + } + viewModel.onSimprintsBiometricNoMatches("biometric") + testingDispatcher.scheduler.advanceUntilIdle() + + val screenState = viewModel.screenState.value as SearchList + assertEquals(SearchScreenState.LIST, screenState.screenState) + assertFalse(screenState.searchForm.isOpened) + assertFalse(screenState.searchForm.isForced) + assertEquals(mapOf("biometric" to "Biometric search"), viewModel.searchParametersUiState.searchedItems) + assertTrue(snapshot.await().isEmpty()) + verify(repository).clearFetchedList() + } + + @Test + fun `Should show no results state after Simprints biometric no matches`() { + whenever(resourceManager.getString(R.string.simprints_biometric_search)) doReturn "Biometric search" + viewModel.searchParametersUiState = + viewModel.searchParametersUiState.copy( + items = listOf(simprintsBiometricSearchField()), + ) - viewModel.onSearch() + viewModel.onSimprintsBiometricNoMatches("biometric") testingDispatcher.scheduler.advanceUntilIdle() + viewModel.onDataLoaded(0, null) - val screenState = viewModel.screenState.value as SearchList - assertFalse(screenState.searchForm.isOpened) + viewModel.dataResult.value?.apply { + assertTrue(isNotEmpty()) + assertTrue(size == 1) + assertEquals(SearchResultType.NO_RESULTS, first().type) + } } @Test @@ -815,6 +891,7 @@ class SearchTEIViewModelTest { filterManager = filterManager, simprintsSearchViewModel = simprintsSearchViewModel, loadSimprintsBiometricSearchResultsUseCase = loadSimprintsBiometricSearchResultsUseCase, + mapSimprintsBiometricSearchResult = mapSimprintsBiometricSearchResult, ) viewModel.fetchSearchParameters(initialProgram, "teiTypeUid") @@ -1437,6 +1514,7 @@ class SearchTEIViewModelTest { filterManager = filterManager, simprintsSearchViewModel = simprintsSearchViewModel, loadSimprintsBiometricSearchResultsUseCase = loadSimprintsBiometricSearchResultsUseCase, + mapSimprintsBiometricSearchResult = mapSimprintsBiometricSearchResult, ) testingDispatcher.scheduler.advanceUntilIdle() @@ -1485,6 +1563,7 @@ class SearchTEIViewModelTest { filterManager = filterManager, simprintsSearchViewModel = simprintsSearchViewModel, loadSimprintsBiometricSearchResultsUseCase = loadSimprintsBiometricSearchResultsUseCase, + mapSimprintsBiometricSearchResult = mapSimprintsBiometricSearchResult, ) testingDispatcher.scheduler.advanceUntilIdle() diff --git a/commons/src/main/java/org/dhis2/commons/simprints/utils/SimprintsIntentUtils.kt b/commons/src/main/java/org/dhis2/commons/simprints/utils/SimprintsIntentUtils.kt index 973d500ba48..8957ba722f2 100644 --- a/commons/src/main/java/org/dhis2/commons/simprints/utils/SimprintsIntentUtils.kt +++ b/commons/src/main/java/org/dhis2/commons/simprints/utils/SimprintsIntentUtils.kt @@ -14,6 +14,7 @@ object SimprintsIntentUtils { private const val SIMPRINTS_REGISTER_LAST_ACTION = "$SIMPRINTS_PACKAGE_NAME.REGISTER_LAST_BIOMETRICS" private const val SIMPRINTS_SESSION_ID_KEY = "sessionId" private const val SIMPRINTS_SELECTED_GUID_KEY = "selectedGuid" + private const val SIMPRINTS_IDENTIFICATION_RESULT_KEY = "identification" data class PreparedCallout( val launchIntent: Intent, @@ -28,6 +29,8 @@ object SimprintsIntentUtils { fun extractSessionId(extras: Bundle?): String? = extras?.getString(SIMPRINTS_SESSION_ID_KEY) + fun hasIdentificationResult(intent: Intent?): Boolean = intent?.hasExtra(SIMPRINTS_IDENTIFICATION_RESULT_KEY) == true + fun prepareCallout(customIntent: CustomIntentModel): PreparedCallout = prepareCallout(customIntent, customIntent.packageName) fun prepareRegisterLastCallout( diff --git a/commons/src/test/kotlin/org/dhis2/commons/simprints/utils/SimprintsIntentUtilsTest.kt b/commons/src/test/kotlin/org/dhis2/commons/simprints/utils/SimprintsIntentUtilsTest.kt index 8dbb65c0a47..16ab20b374b 100644 --- a/commons/src/test/kotlin/org/dhis2/commons/simprints/utils/SimprintsIntentUtilsTest.kt +++ b/commons/src/test/kotlin/org/dhis2/commons/simprints/utils/SimprintsIntentUtilsTest.kt @@ -1,5 +1,6 @@ package org.dhis2.commons.simprints.utils +import android.content.Intent import android.os.Bundle import org.dhis2.mobile.commons.model.CustomIntentModel import org.dhis2.mobile.commons.model.CustomIntentResponseDataModel @@ -84,10 +85,27 @@ class SimprintsIntentUtilsTest { assertEquals("session-id", sessionId) } + @Test + fun `hasIdentificationResult should detect biometric identification result extra`() { + assertTrue(hasIdentificationResultExtra(hasExtra = true)) + } + + @Test + fun `hasIdentificationResult should return false when biometric result extras are missing`() { + assertFalse(hasIdentificationResultExtra(hasExtra = false)) + } + private fun identifyIntent() = customIntent(packageName = "com.simprints.id.IDENTIFY") private fun registerIntent() = customIntent(packageName = "com.simprints.id.REGISTER") + private fun hasIdentificationResultExtra(hasExtra: Boolean) = + SimprintsIntentUtils.hasIdentificationResult( + mock { + on { hasExtra("identification") } doReturn hasExtra + } + ) + private fun customIntent(packageName: String) = CustomIntentModel( uid = packageName, From dd17d35c08ef051393c4a632a6971a375b73f603 Mon Sep 17 00:00:00 2001 From: Alex Date: Mon, 1 Jun 2026 23:53:24 +0100 Subject: [PATCH 02/12] Simprints RAMP-1 history charts under the fields for which they are enabled in the datastore management config --- .../data/service/SyncDataWorkerModule.kt | 2 + .../data/service/SyncGranularRxModule.kt | 2 + .../data/service/SyncInitWorkerModule.kt | 2 + .../data/service/SyncMetadataWorkerModule.kt | 2 + .../dhis2/data/service/SyncPresenterImpl.kt | 4 + .../dhis2/data/services/SyncPresenterTest.kt | 35 +++ .../ramp/model/RampDatastoreConfig.kt | 29 +++ .../repository/RampDatastoreRepository.kt | 120 +++++++++ .../repository/RampDatastoreRepositoryTest.kt | 148 +++++++++++ form/build.gradle.kts | 1 + .../org/dhis2/form/data/EventRepository.kt | 22 ++ .../org/dhis2/form/data/FormRepository.kt | 2 +- .../org/dhis2/form/data/FormRepositoryImpl.kt | 37 +-- .../dhis2/form/data/RulesUtilsProviderImpl.kt | 2 +- .../main/java/org/dhis2/form/di/Injector.kt | 11 + .../java/org/dhis2/form/model/FieldUiModel.kt | 7 + .../org/dhis2/form/model/FieldUiModelImpl.kt | 12 +- .../dhis2/form/model/SectionUiModelImpl.kt | 7 + .../ramp/data/FormHistoryChartRepository.kt | 136 +++++++++++ .../ramp/data/GetFormHistoryChartUseCase.kt | 16 ++ .../simprints/ramp/model/FormHistoryChart.kt | 18 ++ .../simprints/ramp/ui/FormHistoryChartView.kt | 228 +++++++++++++++++ .../java/org/dhis2/form/ui/FormViewModel.kt | 15 +- .../ui/provider/inputfield/FieldProvider.kt | 101 ++++---- .../dhis2/form/data/EventRepositoryTest.kt | 3 + .../form/data/RulesUtilsProviderImplTest.kt | 41 ++++ .../data/FormHistoryChartRepositoryTest.kt | 231 ++++++++++++++++++ .../data/GetFormHistoryChartUseCaseTest.kt | 53 ++++ .../org/dhis2/form/ui/FormViewModelTest.kt | 41 ++++ 29 files changed, 1260 insertions(+), 68 deletions(-) create mode 100644 commons/src/main/java/org/dhis2/commons/simprints/ramp/model/RampDatastoreConfig.kt create mode 100644 commons/src/main/java/org/dhis2/commons/simprints/ramp/repository/RampDatastoreRepository.kt create mode 100644 commons/src/test/kotlin/org/dhis2/commons/simprints/ramp/repository/RampDatastoreRepositoryTest.kt create mode 100644 form/src/main/java/org/dhis2/form/simprints/ramp/data/FormHistoryChartRepository.kt create mode 100644 form/src/main/java/org/dhis2/form/simprints/ramp/data/GetFormHistoryChartUseCase.kt create mode 100644 form/src/main/java/org/dhis2/form/simprints/ramp/model/FormHistoryChart.kt create mode 100644 form/src/main/java/org/dhis2/form/simprints/ramp/ui/FormHistoryChartView.kt create mode 100644 form/src/test/java/org/dhis2/form/simprints/ramp/data/FormHistoryChartRepositoryTest.kt create mode 100644 form/src/test/java/org/dhis2/form/simprints/ramp/data/GetFormHistoryChartUseCaseTest.kt diff --git a/app/src/main/java/org/dhis2/data/service/SyncDataWorkerModule.kt b/app/src/main/java/org/dhis2/data/service/SyncDataWorkerModule.kt index 66758840961..c299f0c4856 100644 --- a/app/src/main/java/org/dhis2/data/service/SyncDataWorkerModule.kt +++ b/app/src/main/java/org/dhis2/data/service/SyncDataWorkerModule.kt @@ -4,6 +4,7 @@ import dagger.Module import dagger.Provides import org.dhis2.commons.di.dagger.PerService import org.dhis2.commons.prefs.PreferenceProvider +import org.dhis2.commons.simprints.ramp.repository.RampDatastoreRepository as SimprintsRampDatastoreRepository import org.dhis2.data.service.workManager.WorkManagerController import org.dhis2.utils.analytics.AnalyticsHelper import org.hisp.dhis.android.core.D2 @@ -31,5 +32,6 @@ class SyncDataWorkerModule { analyticsHelper, syncStatusController, syncRepository, + SimprintsRampDatastoreRepository(d2), ) } diff --git a/app/src/main/java/org/dhis2/data/service/SyncGranularRxModule.kt b/app/src/main/java/org/dhis2/data/service/SyncGranularRxModule.kt index 64dc7834207..e42f3f9bc97 100644 --- a/app/src/main/java/org/dhis2/data/service/SyncGranularRxModule.kt +++ b/app/src/main/java/org/dhis2/data/service/SyncGranularRxModule.kt @@ -4,6 +4,7 @@ import dagger.Module import dagger.Provides import org.dhis2.commons.di.dagger.PerService import org.dhis2.commons.prefs.PreferenceProvider +import org.dhis2.commons.simprints.ramp.repository.RampDatastoreRepository as SimprintsRampDatastoreRepository import org.dhis2.data.service.workManager.WorkManagerController import org.dhis2.utils.analytics.AnalyticsHelper import org.hisp.dhis.android.core.D2 @@ -31,5 +32,6 @@ class SyncGranularRxModule { analyticsHelper, syncStatusController, syncRepository, + SimprintsRampDatastoreRepository(d2), ) } diff --git a/app/src/main/java/org/dhis2/data/service/SyncInitWorkerModule.kt b/app/src/main/java/org/dhis2/data/service/SyncInitWorkerModule.kt index 68fbd402275..b14ea061051 100644 --- a/app/src/main/java/org/dhis2/data/service/SyncInitWorkerModule.kt +++ b/app/src/main/java/org/dhis2/data/service/SyncInitWorkerModule.kt @@ -4,6 +4,7 @@ import dagger.Module import dagger.Provides import org.dhis2.commons.di.dagger.PerService import org.dhis2.commons.prefs.PreferenceProvider +import org.dhis2.commons.simprints.ramp.repository.RampDatastoreRepository as SimprintsRampDatastoreRepository import org.dhis2.data.service.workManager.WorkManagerController import org.dhis2.utils.analytics.AnalyticsHelper import org.hisp.dhis.android.core.D2 @@ -31,5 +32,6 @@ class SyncInitWorkerModule { analyticsHelper, syncStatusController, syncRepository, + SimprintsRampDatastoreRepository(d2), ) } diff --git a/app/src/main/java/org/dhis2/data/service/SyncMetadataWorkerModule.kt b/app/src/main/java/org/dhis2/data/service/SyncMetadataWorkerModule.kt index 33fcbff9d17..fcd02f30b29 100644 --- a/app/src/main/java/org/dhis2/data/service/SyncMetadataWorkerModule.kt +++ b/app/src/main/java/org/dhis2/data/service/SyncMetadataWorkerModule.kt @@ -4,6 +4,7 @@ import dagger.Module import dagger.Provides import org.dhis2.commons.di.dagger.PerService import org.dhis2.commons.prefs.PreferenceProvider +import org.dhis2.commons.simprints.ramp.repository.RampDatastoreRepository as SimprintsRampDatastoreRepository import org.dhis2.data.service.workManager.WorkManagerController import org.dhis2.utils.analytics.AnalyticsHelper import org.hisp.dhis.android.core.D2 @@ -31,5 +32,6 @@ class SyncMetadataWorkerModule { analyticsHelper, syncStatusController, syncRepository, + SimprintsRampDatastoreRepository(d2), ) } diff --git a/app/src/main/java/org/dhis2/data/service/SyncPresenterImpl.kt b/app/src/main/java/org/dhis2/data/service/SyncPresenterImpl.kt index 5f235e6521e..32f726cc145 100644 --- a/app/src/main/java/org/dhis2/data/service/SyncPresenterImpl.kt +++ b/app/src/main/java/org/dhis2/data/service/SyncPresenterImpl.kt @@ -22,6 +22,7 @@ import org.dhis2.commons.prefs.Preference.Companion.TIME_DAILY import org.dhis2.commons.prefs.Preference.Companion.TIME_DATA import org.dhis2.commons.prefs.Preference.Companion.TIME_META import org.dhis2.commons.prefs.PreferenceProvider +import org.dhis2.commons.simprints.ramp.repository.RampDatastoreRepository as SimprintsRampDatastoreRepository import org.dhis2.data.service.workManager.WorkManagerController import org.dhis2.data.service.workManager.WorkerItem import org.dhis2.data.service.workManager.WorkerType @@ -49,6 +50,7 @@ class SyncPresenterImpl( private val analyticsHelper: AnalyticsHelper, private val syncStatusController: SyncStatusController, private val syncRepository: SyncRepository, + private val simprintsRampDatastoreRepository: SimprintsRampDatastoreRepository, ) : SyncPresenter { override fun initSyncControllerMap() { Completable @@ -266,6 +268,8 @@ class SyncPresenterImpl( .eq(FileResourceDomainType.ICON) .download(), ), + ).andThen( + Completable.fromAction { simprintsRampDatastoreRepository.sync() }, ).blockingAwait() } diff --git a/app/src/test/java/org/dhis2/data/services/SyncPresenterTest.kt b/app/src/test/java/org/dhis2/data/services/SyncPresenterTest.kt index f0b474d582a..f32d2ea6e45 100644 --- a/app/src/test/java/org/dhis2/data/services/SyncPresenterTest.kt +++ b/app/src/test/java/org/dhis2/data/services/SyncPresenterTest.kt @@ -1,9 +1,13 @@ package org.dhis2.data.services import io.reactivex.Completable +import io.reactivex.Completable.complete import io.reactivex.Observable +import io.reactivex.Observable.fromArray +import io.reactivex.Observable.just import org.dhis2.commons.bindings.program import org.dhis2.commons.prefs.PreferenceProvider +import org.dhis2.commons.simprints.ramp.repository.RampDatastoreRepository as SimprintsRampDatastoreRepository import org.dhis2.data.service.SyncPresenterImpl import org.dhis2.data.service.SyncRepository import org.dhis2.data.service.SyncResult @@ -21,6 +25,7 @@ import org.hisp.dhis.android.core.fileresource.FileResourceDomainType import org.hisp.dhis.android.core.program.Program import org.hisp.dhis.android.core.program.ProgramType import org.hisp.dhis.android.core.settings.GeneralSettings +import org.hisp.dhis.android.core.settings.GeneralSettings.builder import org.hisp.dhis.android.core.settings.LimitScope import org.hisp.dhis.android.core.settings.ProgramSetting import org.hisp.dhis.android.core.settings.ProgramSettings @@ -47,6 +52,7 @@ class SyncPresenterTest { private val analyticsHelper: AnalyticsHelper = mock() private val syncStatusController: SyncStatusController = mock() private val syncRepository: SyncRepository = mock() + private val simprintsRampDatastoreRepository: SimprintsRampDatastoreRepository = mock() @Before fun setUp() { @@ -58,6 +64,7 @@ class SyncPresenterTest { analyticsHelper, syncStatusController, syncRepository, + simprintsRampDatastoreRepository, ) } @@ -249,6 +256,34 @@ class SyncPresenterTest { verify(analyticsHelper).clearMatomoSecondaryTracker() } + @Test + fun `Should sync simprints ramp datastore when syncing metadata`() { + whenever( + d2.metadataModule().download(), + ) doReturn fromArray(BaseD2Progress.empty(2)) + whenever( + d2.settingModule().generalSetting().blockingGet(), + ) doReturn + builder() + .encryptDB(false) + .build() + whenever( + d2.mapsModule().mapLayersDownloader().downloadMetadata(), + ) doReturn complete() + whenever( + d2 + .fileResourceModule() + .fileResourceDownloader() + .byDomainType() + .eq(FileResourceDomainType.ICON) + .download(), + ) doReturn just(BaseD2Progress.empty(1)) + + presenter.syncMetadata { } + + verify(simprintsRampDatastoreRepository).sync() + } + @Test fun `Should return successfully SYNC if tei enrollment and events are ok`() { whenever( 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 new file mode 100644 index 00000000000..a53a3dbfd96 --- /dev/null +++ b/commons/src/main/java/org/dhis2/commons/simprints/ramp/model/RampDatastoreConfig.kt @@ -0,0 +1,29 @@ +package org.dhis2.commons.simprints.ramp.model + +import com.google.gson.annotations.SerializedName + +data class RampDatastoreConfig( + val dataElementHistoryCharts: List = emptyList(), +) { + fun isNotEmpty(): Boolean = dataElementHistoryCharts.isNotEmpty() +} + +data class DataElementHistoryChartConfig( + @SerializedName("programId") + val programId: String? = null, + @SerializedName("followUpVisitProgramStageId") + val followUpVisitProgramStageId: String? = null, + @SerializedName("dataElementId") + val dataElementId: String? = null, + @SerializedName("xAxisVisitNumberDataElementId") + val xAxisVisitNumberDataElementId: String? = null, + @SerializedName("followUpVisitMaxNumber") + val followUpVisitMaxNumber: Int? = null, +) { + fun isValid(): Boolean = + !programId.isNullOrBlank() && + !followUpVisitProgramStageId.isNullOrBlank() && + !dataElementId.isNullOrBlank() && + !xAxisVisitNumberDataElementId.isNullOrBlank() && + (followUpVisitMaxNumber ?: -1) >= 0 +} 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 new file mode 100644 index 00000000000..e7a3c687f1d --- /dev/null +++ b/commons/src/main/java/org/dhis2/commons/simprints/ramp/repository/RampDatastoreRepository.kt @@ -0,0 +1,120 @@ +package org.dhis2.commons.simprints.ramp.repository + +import com.google.gson.Gson +import com.google.gson.JsonElement +import com.google.gson.JsonParser +import org.dhis2.commons.simprints.ramp.model.DataElementHistoryChartConfig +import org.dhis2.commons.simprints.ramp.model.RampDatastoreConfig +import org.hisp.dhis.android.core.D2 + +class RampDatastoreRepository( + private val d2: D2, + private val gson: Gson = Gson(), +) { + private var cachedConfig: CachedConfig? = null + + fun getConfig(): RampDatastoreConfig { + val localValue = getLocalRampDatastoreValue() + val cached = cachedConfig + + if (cached != null && cached.rawValue == localValue) { + return cached.config + } + + return (localValue?.let(::parseRawValue) ?: RampDatastoreConfig()) + .also { cachedConfig = CachedConfig(localValue, it) } + } + + fun sync() { + d2 + .dataStoreModule() + .dataStoreDownloader() + .byNamespace() + .eq(RAMP_DATASTORE_NAMESPACE) + .blockingDownload() + } + + private fun getLocalRampDatastoreValue(): String? = + runCatching { + d2 + .dataStoreModule() + .dataStore() + .value(RAMP_DATASTORE_NAMESPACE, RAMP_DATASTORE_KEY) + .blockingGet() + ?.value() + }.getOrNull() + + private fun parseRawValue(value: String): RampDatastoreConfig = + runCatching { + val root = + parseDatastoreJsonElement(value)?.asJsonObject + ?: return@runCatching RampDatastoreConfig() + RampDatastoreConfig( + dataElementHistoryCharts = + root + .get(DATA_ELEMENT_HISTORY_CHARTS_KEY) + ?.parseList() + .orEmpty() + .filter { it.isValid() }, + ) + }.getOrDefault(RampDatastoreConfig()) + + private fun JsonElement.parseDatastoreJsonElement(): JsonElement? = + runCatching { + if (isJsonPrimitive && asJsonPrimitive.isString) { + parseDatastoreJsonElement(asString) ?: this + } else { + this + } + }.getOrNull() + + private fun parseDatastoreJsonElement(value: String): JsonElement? = parseJsonElement(value) ?: parseJsonWrapperElement(value) + + private fun parseJsonElement(value: String): JsonElement? = + runCatching { + JsonParser.parseString(value).parseDatastoreJsonElement() + }.getOrNull() + + private fun parseJsonWrapperElement(value: String): JsonElement? = + value + .jsonWrapperPayload() + ?.let(::parseJsonElement) + + private fun String.jsonWrapperPayload(): String? { + val wrappedValue = trim() + val argumentsStart = wrappedValue.indexOf('(') + val argumentsEnd = wrappedValue.lastIndexOf(')') + if (argumentsStart <= 0 || argumentsEnd != wrappedValue.lastIndex) return null + + val assignment = wrappedValue.substring(argumentsStart + 1, argumentsEnd).split("=", limit = 2) + return assignment + .takeIf { + wrappedValue.take(argumentsStart) == DATASTORE_JSON_WRAPPER_NAME && + it.size == 2 && + it[0].trim() == DATASTORE_JSON_WRAPPER_JSON_FIELD + }?.get(1) + ?.trim() + } + + private inline fun JsonElement.parseList(): List = + when { + isJsonArray -> asJsonArray.mapNotNull { it.parseObject() } + isJsonObject -> listOfNotNull(parseObject()) + else -> emptyList() + } + + private inline fun JsonElement.parseObject(): T? = runCatching { gson.fromJson(this, T::class.java) }.getOrNull() + + private data class CachedConfig( + val rawValue: String?, + val config: RampDatastoreConfig, + ) + + private companion object { + private const val RAMP_DATASTORE_NAMESPACE = "simprints" + private const val RAMP_DATASTORE_KEY = "ramp" + private const val DATA_ELEMENT_HISTORY_CHARTS_KEY = "dataElementHistoryCharts" + private const val DATASTORE_JSON_WRAPPER_NAME = "JsonWrapper" + private const val DATASTORE_JSON_WRAPPER_JSON_FIELD = "json" + } +} 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 new file mode 100644 index 00000000000..88c4d380b3c --- /dev/null +++ b/commons/src/test/kotlin/org/dhis2/commons/simprints/ramp/repository/RampDatastoreRepositoryTest.kt @@ -0,0 +1,148 @@ +package org.dhis2.commons.simprints.ramp.repository + +import com.google.gson.Gson +import org.hisp.dhis.android.core.D2 +import org.hisp.dhis.android.core.datastore.DataStoreEntry +import org.junit.Assert.assertEquals +import org.junit.Test +import org.mockito.Mockito +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.doReturnConsecutively +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +class RampDatastoreRepositoryTest { + private val d2: D2 = Mockito.mock(D2::class.java, Mockito.RETURNS_DEEP_STUBS) + private val repository = RampDatastoreRepository(d2) + + @Test + fun `getConfig should parse history chart config and ignore invalid entries`() { + stubRampConfigRawValue( + """ + { + "dataElementHistoryCharts": [ + { + "programId": "program", + "followUpVisitProgramStageId": "follow-stage", + "dataElementId": "weight", + "xAxisVisitNumberDataElementId": "visit-number", + "followUpVisitMaxNumber": 12 + }, + { + "programId": "program", + "followUpVisitProgramStageId": "follow-stage", + "xAxisVisitNumberDataElementId": "visit-number", + "followUpVisitMaxNumber": 12 + } + ], + "otherConfigs": [ + { "ignored": true } + ] + } + """.trimIndent(), + ) + + val config = repository.getConfig() + + assertEquals(1, config.dataElementHistoryCharts.size) + config.dataElementHistoryCharts.first().let { chart -> + assertEquals("program", chart.programId) + assertEquals("follow-stage", chart.followUpVisitProgramStageId) + assertEquals("weight", chart.dataElementId) + assertEquals("visit-number", chart.xAxisVisitNumberDataElementId) + assertEquals(12, chart.followUpVisitMaxNumber) + } + } + + @Test + fun `getConfig should parse wrapped string datastore value`() { + val rawJson = + """ + { + "dataElementHistoryCharts": { + "programId": "program", + "followUpVisitProgramStageId": "follow-stage", + "dataElementId": "muac", + "xAxisVisitNumberDataElementId": "visit-number", + "followUpVisitMaxNumber": 5 + } + } + """.trimIndent() + stubRampConfigRawValue("JsonWrapper(json=${Gson().toJson(rawJson)})") + + val config = repository.getConfig() + + assertEquals("muac", config.dataElementHistoryCharts.single().dataElementId) + } + + @Test + fun `getConfig should parse wrapped object datastore value`() { + stubRampConfigRawValue("JsonWrapper(json=${getRampRawUnwrappedValue(dataElementId = "height")})") + + val config = repository.getConfig() + + assertEquals("height", config.dataElementHistoryCharts.single().dataElementId) + } + + @Test + fun `getConfig should reload config when local datastore value changes`() { + whenever( + d2 + .dataStoreModule() + .dataStore() + .value("simprints", "ramp") + .blockingGet(), + ) doReturnConsecutively + listOf( + rampEntry(getRampRawUnwrappedValue(dataElementId = "value1")), + rampEntry(getRampRawUnwrappedValue(dataElementId = "value2")), + ) + + assertEquals("value1", repository.getConfig().dataElementHistoryCharts.single().dataElementId) + assertEquals("value2", repository.getConfig().dataElementHistoryCharts.single().dataElementId) + } + + @Test + fun `sync should download simprints namespace`() { + repository.sync() + + verify( + d2 + .dataStoreModule() + .dataStoreDownloader() + .byNamespace() + .eq("simprints"), + ).blockingDownload() + } + + private fun stubRampConfigRawValue(value: String) { + whenever( + d2 + .dataStoreModule() + .dataStore() + .value("simprints", "ramp") + .blockingGet(), + ) doReturn rampEntry(value) + } + + private fun rampEntry(value: String): DataStoreEntry = + DataStoreEntry + .builder() + .namespace("simprints") + .key("ramp") + .value(value) + .build() + + private fun getRampRawUnwrappedValue(dataElementId: String) = + """ + { + "dataElementHistoryCharts": { + "programId": "program", + "followUpVisitProgramStageId": "follow-stage", + "dataElementId": "$dataElementId", + "xAxisVisitNumberDataElementId": "visit-number", + "followUpVisitMaxNumber": 5 + } + } + """.trimIndent() +} diff --git a/form/build.gradle.kts b/form/build.gradle.kts index 21683e3fb79..6e7ba7aabee 100644 --- a/form/build.gradle.kts +++ b/form/build.gradle.kts @@ -63,6 +63,7 @@ dependencies { implementation(project(":commons")) implementation(project(":commonskmm")) + implementation(project(":dhis_android_analytics")) implementation(project(":dhis2_android_maps")) implementation(project(":dhis2-mobile-program-rules")) implementation(libs.androidx.activity.compose) diff --git a/form/src/main/java/org/dhis2/form/data/EventRepository.kt b/form/src/main/java/org/dhis2/form/data/EventRepository.kt index 3b61ebeab03..3640303b860 100644 --- a/form/src/main/java/org/dhis2/form/data/EventRepository.kt +++ b/form/src/main/java/org/dhis2/form/data/EventRepository.kt @@ -27,6 +27,7 @@ import org.dhis2.form.model.EventMode import org.dhis2.form.model.FieldUiModel import org.dhis2.form.model.OptionSetConfiguration import org.dhis2.form.model.PeriodSelector +import org.dhis2.form.simprints.ramp.data.GetFormHistoryChartUseCase as GetSimprintsRampFormHistoryChartUseCase import org.dhis2.form.ui.FieldViewModelFactory import org.dhis2.mobile.commons.customintents.CustomIntentRepository import org.dhis2.mobile.commons.extensions.toColor @@ -61,6 +62,7 @@ class EventRepository( private val eventResourcesProvider: EventResourcesProvider, private val eventMode: EventMode, private val customIntentRepository: CustomIntentRepository, + private val getSimprintsRampFormHistoryChart: GetSimprintsRampFormHistoryChartUseCase, dispatcherProvider: DispatcherProvider, ) : DataEntryBaseRepository( FormBaseConfiguration(d2, dispatcherProvider), @@ -574,6 +576,22 @@ class EventRepository( } ?: emptyMap() } + override fun updateField( + fieldUiModel: FieldUiModel, + warningMessage: String?, + optionsToHide: List, + optionGroupsToHide: List, + optionGroupsToShow: List, + ): FieldUiModel = + super + .updateField( + fieldUiModel, + warningMessage, + optionsToHide, + optionGroupsToHide, + optionGroupsToShow, + ).withSimprintsRampHistoryChart() + private fun getFieldsForSingleSection(): Single> = Single.fromCallable { val stageDataElements = @@ -752,8 +770,12 @@ class EventRepository( } return fieldViewModel + .withSimprintsRampHistoryChart() } + private fun FieldUiModel.withSimprintsRampHistoryChart(): FieldUiModel = + setSimprintsRampHistoryChart(getSimprintsRampFormHistoryChart(this)) + private fun getConflictErrorsAndWarnings( dataElementUid: String, dataValue: String?, diff --git a/form/src/main/java/org/dhis2/form/data/FormRepository.kt b/form/src/main/java/org/dhis2/form/data/FormRepository.kt index dc06bc1a2f8..02965016481 100644 --- a/form/src/main/java/org/dhis2/form/data/FormRepository.kt +++ b/form/src/main/java/org/dhis2/form/data/FormRepository.kt @@ -38,7 +38,7 @@ interface FormRepository { uid: String, value: String?, valueType: ValueType?, - ) + ): FieldUiModel? fun currentFocusedItem(): FieldUiModel? 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 fb2a70f310d..ebb1be71115 100644 --- a/form/src/main/java/org/dhis2/form/data/FormRepositoryImpl.kt +++ b/form/src/main/java/org/dhis2/form/data/FormRepositoryImpl.kt @@ -684,36 +684,41 @@ class FormRepositoryImpl( uid: String, value: String?, valueType: ValueType?, - ) { + ): FieldUiModel? { val updatedEnrollmentDataList = dataEntryRepository.getSpecificDataEntryItems(uid) if (updatedEnrollmentDataList.isNotEmpty()) updateEnrollmentDate(updatedEnrollmentDataList) + var updatedItem: FieldUiModel? = null itemList.let { list -> list .find { item -> item.uid == uid }?.let { item -> + val itemWithNewValue = + item + .setValue(value) + .setDisplayName( + displayNameProvider.provideDisplayName( + valueType, + value, + item.optionSet, + item.periodSelector?.type, + ), + ).setLegend( + legendValueProvider.provideLegendValue( + item.uid, + value, + ), + ) + updatedItem = itemWithNewValue itemList = list.updated( list.indexOf(item), - item - .setValue(value) - .setDisplayName( - displayNameProvider.provideDisplayName( - valueType, - value, - item.optionSet, - item.periodSelector?.type, - ), - ).setLegend( - legendValueProvider.provideLegendValue( - item.uid, - value, - ), - ), + itemWithNewValue, ) } } + return updatedItem } private fun updateEnrollmentDate(fieldUiModelList: List) { diff --git a/form/src/main/java/org/dhis2/form/data/RulesUtilsProviderImpl.kt b/form/src/main/java/org/dhis2/form/data/RulesUtilsProviderImpl.kt index 3710d5b6c55..ef9f7a06b87 100644 --- a/form/src/main/java/org/dhis2/form/data/RulesUtilsProviderImpl.kt +++ b/form/src/main/java/org/dhis2/form/data/RulesUtilsProviderImpl.kt @@ -380,7 +380,7 @@ class RulesUtilsProviderImpl( fieldViewModels[fieldUid] = it } } - } ?: { + } ?: run { if (!hiddenFields.contains(assign.field())) { valuesToChange[fieldUid] = ruleEffect.data?.formatData() } diff --git a/form/src/main/java/org/dhis2/form/di/Injector.kt b/form/src/main/java/org/dhis2/form/di/Injector.kt index 2076d7ee216..440fab6314c 100644 --- a/form/src/main/java/org/dhis2/form/di/Injector.kt +++ b/form/src/main/java/org/dhis2/form/di/Injector.kt @@ -10,6 +10,7 @@ import org.dhis2.commons.resources.DhisPeriodUtils import org.dhis2.commons.resources.EventResourcesProvider import org.dhis2.commons.resources.MetadataIconProvider import org.dhis2.commons.resources.ResourceManager +import org.dhis2.commons.simprints.ramp.repository.RampDatastoreRepository as SimprintsRampDatastoreRepository import org.dhis2.commons.simprints.repository.SimprintsSessionRepository import org.dhis2.commons.viewmodel.DispatcherProvider import org.dhis2.form.data.DataEntryRepository @@ -29,6 +30,8 @@ import org.dhis2.form.model.EnrollmentRecords import org.dhis2.form.model.EventRecords import org.dhis2.form.model.FormRepositoryRecords import org.dhis2.form.model.coroutine.FormDispatcher +import org.dhis2.form.simprints.ramp.data.FormHistoryChartRepository as SimprintsRampFormHistoryChartRepository +import org.dhis2.form.simprints.ramp.data.GetFormHistoryChartUseCase as GetSimprintsRampFormHistoryChartUseCase import org.dhis2.form.ui.FieldViewModelFactory import org.dhis2.form.ui.FieldViewModelFactoryImpl import org.dhis2.form.ui.FormViewModelFactory @@ -180,6 +183,14 @@ object Injector { eventMode = eventRecords.eventMode, dispatcherProvider = provideDispatchers(), customIntentRepository = provideCustomIntentProvider(), + getSimprintsRampFormHistoryChart = + GetSimprintsRampFormHistoryChartUseCase( + SimprintsRampDatastoreRepository(provideD2()), + SimprintsRampFormHistoryChartRepository( + eventUid = eventRecords.eventUid, + d2 = provideD2(), + ), + ), ) private fun provideEnrollmentFormLabelsProvider(context: Context) = EnrollmentFormLabelsProvider(provideResourcesManager(context)) diff --git a/form/src/main/java/org/dhis2/form/model/FieldUiModel.kt b/form/src/main/java/org/dhis2/form/model/FieldUiModel.kt index b0d88f6ce2c..f0781506e6b 100644 --- a/form/src/main/java/org/dhis2/form/model/FieldUiModel.kt +++ b/form/src/main/java/org/dhis2/form/model/FieldUiModel.kt @@ -1,5 +1,6 @@ package org.dhis2.form.model +import org.dhis2.form.simprints.ramp.model.FormHistoryChart as SimprintsRampFormHistoryChart import org.dhis2.form.ui.event.RecyclerViewUiEvents import org.dhis2.form.ui.event.UiEventFactory import org.dhis2.form.ui.intent.FormIntent @@ -71,6 +72,8 @@ interface FieldUiModel { val periodSelector: PeriodSelector? + val simprintsRampHistoryChart: SimprintsRampFormHistoryChart? + fun setCallback(callback: Callback) fun equals(item: FieldUiModel): Boolean @@ -109,6 +112,10 @@ interface FieldUiModel { fun setOptionSetConfiguration(optionSetConfiguration: OptionSetConfiguration): FieldUiModel + fun setSimprintsRampHistoryChart( + simprintsRampHistoryChart: SimprintsRampFormHistoryChart?, + ): FieldUiModel + interface Callback { fun intent(intent: FormIntent) diff --git a/form/src/main/java/org/dhis2/form/model/FieldUiModelImpl.kt b/form/src/main/java/org/dhis2/form/model/FieldUiModelImpl.kt index 53886e98c6a..0e5e1b8ba7c 100644 --- a/form/src/main/java/org/dhis2/form/model/FieldUiModelImpl.kt +++ b/form/src/main/java/org/dhis2/form/model/FieldUiModelImpl.kt @@ -1,5 +1,6 @@ package org.dhis2.form.model +import org.dhis2.form.simprints.ramp.model.FormHistoryChart import org.dhis2.form.ui.event.UiEventFactory import org.dhis2.form.ui.intent.FormIntent import org.dhis2.mobile.commons.model.CustomIntentModel @@ -36,6 +37,7 @@ data class FieldUiModelImpl( override val eventCategories: List? = null, override val periodSelector: PeriodSelector? = null, override var customIntent: CustomIntentModel? = null, + override val simprintsRampHistoryChart: FormHistoryChart? = null, ) : FieldUiModel { private var callback: FieldUiModel.Callback? = null @@ -80,7 +82,11 @@ data class FieldUiModelImpl( override val isNegativeChecked: Boolean get() = value?.toBoolean() == false - override fun setValue(value: String?) = this.copy(value = value) + override fun setValue(value: String?) = + this.copy( + value = value, + simprintsRampHistoryChart = simprintsRampHistoryChart?.withCurrentValue(value), + ) override fun setSelectableDates(selectableDates: SelectableDates?) = this.copy(selectableDates = selectableDates) @@ -107,6 +113,9 @@ data class FieldUiModelImpl( override fun setOptionSetConfiguration(optionSetConfiguration: OptionSetConfiguration) = this.copy(optionSetConfiguration = optionSetConfiguration) + override fun setSimprintsRampHistoryChart(simprintsRampHistoryChart: FormHistoryChart?) = + this.copy(simprintsRampHistoryChart = simprintsRampHistoryChart) + override fun equals(item: FieldUiModel): Boolean { if (this === item) return true if (javaClass != item.javaClass) return false @@ -132,6 +141,7 @@ data class FieldUiModelImpl( if (selectableDates != item.selectableDates) return false if (eventCategories != item.eventCategories) return false if (customIntent != item.customIntent) return false + if (simprintsRampHistoryChart != item.simprintsRampHistoryChart) return false if (optionSetConfiguration != item.optionSetConfiguration) return false return true } diff --git a/form/src/main/java/org/dhis2/form/model/SectionUiModelImpl.kt b/form/src/main/java/org/dhis2/form/model/SectionUiModelImpl.kt index addf657cdde..1b743609693 100644 --- a/form/src/main/java/org/dhis2/form/model/SectionUiModelImpl.kt +++ b/form/src/main/java/org/dhis2/form/model/SectionUiModelImpl.kt @@ -1,6 +1,7 @@ package org.dhis2.form.model import androidx.databinding.ObservableField +import org.dhis2.form.simprints.ramp.model.FormHistoryChart as SimprintsRampFormHistoryChart import org.dhis2.form.ui.event.UiEventFactory import org.dhis2.form.ui.intent.FormIntent import org.dhis2.form.ui.intent.FormIntent.OnFocus @@ -45,6 +46,7 @@ data class SectionUiModelImpl( override val eventCategories: List? = null, override val periodSelector: PeriodSelector? = null, override var customIntent: CustomIntentModel? = null, + override val simprintsRampHistoryChart: SimprintsRampFormHistoryChart? = null, ) : FieldUiModel { private var sectionNumber: Int = 0 private var showBottomShadow: Boolean = false @@ -127,6 +129,11 @@ data class SectionUiModelImpl( override fun setOptionSetConfiguration(optionSetConfiguration: OptionSetConfiguration) = this.copy(optionSetConfiguration = optionSetConfiguration) + override fun setSimprintsRampHistoryChart( + simprintsRampHistoryChart: SimprintsRampFormHistoryChart?, + ) = + this.copy(simprintsRampHistoryChart = simprintsRampHistoryChart) + override fun isSectionWithFields() = totalFields > 0 override fun equals(item: FieldUiModel): Boolean { diff --git a/form/src/main/java/org/dhis2/form/simprints/ramp/data/FormHistoryChartRepository.kt b/form/src/main/java/org/dhis2/form/simprints/ramp/data/FormHistoryChartRepository.kt new file mode 100644 index 00000000000..a24df722736 --- /dev/null +++ b/form/src/main/java/org/dhis2/form/simprints/ramp/data/FormHistoryChartRepository.kt @@ -0,0 +1,136 @@ +package org.dhis2.form.simprints.ramp.data + +import org.dhis2.commons.simprints.ramp.model.DataElementHistoryChartConfig +import org.dhis2.form.model.FieldUiModel +import org.dhis2.form.simprints.ramp.model.FormHistoryChart +import org.hisp.dhis.android.core.D2 +import org.hisp.dhis.android.core.event.Event +import java.util.Date + +class FormHistoryChartRepository( + private val eventUid: String, + private val d2: D2, +) { + private val currentEvent by lazy { loadCurrentEvent() } + private val followUpVisitEventsByProgramStage = mutableMapOf>() + + fun getChart( + fieldUiModel: FieldUiModel, + configs: List, + ): FormHistoryChart? { + val currentEvent = currentEvent ?: return null + val chartConfig = + configs.firstOrNull { config -> + config.programId?.trim() == currentEvent.program() && + config.followUpVisitProgramStageId?.trim() == currentEvent.programStage() && + config.dataElementId?.trim() == fieldUiModel.uid + } ?: return null + + return getVisitNumberHistoryChartFor( + currentEvent = currentEvent, + fieldUiModel = fieldUiModel, + chartConfig = chartConfig, + ) + } + + private fun getVisitNumberHistoryChartFor( + currentEvent: Event, + fieldUiModel: FieldUiModel, + chartConfig: DataElementHistoryChartConfig, + ): FormHistoryChart? { + val followUpVisitMaxNumber = chartConfig.followUpVisitMaxNumber?.takeIf { it >= 0 } ?: return null + val followUpVisitProgramStageUid = chartConfig.followUpVisitProgramStageId.trimToValue() ?: return null + val xAxisVisitNumberDataElementUid = chartConfig.xAxisVisitNumberDataElementId.trimToValue() ?: return null + val dataElementUid = chartConfig.dataElementId.trimToValue() ?: return null + val labels = (0..followUpVisitMaxNumber).map(Int::toString) + val values = MutableList(labels.size) { null } + var currentValueIndex: Int? = null + + getFollowUpVisitEvents(currentEvent, followUpVisitProgramStageUid).forEach { event -> + val dataValuesByDataElement = event.dataValuesByDataElement() + val visitNumber = + dataValuesByDataElement[xAxisVisitNumberDataElementUid] + ?.toVisitNumber() + ?.takeIf { it in 0..followUpVisitMaxNumber } + ?: return@forEach + val value = + if (event.uid() == eventUid) { + currentValueIndex = visitNumber + fieldUiModel.value?.toFloatOrNull() + } else { + dataValuesByDataElement[dataElementUid]?.toFloatOrNull() + } + + values[visitNumber] = value + } + + return FormHistoryChart( + title = fieldUiModel.label, + labels = labels, + values = values, + currentValueIndex = currentValueIndex, + ) + } + + private fun loadCurrentEvent(): Event? = + d2 + .eventModule() + .events() + .uid(eventUid) + .blockingGet() + + private fun getFollowUpVisitEvents( + currentEvent: Event, + followUpVisitProgramStageUid: String, + ): List { + followUpVisitEventsByProgramStage[followUpVisitProgramStageUid]?.let { return it } + + val enrollmentUid = currentEvent.enrollment() ?: return emptyList() + val currentEventDate = currentEvent.displayDate() + + return d2 + .eventModule() + .events() + .withTrackedEntityDataValues() + .byEnrollmentUid() + .eq(enrollmentUid) + .byProgramStageUid() + .eq(followUpVisitProgramStageUid) + .blockingGet() + .filter { followUpEvent -> + val followUpEventDate = followUpEvent.displayDate() + followUpEvent.uid() == eventUid || + currentEventDate == null || + followUpEventDate == null || + !followUpEventDate.after(currentEventDate) + }.sortedWith( + compareBy( + { followUpEvent -> followUpEvent.displayDate() ?: Date(0) }, + { followUpEvent -> followUpEvent.uid() }, + ), + ).also { followUpVisitEventsByProgramStage[followUpVisitProgramStageUid] = it } + } + + private fun Event.dataValuesByDataElement(): Map = + trackedEntityDataValues() + .orEmpty() + .mapNotNull { dataValue -> + val dataElementUid = dataValue.dataElement() + val value = dataValue.value() + if (dataElementUid == null || value == null) { + null + } else { + dataElementUid to value + } + }.toMap() + + private fun Event.displayDate(): Date? = eventDate() ?: dueDate() ?: created() + + private fun String?.trimToValue(): String? = this?.trim()?.takeIf { it.isNotEmpty() } + + private fun String.toVisitNumber(): Int? { + val number = trim().toDoubleOrNull() ?: return null + val integer = number.toInt() + return integer.takeIf { it.toDouble() == number } + } +} diff --git a/form/src/main/java/org/dhis2/form/simprints/ramp/data/GetFormHistoryChartUseCase.kt b/form/src/main/java/org/dhis2/form/simprints/ramp/data/GetFormHistoryChartUseCase.kt new file mode 100644 index 00000000000..841d8a8d6e9 --- /dev/null +++ b/form/src/main/java/org/dhis2/form/simprints/ramp/data/GetFormHistoryChartUseCase.kt @@ -0,0 +1,16 @@ +package org.dhis2.form.simprints.ramp.data + +import org.dhis2.commons.simprints.ramp.repository.RampDatastoreRepository +import org.dhis2.form.model.FieldUiModel +import org.dhis2.form.simprints.ramp.model.FormHistoryChart + +class GetFormHistoryChartUseCase( + private val rampDatastoreRepository: RampDatastoreRepository, + private val formHistoryChartRepository: FormHistoryChartRepository, +) { + operator fun invoke(fieldUiModel: FieldUiModel): FormHistoryChart? = + formHistoryChartRepository.getChart( + fieldUiModel = fieldUiModel, + configs = rampDatastoreRepository.getConfig().dataElementHistoryCharts, + ) +} diff --git a/form/src/main/java/org/dhis2/form/simprints/ramp/model/FormHistoryChart.kt b/form/src/main/java/org/dhis2/form/simprints/ramp/model/FormHistoryChart.kt new file mode 100644 index 00000000000..55e96d34499 --- /dev/null +++ b/form/src/main/java/org/dhis2/form/simprints/ramp/model/FormHistoryChart.kt @@ -0,0 +1,18 @@ +package org.dhis2.form.simprints.ramp.model + +data class FormHistoryChart( + val title: String, + val labels: List, + val values: List, + val currentValueIndex: Int? = null, +) { + fun withCurrentValue(value: String?): FormHistoryChart { + val index = currentValueIndex?.takeIf { it in values.indices } ?: return this + return copy( + values = + values.toMutableList().also { updatedValues -> + updatedValues[index] = value?.toFloatOrNull() + }, + ) + } +} diff --git a/form/src/main/java/org/dhis2/form/simprints/ramp/ui/FormHistoryChartView.kt b/form/src/main/java/org/dhis2/form/simprints/ramp/ui/FormHistoryChartView.kt new file mode 100644 index 00000000000..b7657764e66 --- /dev/null +++ b/form/src/main/java/org/dhis2/form/simprints/ramp/ui/FormHistoryChartView.kt @@ -0,0 +1,228 @@ +package org.dhis2.form.simprints.ramp.ui + +import android.content.Context +import android.view.ViewGroup +import android.widget.FrameLayout +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import com.github.mikephil.charting.charts.LineChart +import com.github.mikephil.charting.components.AxisBase +import com.github.mikephil.charting.formatter.ValueFormatter +import dhis2.org.analytics.charts.data.ChartType +import dhis2.org.analytics.charts.data.Graph +import dhis2.org.analytics.charts.data.GraphFieldValue +import dhis2.org.analytics.charts.data.GraphPoint +import dhis2.org.analytics.charts.data.SerieData +import dhis2.org.analytics.charts.data.toChartBuilder +import dhis2.org.analytics.charts.formatters.CategoryFormatter +import dhis2.org.analytics.charts.mappers.DEFAULT_VALUE_TEXT_SIZE +import dhis2.org.analytics.charts.mappers.GraphToLineData +import org.dhis2.form.simprints.ramp.model.FormHistoryChart +import org.hisp.dhis.android.core.common.RelativePeriod +import org.hisp.dhis.android.core.period.PeriodType +import java.text.DecimalFormat +import java.text.DecimalFormatSymbols +import java.util.Date +import java.util.Locale +import kotlin.math.abs +import kotlin.math.ceil +import kotlin.math.roundToInt + +private const val HISTORY_CHART_HEIGHT_DP = 180 +private const val HISTORY_CHART_PERIOD_STEP = 1L +private const val HISTORY_CHART_X_AXIS_LABEL_ROTATION = 30f +private const val HISTORY_CHART_TEXT_SIZE_FACTOR = 1.25f +private const val HISTORY_CHART_TEXT_SIZE = DEFAULT_VALUE_TEXT_SIZE * HISTORY_CHART_TEXT_SIZE_FACTOR +private const val HISTORY_CHART_Y_AXIS_LABEL_COUNT = 5 +private const val HISTORY_CHART_FLAT_RANGE_EPSILON = 0.0001f +private const val HISTORY_CHART_INTEGER_GRANULARITY_MIN_SPAN = 1f +private const val HISTORY_CHART_INTEGER_GRANULARITY = 1f + +@Composable +fun FormHistoryChartView(historyChart: FormHistoryChart) { + val graph = remember(historyChart) { historyChart.toAnalyticsGraph() } + + AndroidView( + modifier = + Modifier + .fillMaxWidth() + .height(HISTORY_CHART_HEIGHT_DP.dp) + .padding(top = 8.dp, bottom = 12.dp), + factory = { context -> + FrameLayout(context).apply { + addView(graph.toChartView(context, historyChart)) + } + }, + update = { chartContainer -> + (chartContainer.getChildAt(0) as? LineChart) + ?.configureHistoryChart(historyChart, graph) + }, + ) +} + +private fun Graph.toChartView( + context: Context, + historyChart: FormHistoryChart, +) = toChartBuilder() + .withType(ChartType.LINE_CHART) + .withGraphData(this) + .build() + .getChartView(context) + .apply { + layoutParams = + FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT, + ) + (this as? LineChart)?.configureHistoryChart(historyChart, this@toChartView) + } + +private fun LineChart.configureHistoryChart( + historyChart: FormHistoryChart, + graph: Graph, +) { + val valueFormatter = IntegerAwareValueFormatter() + + setHighlightPerTapEnabled(false) + setHighlightPerDragEnabled(false) + data = graph.toHistoryLineData(valueFormatter) + xAxis.apply { + axisMinimum = -1f + axisMaximum = historyChart.labels.size.toFloat() + granularity = HISTORY_CHART_INTEGER_GRANULARITY + setLabelCount(historyChart.labels.size + 2, true) + this.valueFormatter = CategoryFormatter(historyChart.labels) + labelRotationAngle = HISTORY_CHART_X_AXIS_LABEL_ROTATION + textSize = HISTORY_CHART_TEXT_SIZE + } + axisLeft.apply { + this.valueFormatter = valueFormatter + setLabelCount(HISTORY_CHART_Y_AXIS_LABEL_COUNT, false) + textSize = HISTORY_CHART_TEXT_SIZE + } + configureHistoryYAxis(historyChart) + legend.textSize = HISTORY_CHART_TEXT_SIZE + notifyDataSetChanged() + invalidate() +} + +private fun LineChart.configureHistoryYAxis(historyChart: FormHistoryChart) { + val plottedValues = historyChart.values.filterNotNull() + if (plottedValues.isEmpty()) { + axisLeft.apply { + isGranularityEnabled = false + resetAxisMinimum() + resetAxisMaximum() + } + return + } + + val minValue = plottedValues.min() + val maxValue = plottedValues.max() + + axisLeft.apply { + isGranularityEnabled = maxValue - minValue >= HISTORY_CHART_INTEGER_GRANULARITY_MIN_SPAN + if (isGranularityEnabled) { + granularity = HISTORY_CHART_INTEGER_GRANULARITY + } + } + + applyHistoryYAxisRange(minValue, maxValue) +} + +private fun LineChart.applyHistoryYAxisRange( + minValue: Float, + maxValue: Float, +) { + // Fix for: MPAndroidChart renders no Y labels/gridlines when the Y axis range collapses + axisLeft.apply { + when { + abs(maxValue - minValue) <= HISTORY_CHART_FLAT_RANGE_EPSILON && maxValue > 0f -> { + axisMinimum = 0f + axisMaximum = maxValue + historyChartYAxisPadding(0f, maxValue) + } + + abs(maxValue - minValue) <= HISTORY_CHART_FLAT_RANGE_EPSILON && minValue < 0f -> { + axisMinimum = minValue - historyChartYAxisPadding(minValue, 0f) + axisMaximum = 0f + } + + abs(maxValue - minValue) <= HISTORY_CHART_FLAT_RANGE_EPSILON -> { + axisMinimum = -1f + axisMaximum = 1f + } + + else -> { + val padding = historyChartYAxisPadding(minValue, maxValue) + axisMinimum = minValue - padding + axisMaximum = maxValue + padding + } + } + } +} + +private fun historyChartYAxisPadding( + minValue: Float, + maxValue: Float, +): Float = ceil((maxValue - minValue) * 0.05f) + +private fun Graph.toHistoryLineData(valueFormatter: ValueFormatter) = + GraphToLineData() + .map(this) + .apply { + setValueFormatter(valueFormatter) + setValueTextSize(HISTORY_CHART_TEXT_SIZE) + } + +private class IntegerAwareValueFormatter : ValueFormatter() { + private val decimalFormat = + DecimalFormat("0.##", DecimalFormatSymbols(Locale.US)) + + override fun getFormattedValue(value: Float): String = + if (abs(value - value.roundToInt()) < INTEGER_VALUE_EPSILON) { + value.roundToInt().toString() + } else { + decimalFormat.format(value.toDouble()) + } + + override fun getAxisLabel( + value: Float, + axis: AxisBase?, + ): String = getFormattedValue(value) + + private companion object { + const val INTEGER_VALUE_EPSILON = 0.0001f + } +} + +private fun FormHistoryChart.toAnalyticsGraph(): Graph = + Graph( + title = title, + series = + listOf( + SerieData( + fieldName = title, + coordinates = + values.mapIndexedNotNull { index, value -> + value?.let { + GraphPoint( + eventDate = Date(index.toLong()), + position = index.toFloat(), + fieldValue = GraphFieldValue.Numeric(value), + ) + } + }, + ), + ), + periodToDisplayDefault = RelativePeriod.TODAY, + eventPeriodType = PeriodType.Daily, + periodStep = HISTORY_CHART_PERIOD_STEP, + chartType = ChartType.LINE_CHART, + categories = labels, + ) 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 e1eb21e6108..e379a14d33e 100644 --- a/form/src/main/java/org/dhis2/form/ui/FormViewModel.kt +++ b/form/src/main/java/org/dhis2/form/ui/FormViewModel.kt @@ -301,13 +301,26 @@ class FormViewModel( } private fun handleOnTextChangeAction(action: RowAction): StoreResult { - repository.updateValueOnList(action.id, action.value, action.valueType) + updateSimprintsRampHistoryChart( + repository.updateValueOnList(action.id, action.value, action.valueType), + ) return StoreResult( action.id, ValueStoreResult.TEXT_CHANGING, ) } + private fun updateSimprintsRampHistoryChart(updatedField: FieldUiModel?) { + val chartField = updatedField?.takeIf { it.simprintsRampHistoryChart != null } ?: return + val currentItems = _items.value ?: return + + _items.postValue( + currentItems.map { item -> + if (item.uid == chartField.uid) chartField else item + }, + ) + } + private fun handleOnSectionChangeAction(action: RowAction): StoreResult { repository.updateSectionOpened(action) return StoreResult( diff --git a/form/src/main/java/org/dhis2/form/ui/provider/inputfield/FieldProvider.kt b/form/src/main/java/org/dhis2/form/ui/provider/inputfield/FieldProvider.kt index 27ba6a2ee2b..198c1b38561 100644 --- a/form/src/main/java/org/dhis2/form/ui/provider/inputfield/FieldProvider.kt +++ b/form/src/main/java/org/dhis2/form/ui/provider/inputfield/FieldProvider.kt @@ -2,6 +2,7 @@ package org.dhis2.form.ui.provider.inputfield import android.content.Intent import androidx.compose.foundation.focusable +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.relocation.BringIntoViewRequester import androidx.compose.foundation.relocation.bringIntoViewRequester @@ -35,6 +36,7 @@ import org.dhis2.form.extensions.supportingText import org.dhis2.form.model.EnrollmentDetail import org.dhis2.form.model.FieldUiModel import org.dhis2.form.model.UiRenderType +import org.dhis2.form.simprints.ramp.ui.FormHistoryChartView as SimprintsRampFormHistoryChartView import org.dhis2.form.ui.event.RecyclerViewUiEvents import org.dhis2.form.ui.intent.FormIntent import org.dhis2.form.ui.keyboard.keyboardAsState @@ -107,56 +109,59 @@ fun FieldProvider( } } - when { - fieldUiModel.optionSet != null && fieldUiModel.valueType != ValueType.MULTI_TEXT -> - ProvideByOptionSet( - modifier = modifierWithFocus, - inputStyle = inputStyle, - fieldUiModel = fieldUiModel, - intentHandler = intentHandler, - fetchOptions = { - intentHandler( - FormIntent.FetchOptions( - fieldUiModel.uid, - fieldUiModel.optionSet!!, - value = fieldUiModel.value, - ), - ) - }, - ) + Column(Modifier.fillMaxWidth()) { + when { + fieldUiModel.optionSet != null && fieldUiModel.valueType != ValueType.MULTI_TEXT -> + ProvideByOptionSet( + modifier = modifierWithFocus, + inputStyle = inputStyle, + fieldUiModel = fieldUiModel, + intentHandler = intentHandler, + fetchOptions = { + intentHandler( + FormIntent.FetchOptions( + fieldUiModel.uid, + fieldUiModel.optionSet!!, + value = fieldUiModel.value, + ), + ) + }, + ) + + fieldUiModel.customIntent != null -> { + ProvideCustomIntentInput( + fieldUiModel = fieldUiModel, + intentHandler = intentHandler, + uiEventHandler = uiEventHandler, + resources = resources, + inputStyle = inputStyle, + reEvaluateRequestParams = reEvaluateCustomIntentRequestParameters, + modifier = modifierWithFocus, + ) + } - fieldUiModel.customIntent != null -> { - ProvideCustomIntentInput( - fieldUiModel = fieldUiModel, - intentHandler = intentHandler, - uiEventHandler = uiEventHandler, - resources = resources, - inputStyle = inputStyle, - reEvaluateRequestParams = reEvaluateCustomIntentRequestParameters, - modifier = modifierWithFocus, - ) + fieldUiModel.eventCategories != null -> + ProvideCategorySelectorInput( + modifier = modifierWithFocus, + inputStyle = inputStyle, + fieldUiModel = fieldUiModel, + ) + + else -> + ProvideByValueType( + modifier = modifierWithFocus, + inputStyle = inputStyle, + fieldUiModel = fieldUiModel, + intentHandler = intentHandler, + uiEventHandler = uiEventHandler, + resources = resources, + focusRequester = focusRequester, + onNextClicked = onNextClicked, + focusManager = focusManager, + onFileSelected = onFileSelected, + ) } - - fieldUiModel.eventCategories != null -> - ProvideCategorySelectorInput( - modifier = modifierWithFocus, - inputStyle = inputStyle, - fieldUiModel = fieldUiModel, - ) - - else -> - ProvideByValueType( - modifier = modifierWithFocus, - inputStyle = inputStyle, - fieldUiModel = fieldUiModel, - intentHandler = intentHandler, - uiEventHandler = uiEventHandler, - resources = resources, - focusRequester = focusRequester, - onNextClicked = onNextClicked, - focusManager = focusManager, - onFileSelected = onFileSelected, - ) + fieldUiModel.simprintsRampHistoryChart?.let { SimprintsRampFormHistoryChartView(it) } } } diff --git a/form/src/test/java/org/dhis2/form/data/EventRepositoryTest.kt b/form/src/test/java/org/dhis2/form/data/EventRepositoryTest.kt index f5fb706e9be..5333cb0d583 100644 --- a/form/src/test/java/org/dhis2/form/data/EventRepositoryTest.kt +++ b/form/src/test/java/org/dhis2/form/data/EventRepositoryTest.kt @@ -7,6 +7,7 @@ import org.dhis2.commons.resources.MetadataIconProvider import org.dhis2.commons.resources.ResourceManager import org.dhis2.commons.viewmodel.DispatcherProvider import org.dhis2.form.model.EventMode +import org.dhis2.form.simprints.ramp.data.GetFormHistoryChartUseCase as GetSimprintsRampFormHistoryChartUseCase import org.dhis2.form.ui.FieldViewModelFactory import org.dhis2.mobile.commons.customintents.CustomIntentRepository import org.hisp.dhis.android.core.D2 @@ -42,6 +43,7 @@ class EventRepositoryTest { private val eventResourcesProvider: EventResourcesProvider = mock() private val metadataIconProvider: MetadataIconProvider = mock() private val customIntentRepository: CustomIntentRepository = Mockito.mock() + private val getSimprintsRampFormHistoryChart: GetSimprintsRampFormHistoryChartUseCase = mock() private val mockedProgram: Program = mock { @@ -186,6 +188,7 @@ class EventRepositoryTest { eventMode = eventMode, dispatcherProvider = dispatchers, customIntentRepository = customIntentRepository, + getSimprintsRampFormHistoryChart = getSimprintsRampFormHistoryChart, ) private val mockedStage = diff --git a/form/src/test/java/org/dhis2/form/data/RulesUtilsProviderImplTest.kt b/form/src/test/java/org/dhis2/form/data/RulesUtilsProviderImplTest.kt index 5db20354047..8f40823f24e 100644 --- a/form/src/test/java/org/dhis2/form/data/RulesUtilsProviderImplTest.kt +++ b/form/src/test/java/org/dhis2/form/data/RulesUtilsProviderImplTest.kt @@ -440,6 +440,47 @@ class RulesUtilsProviderImplTest { assertTrue(result.fieldsToUpdate.isEmpty()) } + @Test + fun `RuleActionAssign should persist unrendered field value`() { + val testingUid = "unrenderedUid" + testRuleEffects.add( + RuleEffect( + "ruleUid", + RuleAction( + "13", + ProgramRuleActionType.ASSIGN.name, + mutableMapOf( + "content" to "content", + "field" to testingUid, + ), + ), + "13", + ), + ) + whenever(valueStore.saveWithTypeCheck(testingUid, "13")) doReturn + Flowable.just( + StoreResult( + testingUid, + ValueStoreResult.VALUE_CHANGED, + ), + ) + + val result = + ruleUtils.applyRuleEffects( + true, + testFieldViewModels, + testRuleEffects, + valueStore, + ) + + verify(valueStore).saveWithTypeCheck(testingUid, "13") + assertTrue( + result.fieldsToUpdate.any { + it.fieldUid == testingUid && it.newValue == "13" + }, + ) + } + @Test fun `RuleActionAssign should assign a value to an empty field with option set`() { val newValue = "New Value" diff --git a/form/src/test/java/org/dhis2/form/simprints/ramp/data/FormHistoryChartRepositoryTest.kt b/form/src/test/java/org/dhis2/form/simprints/ramp/data/FormHistoryChartRepositoryTest.kt new file mode 100644 index 00000000000..db465f5509e --- /dev/null +++ b/form/src/test/java/org/dhis2/form/simprints/ramp/data/FormHistoryChartRepositoryTest.kt @@ -0,0 +1,231 @@ +package org.dhis2.form.simprints.ramp.data + +import org.dhis2.commons.simprints.ramp.model.DataElementHistoryChartConfig +import org.dhis2.form.model.FieldUiModelImpl +import org.hisp.dhis.android.core.D2 +import org.hisp.dhis.android.core.arch.repositories.filters.internal.StringFilterConnector +import org.hisp.dhis.android.core.common.ValueType +import org.hisp.dhis.android.core.event.Event +import org.hisp.dhis.android.core.event.EventCollectionRepository +import org.hisp.dhis.android.core.event.EventObjectRepository +import org.hisp.dhis.android.core.trackedentity.TrackedEntityDataValue +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import org.mockito.Mockito +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.util.Date + +class FormHistoryChartRepositoryTest { + private val d2: D2 = Mockito.mock(D2::class.java, Mockito.RETURNS_DEEP_STUBS) + private val events: EventCollectionRepository = mock() + private val currentEventRepository: EventObjectRepository = mock() + private val eventsWithDataValues: EventCollectionRepository = mock() + private val enrollmentFilter: StringFilterConnector = mock() + private val enrollmentEvents: EventCollectionRepository = mock() + private val programStageFilter: StringFilterConnector = mock() + private val followUpEvents: EventCollectionRepository = mock() + private val repository = FormHistoryChartRepository(CURRENT_EVENT_UID, d2) + + @Test + fun `getChart should return visit-number chart with current input value and no future values`() { + val currentEvent = + getEvent( + uid = CURRENT_EVENT_UID, + eventDate = Date(2_000), // millis + dataValues = + listOf( + dataValue(VISIT_NUMBER_UID, "1"), + dataValue(DATA_ELEMENT_UID, "9.0"), + ), + ) + stubCurrentEvent(currentEvent) + stubFollowUpEvents( + listOf( + getEvent( + uid = "previous", + eventDate = Date(1_000), + dataValues = + listOf( + dataValue(VISIT_NUMBER_UID, "0"), + dataValue(DATA_ELEMENT_UID, "8.0"), + ), + ), + currentEvent, + getEvent( + uid = "future", + eventDate = Date(3_000), + dataValues = + listOf( + dataValue(VISIT_NUMBER_UID, "2"), + dataValue(DATA_ELEMENT_UID, "10.0"), + ), + ), + ), + ) + + val chart = + repository.getChart( + fieldUiModel = + FieldUiModelImpl( + uid = DATA_ELEMENT_UID, + value = "9.5", + label = "Weight", + valueType = ValueType.NUMBER, + optionSetConfiguration = null, + autocompleteList = null, + ), + configs = listOf(getConfig()), + ) + + assertEquals("Weight", chart?.title) + assertEquals(listOf("0", "1", "2", "3"), chart?.labels) + assertEquals(listOf(8f, 9.5f, null, null), chart?.values) + assertEquals(1, chart?.currentValueIndex) + } + + @Test + fun `getChart should return null when field is not configured`() { + stubCurrentEvent(getEvent(uid = CURRENT_EVENT_UID)) + + val chart = + repository.getChart( + fieldUiModel = + FieldUiModelImpl( + uid = "unconfigured-data-element", + label = "Height", + valueType = ValueType.NUMBER, + optionSetConfiguration = null, + autocompleteList = null, + ), + configs = listOf(getConfig()), + ) + + assertNull(chart) + } + + @Test + fun `getChart should not fall back to stored current event value when current input is empty`() { + val currentEvent = + getEvent( + uid = CURRENT_EVENT_UID, + dataValues = + listOf( + dataValue(VISIT_NUMBER_UID, "1"), + dataValue(DATA_ELEMENT_UID, "9.0"), + ), + ) + stubCurrentEvent(currentEvent) + stubFollowUpEvents(listOf(currentEvent)) + + val chart = + repository.getChart( + fieldUiModel = + FieldUiModelImpl( + uid = DATA_ELEMENT_UID, + value = "", + label = "Weight", + valueType = ValueType.NUMBER, + optionSetConfiguration = null, + autocompleteList = null, + ), + configs = listOf(getConfig()), + ) + + assertEquals(listOf(null, null, null, null), chart?.values) + assertEquals(1, chart?.currentValueIndex) + } + + @Test + fun `getChart should reuse loaded current event and follow up events`() { + val currentEvent = + getEvent( + uid = CURRENT_EVENT_UID, + dataValues = + listOf( + dataValue(VISIT_NUMBER_UID, "1"), + dataValue(DATA_ELEMENT_UID, "9.0"), + ), + ) + val fieldUiModel = + FieldUiModelImpl( + uid = DATA_ELEMENT_UID, + value = "9.5", + label = "Weight", + valueType = ValueType.NUMBER, + optionSetConfiguration = null, + autocompleteList = null, + ) + stubCurrentEvent(currentEvent) + stubFollowUpEvents(listOf(currentEvent)) + + repository.getChart(fieldUiModel, listOf(getConfig())) + repository.getChart(fieldUiModel, listOf(getConfig())) + + verify(currentEventRepository, times(1)).blockingGet() + verify(followUpEvents, times(1)).blockingGet() + } + + private fun getConfig() = + DataElementHistoryChartConfig( + programId = PROGRAM_UID, + followUpVisitProgramStageId = PROGRAM_STAGE_UID, + dataElementId = DATA_ELEMENT_UID, + xAxisVisitNumberDataElementId = VISIT_NUMBER_UID, + followUpVisitMaxNumber = 3, + ) + + private fun stubCurrentEvent(event: Event) { + whenever(d2.eventModule().events()) doReturn events + whenever(events.uid(CURRENT_EVENT_UID)) doReturn currentEventRepository + whenever(currentEventRepository.blockingGet()) doReturn event + } + + private fun stubFollowUpEvents(eventsToReturn: List) { + whenever(events.withTrackedEntityDataValues()) doReturn eventsWithDataValues + whenever(eventsWithDataValues.byEnrollmentUid()) doReturn enrollmentFilter + whenever(enrollmentFilter.eq(ENROLLMENT_UID)) doReturn enrollmentEvents + whenever(enrollmentEvents.byProgramStageUid()) doReturn programStageFilter + whenever(programStageFilter.eq(PROGRAM_STAGE_UID)) doReturn followUpEvents + whenever(followUpEvents.blockingGet()) doReturn eventsToReturn + } + + private fun getEvent( + uid: String, + eventDate: Date = Date(1_000), + dataValues: List = emptyList(), + ): Event = + Event + .builder() + .uid(uid) + .program(PROGRAM_UID) + .programStage(PROGRAM_STAGE_UID) + .enrollment(ENROLLMENT_UID) + .eventDate(eventDate) + .trackedEntityDataValues(dataValues) + .build() + + private fun dataValue( + dataElementUid: String, + value: String, + ): TrackedEntityDataValue = + TrackedEntityDataValue + .builder() + .event(CURRENT_EVENT_UID) + .dataElement(dataElementUid) + .value(value) + .build() + + private companion object { + const val CURRENT_EVENT_UID = "current-event" + const val ENROLLMENT_UID = "enrollment" + const val PROGRAM_UID = "program" + const val PROGRAM_STAGE_UID = "follow-stage" + const val DATA_ELEMENT_UID = "weight" + const val VISIT_NUMBER_UID = "visit-number" + } +} diff --git a/form/src/test/java/org/dhis2/form/simprints/ramp/data/GetFormHistoryChartUseCaseTest.kt b/form/src/test/java/org/dhis2/form/simprints/ramp/data/GetFormHistoryChartUseCaseTest.kt new file mode 100644 index 00000000000..36846cd56e6 --- /dev/null +++ b/form/src/test/java/org/dhis2/form/simprints/ramp/data/GetFormHistoryChartUseCaseTest.kt @@ -0,0 +1,53 @@ +package org.dhis2.form.simprints.ramp.data + +import org.dhis2.commons.simprints.ramp.model.DataElementHistoryChartConfig +import org.dhis2.commons.simprints.ramp.model.RampDatastoreConfig +import org.dhis2.commons.simprints.ramp.repository.RampDatastoreRepository +import org.dhis2.form.model.FieldUiModel +import org.dhis2.form.simprints.ramp.model.FormHistoryChart +import org.junit.Assert.assertEquals +import org.junit.Test +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +class GetFormHistoryChartUseCaseTest { + private val rampDatastoreRepository: RampDatastoreRepository = mock() + private val formHistoryChartRepository: FormHistoryChartRepository = mock() + private val useCase = + GetFormHistoryChartUseCase( + rampDatastoreRepository = rampDatastoreRepository, + formHistoryChartRepository = formHistoryChartRepository, + ) + + @Test + fun `invoke should build chart from ramp datastore chart configs`() { + val fieldUiModel: FieldUiModel = mock() + val chartConfigs = + listOf( + DataElementHistoryChartConfig( + programId = "program", + followUpVisitProgramStageId = "follow-stage", + dataElementId = "weight", + xAxisVisitNumberDataElementId = "visit-number", + followUpVisitMaxNumber = 3, + ), + ) + val expectedChart = + FormHistoryChart( + title = "Weight", + labels = listOf("0", "1"), + values = listOf(8f, 9f), + ) + whenever(rampDatastoreRepository.getConfig()) doReturn + RampDatastoreConfig(dataElementHistoryCharts = chartConfigs) + whenever(formHistoryChartRepository.getChart(fieldUiModel, chartConfigs)) doReturn expectedChart + + val chart = useCase(fieldUiModel) + + assertEquals(expectedChart, chart) + verify(rampDatastoreRepository).getConfig() + verify(formHistoryChartRepository).getChart(fieldUiModel, chartConfigs) + } +} 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 d18234f3660..5214987aaa5 100644 --- a/form/src/test/java/org/dhis2/form/ui/FormViewModelTest.kt +++ b/form/src/test/java/org/dhis2/form/ui/FormViewModelTest.kt @@ -13,7 +13,9 @@ import org.dhis2.form.data.FormRepository import org.dhis2.form.data.GeometryController import org.dhis2.form.model.ActionType import org.dhis2.form.model.FieldUiModel +import org.dhis2.form.model.FieldUiModelImpl import org.dhis2.form.model.RowAction +import org.dhis2.form.simprints.ramp.model.FormHistoryChart import org.dhis2.form.ui.event.RecyclerViewUiEvents import org.dhis2.form.ui.intent.FormIntent import org.dhis2.form.ui.provider.FormResultDialogProvider @@ -123,6 +125,45 @@ class FormViewModelTest { verify(repository).updateValueOnList(dateField.uid, dateField.value, dateField.valueType) } + @Test + fun `Should publish updated simprints ramp chart field when text is changing`() = + runTest { + val initialField = + FieldUiModelImpl( + uid = "weight", + value = "9.0", + label = "Weight", + valueType = ValueType.NUMBER, + optionSetConfiguration = null, + autocompleteList = null, + simprintsRampHistoryChart = + FormHistoryChart( + title = "Weight", + labels = listOf("0", "1"), + values = listOf(8f, 9f), + currentValueIndex = 1, + ), + ) + val updatedField = initialField.setValue("") + whenever(repository.fetchFormItems(any())) doReturn listOf(initialField) + whenever(repository.getDateFormatConfiguration()) doReturn "ddMMyyyy" + viewModel = + FormViewModel( + repository, + dispatcher, + geometryController, + resultDialogUiProvider = resultDialogUiProvider, + ) + advanceUntilIdle() + whenever(repository.updateValueOnList("weight", "", ValueType.NUMBER)) doReturn updatedField + + viewModel.submitIntent(FormIntent.OnTextChange("weight", "", ValueType.NUMBER)) + advanceUntilIdle() + + assertEquals(updatedField, viewModel.items.value?.first()) + verify(repository).updateValueOnList("weight", "", ValueType.NUMBER) + } + private val futureDate: String = LocalDate.now().plusDays(1).format(DateTimeFormatter.ISO_DATE) private val dateFieldFuture: FieldUiModel = From ac3699c18ef2c085895e71b2b2839f492af0086d Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 2 Jun 2026 12:17:25 +0100 Subject: [PATCH 03/12] Simprints RAMP-39,40,41 history table in program & program stage tabs where enabled via datastore management config; full width landscape view for the tables. --- .../ramp/data/EventHistoryTableRepository.kt | 424 ++++++++++++++++++ .../ramp/data/GetEventHistoryTableUseCase.kt | 14 + .../ramp/di/EventHistoryTableComponent.kt | 11 + .../ramp/di/EventHistoryTableModule.kt | 52 +++ .../ramp/model/EventHistoryTableModels.kt | 36 ++ .../ramp/ui/EventHistoryTableFragment.kt | 105 +++++ .../ramp/ui/EventHistoryTableScreen.kt | 303 +++++++++++++ .../ramp/ui/EventHistoryTableViewModel.kt | 43 ++ .../ui/EventHistoryTableViewModelFactory.kt | 18 + .../eventCapture/EventCaptureActivity.kt | 85 +++- .../eventCapture/EventCaptureComponent.java | 4 + .../eventCapture/EventCaptureContract.kt | 4 + .../eventCapture/EventCapturePagerAdapter.kt | 49 +- .../eventCapture/EventCapturePresenterImpl.kt | 23 +- .../EventCaptureRepositoryImpl.java | 31 +- .../eventCapture/EventPageConfigurator.kt | 2 + .../teiDashboard/DashboardRepository.kt | 2 + .../teiDashboard/DashboardRepositoryImpl.kt | 13 + .../teiDashboard/DashboardViewModel.kt | 13 + .../teiDashboard/TeiDashboardComponent.java | 5 + .../TeiDashboardMobileActivity.kt | 50 +++ .../TeiDashboardPageConfigurator.kt | 2 + app/src/main/res/values/strings.xml | 4 + .../data/EventHistoryTableRepositoryTest.kt | 383 ++++++++++++++++ .../data/GetEventHistoryTableUseCaseTest.kt | 42 ++ .../ramp/ui/EventHistoryTableViewModelTest.kt | 93 ++++ .../eventCapture/EventPageConfiguratorTest.kt | 27 ++ .../DashboardRepositoryImplTest.kt | 22 + .../TeiDashboardPageConfiguratorTest.kt | 12 + .../ramp/model/RampDatastoreConfig.kt | 24 +- .../repository/RampDatastoreRepository.kt | 74 +-- .../repository/RampDatastoreRepositoryTest.kt | 79 +++- .../org/dhis2/tracker/TEIDashboardItems.kt | 2 +- 33 files changed, 2010 insertions(+), 41 deletions(-) create mode 100644 app/src/main/java/org/dhis2/simprints/ramp/data/EventHistoryTableRepository.kt create mode 100644 app/src/main/java/org/dhis2/simprints/ramp/data/GetEventHistoryTableUseCase.kt create mode 100644 app/src/main/java/org/dhis2/simprints/ramp/di/EventHistoryTableComponent.kt create mode 100644 app/src/main/java/org/dhis2/simprints/ramp/di/EventHistoryTableModule.kt create mode 100644 app/src/main/java/org/dhis2/simprints/ramp/model/EventHistoryTableModels.kt create mode 100644 app/src/main/java/org/dhis2/simprints/ramp/ui/EventHistoryTableFragment.kt create mode 100644 app/src/main/java/org/dhis2/simprints/ramp/ui/EventHistoryTableScreen.kt create mode 100644 app/src/main/java/org/dhis2/simprints/ramp/ui/EventHistoryTableViewModel.kt create mode 100644 app/src/main/java/org/dhis2/simprints/ramp/ui/EventHistoryTableViewModelFactory.kt create mode 100644 app/src/test/java/org/dhis2/simprints/ramp/data/EventHistoryTableRepositoryTest.kt create mode 100644 app/src/test/java/org/dhis2/simprints/ramp/data/GetEventHistoryTableUseCaseTest.kt create mode 100644 app/src/test/java/org/dhis2/simprints/ramp/ui/EventHistoryTableViewModelTest.kt create mode 100644 app/src/test/java/org/dhis2/usescases/eventsWithoutRegistration/eventCapture/EventPageConfiguratorTest.kt diff --git a/app/src/main/java/org/dhis2/simprints/ramp/data/EventHistoryTableRepository.kt b/app/src/main/java/org/dhis2/simprints/ramp/data/EventHistoryTableRepository.kt new file mode 100644 index 00000000000..52da1cbdfbb --- /dev/null +++ b/app/src/main/java/org/dhis2/simprints/ramp/data/EventHistoryTableRepository.kt @@ -0,0 +1,424 @@ +package org.dhis2.simprints.ramp.data + +import org.dhis2.bindings.userFriendlyValue +import org.dhis2.commons.simprints.ramp.model.ProgramStageHistoryTableConfig +import org.dhis2.commons.simprints.ramp.repository.RampDatastoreRepository +import org.dhis2.simprints.ramp.model.EventHistoryTable +import org.dhis2.simprints.ramp.model.EventHistoryTableColumn +import org.dhis2.simprints.ramp.model.EventHistoryTableRow +import org.dhis2.simprints.ramp.model.EventHistoryTableSection +import org.hisp.dhis.android.core.D2 +import org.hisp.dhis.android.core.arch.repositories.scope.RepositoryScope +import org.hisp.dhis.android.core.common.ValueType +import org.hisp.dhis.android.core.event.Event +import org.hisp.dhis.android.core.program.ProgramStageDataElement +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +class EventHistoryTableRepository( + private val d2: D2, + private val simprintsRampDatastoreRepository: RampDatastoreRepository, + private val eventUid: String? = null, + private val programUid: String? = null, + private val enrollmentUid: String? = null, +) { + fun getTable(): EventHistoryTable? { + val tableContext = getTableContext() ?: return null + val config = tableContext.config + val followUpVisitProgramStageUid = config.followUpVisitProgramStageId.trimToValue() ?: return null + val followUpVisitMaxNumber = config.followUpVisitMaxNumber?.takeIf { it >= 0 } ?: return null + val headerVisitNumberDataElementUid = config.headerVisitNumberDataElementId.trimToValue() ?: return null + val excludedDataElementIds = + ( + config.excludedFollowUpVisitDataElementIds + .orEmpty() + .mapNotNull { dataElementId -> dataElementId.trimToValue() } + + headerVisitNumberDataElementUid + ).toSet() + val rowDefinitions = getRowDefinitions(followUpVisitProgramStageUid, excludedDataElementIds) + if (rowDefinitions.isEmpty()) { + return null + } + + val events = getHistoryEvents(tableContext, followUpVisitProgramStageUid) + val eventDataValuesByUid = events.associate { event -> event.uid() to event.dataValuesByDataElement() } + val eventsByColumnIndex = + getEventsByVisitNumberColumn( + events = events, + eventDataValuesByUid = eventDataValuesByUid, + headerVisitNumberDataElementUid = headerVisitNumberDataElementUid, + followUpVisitMaxNumber = followUpVisitMaxNumber, + ) + val columnIndexes = (0..followUpVisitMaxNumber).toList() + val optionDisplayNamesBySet = getOptionDisplayNamesBySet(rowDefinitions) + + fun eventForColumn(columnIndex: Int): Event? = eventsByColumnIndex[columnIndex] + + val tableColumns = + columnIndexes.map { columnIndex -> + EventHistoryTableColumn( + eventUid = eventForColumn(columnIndex)?.uid(), + label = columnIndex.toString(), + ) + } + val dateRowValues = + columnIndexes.map { columnIndex -> + eventForColumn(columnIndex).displayDate().toHistoryTableDateLabel() + } + val sections = + rowDefinitions.mapNotNull { section -> + section + .rows + .map { row -> + EventHistoryTableRow( + label = row.label, + values = + columnIndexes.map { columnIndex -> + val event = eventForColumn(columnIndex) + row.displayValue( + rawValue = + event + ?.let { eventDataValuesByUid[it.uid()] } + ?.get(row.dataElementUid) + .orEmpty(), + optionDisplayNamesBySet = optionDisplayNamesBySet, + ) + }, + ) + }.takeIf { it.isNotEmpty() } + ?.let { rows -> + EventHistoryTableSection( + title = section.title, + rows = rows, + ) + } + } + + return sections + .takeIf { it.isNotEmpty() } + ?.let { + EventHistoryTable( + columns = tableColumns, + sections = it, + dateRowValues = dateRowValues, + ) + } + } + + private fun getTableContext(): HistoryTableContext? { + val currentEvent = + eventUid + .trimToValue() + ?.let { eventUid -> + d2 + .eventModule() + .events() + .withTrackedEntityDataValues() + .byUid() + .eq(eventUid) + .one() + .blockingGet() + } + + if (currentEvent != null) { + val programUid = currentEvent.program() ?: return null + val programStageUid = currentEvent.programStage() ?: return null + val config = + getProgramStageHistoryTableConfig( + programId = programUid, + programStageId = programStageUid, + ) ?: return null + + return HistoryTableContext( + config = config, + enrollmentUid = currentEvent.enrollment(), + currentEvent = currentEvent, + ) + } + + val programUid = programUid.trimToValue() ?: return null + val enrollmentUid = enrollmentUid.trimToValue() ?: return null + val config = + getProgramStageHistoryTableConfig( + programId = programUid, + programStageId = null, + ) ?: return null + + return HistoryTableContext( + config = config, + enrollmentUid = enrollmentUid, + currentEvent = null, + ) + } + + private fun getProgramStageHistoryTableConfig( + programId: String, + programStageId: String?, + ): ProgramStageHistoryTableConfig? = + simprintsRampDatastoreRepository + .getConfig() + .programStageHistoryTables + .firstOrNull { config -> + config.programId.trimToValue() == programId && + ( + programStageId == null || + config.followUpVisitProgramStageId.trimToValue() == programStageId + ) + } + + private fun getRowDefinitions( + programStageUid: String, + excludedDataElementIds: Set, + ): List { + val followUpRows = + d2 + .programModule() + .programStageDataElements() + .withRenderType() + .byProgramStage() + .eq(programStageUid) + .orderBySortOrder(RepositoryScope.OrderByDirection.ASC) + .blockingGet() + .asSequence() + .filter { programStageDataElement -> + programStageDataElement.programStage()?.uid() == programStageUid + }.mapNotNull { programStageDataElement -> + programStageDataElement.toRowDefinition(excludedDataElementIds) + }.distinctBy { row -> row.dataElementUid } + .toList() + val sections = + d2 + .programModule() + .programStageSections() + .byProgramStageUid() + .eq(programStageUid) + .withDataElements() + .blockingGet() + .filter { section -> section.programStage()?.uid() == programStageUid } + .sortedWith(compareBy({ section -> section.sortOrder() ?: Int.MAX_VALUE }, { section -> section.uid() })) + + return if (sections.isEmpty()) { + getSingleSectionRowDefinitions( + programStageUid = programStageUid, + rows = followUpRows, + ) + } else { + sections.mapNotNull { section -> + val sectionDataElementUids = + section + .dataElements() + .orEmpty() + .mapNotNull { dataElement -> dataElement.uid() } + .toSet() + val rows = followUpRows.filter { row -> row.dataElementUid in sectionDataElementUids } + + rows + .takeIf { it.isNotEmpty() } + ?.let { + HistoryTableSectionDefinition( + title = section.displayName() ?: section.uid(), + rows = it, + ) + } + } + } + } + + private fun getSingleSectionRowDefinitions( + programStageUid: String, + rows: List, + ): List { + val programStage = + d2 + .programModule() + .programStages() + .uid(programStageUid) + .blockingGet() + + return rows + .takeIf { it.isNotEmpty() } + ?.let { + listOf( + HistoryTableSectionDefinition( + title = programStage?.displayName() ?: programStageUid, + rows = it, + ), + ) + }.orEmpty() + } + + private fun ProgramStageDataElement.toRowDefinition(excludedDataElementIds: Set): HistoryTableRowDefinition? { + val uid = dataElement()?.uid() ?: return null + if (uid in excludedDataElementIds) { + return null + } + + val dataElement = + d2 + .dataElementModule() + .dataElements() + .uid(uid) + .blockingGet() ?: return null + + return HistoryTableRowDefinition( + dataElementUid = uid, + label = + dataElement.displayShortName() + ?: dataElement.shortName() + ?: dataElement.displayFormName() + ?: dataElement.displayName() + ?: uid, + valueType = dataElement.valueType(), + optionSetUid = dataElement.optionSetUid(), + ) + } + + private fun getHistoryEvents( + tableContext: HistoryTableContext, + followUpVisitProgramStageUid: String, + ): List { + val currentEventDate = tableContext.currentEvent?.displayDate() + val queriedEvents = + if (tableContext.enrollmentUid.isNullOrBlank()) { + emptyList() + } else { + d2 + .eventModule() + .events() + .withTrackedEntityDataValues() + .byEnrollmentUid() + .eq(tableContext.enrollmentUid) + .byProgramStageUid() + .eq(followUpVisitProgramStageUid) + .blockingGet() + } + val events = + (queriedEvents + listOfNotNull(tableContext.currentEvent)) + .associateBy { event -> event.uid() } + .values + + return events + .filter { event -> + val eventDate = event.displayDate() + event.uid() == tableContext.currentEvent?.uid() || + currentEventDate == null || + eventDate == null || + !eventDate.after(currentEventDate) + }.sortedWith( + compareBy( + { event -> event.displayDate() ?: Date(0) }, + { event -> event.uid() }, + ), + ) + } + + private fun getEventsByVisitNumberColumn( + events: List, + eventDataValuesByUid: Map>, + headerVisitNumberDataElementUid: String, + followUpVisitMaxNumber: Int, + ): Map = + events + .mapNotNull { event -> + eventDataValuesByUid[event.uid()] + ?.get(headerVisitNumberDataElementUid) + ?.toVisitNumber() + ?.takeIf { it in 0..followUpVisitMaxNumber } + ?.let { visitNumber -> visitNumber to event } + }.toMap() + + private fun Event.dataValuesByDataElement(): Map = + trackedEntityDataValues() + .orEmpty() + .mapNotNull { dataValue -> + val dataElementUid = dataValue.dataElement() + val value = dataValue.value() + if (dataElementUid == null || value == null) { + null + } else { + dataElementUid to value + } + }.toMap() + + private fun getOptionDisplayNamesBySet(rowDefinitions: List): Map> = + rowDefinitions + .flatMap { it.rows } + .mapNotNull { row -> + row.optionSetUid?.takeIf { row.valueType != ValueType.MULTI_TEXT } + }.distinct() + .associateWith(::getOptionDisplayNames) + + private fun getOptionDisplayNames(optionSetUid: String): Map = + d2 + .optionModule() + .options() + .byOptionSetUid() + .eq(optionSetUid) + .blockingGet() + .flatMap { option -> + val displayName = option.displayName() ?: option.name() ?: option.code().orEmpty() + listOfNotNull( + option.code()?.let { it to displayName }, + option.displayName()?.let { it to displayName }, + option.name()?.let { it to displayName }, + ) + }.toMap() + + private fun HistoryTableRowDefinition.displayValue( + rawValue: String, + optionDisplayNamesBySet: Map>, + ): String { + if (rawValue.isEmpty()) { + return rawValue + } + + optionSetUid?.takeIf { valueType != ValueType.MULTI_TEXT }?.let { optionSetUid -> + return optionDisplayNamesBySet[optionSetUid]?.get(rawValue) ?: rawValue + } + + return rawValue.userFriendlyValue( + d2 = d2, + valueType = valueType, + optionSetUid = optionSetUid, + addPercentageSymbol = false, + ) ?: rawValue + } + + private fun Event?.displayDate(): Date? = this?.eventDate() ?: this?.dueDate() ?: this?.created() + + private fun Date?.toHistoryTableDateLabel(): String = + this + ?.let { SimpleDateFormat(HISTORY_TABLE_DATE_LABEL_FORMAT, Locale.getDefault()).format(it) } + .orEmpty() + + private fun String?.trimToValue(): String? = this?.trim()?.takeIf { it.isNotEmpty() } + + private fun String.toVisitNumber(): Int? { + val number = trim().toDoubleOrNull() ?: return null + val integer = number.toInt() + return integer.takeIf { it.toDouble() == number } + } + + private data class HistoryTableSectionDefinition( + val title: String, + val rows: List, + ) + + private data class HistoryTableRowDefinition( + val dataElementUid: String, + val label: String, + val valueType: ValueType?, + val optionSetUid: String?, + ) + + private data class HistoryTableContext( + val config: ProgramStageHistoryTableConfig, + val enrollmentUid: String?, + val currentEvent: Event?, + ) + + private companion object { + private const val HISTORY_TABLE_DATE_LABEL_FORMAT = "MMM d" + } +} diff --git a/app/src/main/java/org/dhis2/simprints/ramp/data/GetEventHistoryTableUseCase.kt b/app/src/main/java/org/dhis2/simprints/ramp/data/GetEventHistoryTableUseCase.kt new file mode 100644 index 00000000000..8421df6489e --- /dev/null +++ b/app/src/main/java/org/dhis2/simprints/ramp/data/GetEventHistoryTableUseCase.kt @@ -0,0 +1,14 @@ +package org.dhis2.simprints.ramp.data + +import org.dhis2.simprints.ramp.model.EventHistoryTable + +class GetEventHistoryTableUseCase( + private val repository: EventHistoryTableRepository, +) { + operator fun invoke(): Result = + try { + Result.success(repository.getTable()) + } catch (exception: Exception) { + Result.failure(exception) + } +} diff --git a/app/src/main/java/org/dhis2/simprints/ramp/di/EventHistoryTableComponent.kt b/app/src/main/java/org/dhis2/simprints/ramp/di/EventHistoryTableComponent.kt new file mode 100644 index 00000000000..b12a0beb259 --- /dev/null +++ b/app/src/main/java/org/dhis2/simprints/ramp/di/EventHistoryTableComponent.kt @@ -0,0 +1,11 @@ +package org.dhis2.simprints.ramp.di + +import dagger.Subcomponent +import org.dhis2.commons.di.dagger.PerFragment +import org.dhis2.simprints.ramp.ui.EventHistoryTableFragment + +@PerFragment +@Subcomponent(modules = [EventHistoryTableModule::class]) +interface EventHistoryTableComponent { + fun inject(fragment: EventHistoryTableFragment) +} diff --git a/app/src/main/java/org/dhis2/simprints/ramp/di/EventHistoryTableModule.kt b/app/src/main/java/org/dhis2/simprints/ramp/di/EventHistoryTableModule.kt new file mode 100644 index 00000000000..728c0400364 --- /dev/null +++ b/app/src/main/java/org/dhis2/simprints/ramp/di/EventHistoryTableModule.kt @@ -0,0 +1,52 @@ +package org.dhis2.simprints.ramp.di + +import dagger.Module +import dagger.Provides +import org.dhis2.commons.di.dagger.PerFragment +import org.dhis2.commons.simprints.ramp.repository.RampDatastoreRepository +import org.dhis2.commons.viewmodel.DispatcherProvider +import org.dhis2.simprints.ramp.data.EventHistoryTableRepository +import org.dhis2.simprints.ramp.data.GetEventHistoryTableUseCase +import org.dhis2.simprints.ramp.ui.EventHistoryTableViewModelFactory +import org.hisp.dhis.android.core.D2 + +@Module +class EventHistoryTableModule( + private val eventUid: String? = null, + private val programUid: String? = null, + private val enrollmentUid: String? = null, +) { + @Provides + @PerFragment + fun provideSimprintsRampDatastoreRepository(d2: D2): RampDatastoreRepository = RampDatastoreRepository(d2) + + @Provides + @PerFragment + fun provideRepository( + d2: D2, + simprintsRampDatastoreRepository: RampDatastoreRepository, + ): EventHistoryTableRepository = + EventHistoryTableRepository( + d2 = d2, + simprintsRampDatastoreRepository = simprintsRampDatastoreRepository, + eventUid = eventUid, + programUid = programUid, + enrollmentUid = enrollmentUid, + ) + + @Provides + @PerFragment + fun provideGetEventHistoryTableUseCase(repository: EventHistoryTableRepository): GetEventHistoryTableUseCase = + GetEventHistoryTableUseCase(repository) + + @Provides + @PerFragment + fun provideViewModelFactory( + getEventHistoryTable: GetEventHistoryTableUseCase, + dispatcherProvider: DispatcherProvider, + ): EventHistoryTableViewModelFactory = + EventHistoryTableViewModelFactory( + getEventHistoryTable = getEventHistoryTable, + dispatcherProvider = dispatcherProvider, + ) +} diff --git a/app/src/main/java/org/dhis2/simprints/ramp/model/EventHistoryTableModels.kt b/app/src/main/java/org/dhis2/simprints/ramp/model/EventHistoryTableModels.kt new file mode 100644 index 00000000000..4140e8ec68b --- /dev/null +++ b/app/src/main/java/org/dhis2/simprints/ramp/model/EventHistoryTableModels.kt @@ -0,0 +1,36 @@ +package org.dhis2.simprints.ramp.model + +sealed interface EventHistoryTableUiState { + data object Loading : EventHistoryTableUiState + + data class Success( + val table: EventHistoryTable, + ) : EventHistoryTableUiState + + data object Empty : EventHistoryTableUiState + + data class Error( + val message: String? = null, + ) : EventHistoryTableUiState +} + +data class EventHistoryTable( + val columns: List, + val sections: List, + val dateRowValues: List = emptyList(), +) + +data class EventHistoryTableColumn( + val eventUid: String?, + val label: String, +) + +data class EventHistoryTableSection( + val title: String, + val rows: List, +) + +data class EventHistoryTableRow( + val label: String, + val values: List, +) diff --git a/app/src/main/java/org/dhis2/simprints/ramp/ui/EventHistoryTableFragment.kt b/app/src/main/java/org/dhis2/simprints/ramp/ui/EventHistoryTableFragment.kt new file mode 100644 index 00000000000..917e8e1047c --- /dev/null +++ b/app/src/main/java/org/dhis2/simprints/ramp/ui/EventHistoryTableFragment.kt @@ -0,0 +1,105 @@ +package org.dhis2.simprints.ramp.ui + +import android.content.Context +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.fragment.app.viewModels +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import org.dhis2.bindings.app +import org.dhis2.commons.Constants +import org.dhis2.simprints.ramp.di.EventHistoryTableModule +import org.dhis2.usescases.eventsWithoutRegistration.eventCapture.EventCaptureActivity +import org.dhis2.usescases.general.FragmentGlobalAbstract +import org.dhis2.usescases.teiDashboard.TeiDashboardMobileActivity +import org.hisp.dhis.mobile.ui.designsystem.theme.DHIS2Theme +import javax.inject.Inject + +class EventHistoryTableFragment : FragmentGlobalAbstract() { + @Inject + lateinit var viewModelFactory: EventHistoryTableViewModelFactory + + private val viewModel: EventHistoryTableViewModel by viewModels { viewModelFactory } + private var hasResumed = false + + override fun onAttach(context: Context) { + super.onAttach(context) + when (context) { + is EventCaptureActivity -> + context.eventCaptureComponent + ?.plus( + EventHistoryTableModule( + eventUid = requireArguments().getString(Constants.EVENT_UID).orEmpty(), + ), + )?.inject(this) + + is TeiDashboardMobileActivity -> + context + .app() + .dashboardComponent() + ?.plus( + EventHistoryTableModule( + programUid = requireArguments().getString(Constants.PROGRAM_UID).orEmpty(), + enrollmentUid = requireArguments().getString(Constants.ENROLLMENT_UID).orEmpty(), + ), + )?.inject(this) + + else -> error("EventHistoryTableFragment must be attached to a supported activity") + } + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View = + ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + DHIS2Theme { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + EventHistoryTableScreen( + modifier = Modifier, + state = uiState, + ) + } + } + } + + override fun onResume() { + super.onResume() + if (hasResumed) { + viewModel.load() + } else { + hasResumed = true + } + } + + companion object { + fun newInstance(eventUid: String): EventHistoryTableFragment = + EventHistoryTableFragment().apply { + arguments = + Bundle().apply { + putString(Constants.EVENT_UID, eventUid) + } + } + + fun newEnrollmentInstance( + programUid: String, + enrollmentUid: String, + ): EventHistoryTableFragment = + EventHistoryTableFragment().apply { + arguments = + Bundle().apply { + putString(Constants.PROGRAM_UID, programUid) + putString(Constants.ENROLLMENT_UID, enrollmentUid) + } + } + } +} 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 new file mode 100644 index 00000000000..6503c46c6fb --- /dev/null +++ b/app/src/main/java/org/dhis2/simprints/ramp/ui/EventHistoryTableScreen.kt @@ -0,0 +1,303 @@ +package org.dhis2.simprints.ramp.ui + +import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import org.dhis2.R +import org.dhis2.simprints.ramp.model.EventHistoryTable +import org.dhis2.simprints.ramp.model.EventHistoryTableColumn +import org.dhis2.simprints.ramp.model.EventHistoryTableRow +import org.dhis2.simprints.ramp.model.EventHistoryTableUiState +import org.hisp.dhis.mobile.ui.designsystem.component.ProgressIndicator +import org.hisp.dhis.mobile.ui.designsystem.component.ProgressIndicatorType +import org.hisp.dhis.mobile.ui.designsystem.theme.Spacing + +@Composable +fun EventHistoryTableScreen( + state: EventHistoryTableUiState, + modifier: Modifier = Modifier, +) { + when (state) { + EventHistoryTableUiState.Loading -> + Box( + modifier = modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + ProgressIndicator(type = ProgressIndicatorType.CIRCULAR) + } + + EventHistoryTableUiState.Empty -> + EventHistoryTableMessage( + modifier = modifier, + message = stringResource(R.string.simprints_ramp_history_table_empty), + ) + + is EventHistoryTableUiState.Error -> + EventHistoryTableMessage( + modifier = modifier, + message = state.message ?: stringResource(R.string.error_unexpected_error), + ) + + is EventHistoryTableUiState.Success -> + HistoryTable( + table = state.table, + modifier = modifier, + ) + } +} + +@Composable +private fun HistoryTable( + table: EventHistoryTable, + modifier: Modifier = Modifier, +) { + val horizontalScrollState = rememberScrollState() + + Column( + modifier = + modifier + .fillMaxSize() + .background(colorResource(id = R.color.white)), + ) { + VisitHeaderRow( + columns = table.columns, + horizontalScrollState = horizontalScrollState, + ) + Column( + modifier = + Modifier + .weight(1f) + .verticalScroll(rememberScrollState()) + .padding(bottom = Spacing.Spacing16), + ) { + if (table.dateRowValues.isNotEmpty()) { + TableDataRow( + row = + EventHistoryTableRow( + label = stringResource(R.string.simprints_ramp_history_table_date), + values = table.dateRowValues, + ), + horizontalScrollState = horizontalScrollState, + ) + } + table.sections.forEach { section -> + SectionHeader(title = section.title) + section.rows.forEach { row -> + TableDataRow( + row = row, + horizontalScrollState = horizontalScrollState, + ) + } + } + } + } +} + +@Composable +private fun VisitHeaderRow( + columns: List, + horizontalScrollState: ScrollState, +) { + Row(modifier = Modifier.heightIntrinsicRow()) { + HeaderCell( + text = stringResource(R.string.simprints_ramp_history_table_visit), + width = RowHeaderWidth, + textAlign = TextAlign.Start, + ) + Row( + modifier = + Modifier.horizontalScroll( + state = horizontalScrollState, + overscrollEffect = null, + ), + ) { + columns.forEach { column -> + HeaderCell( + text = column.label, + width = DataCellWidth, + textAlign = TextAlign.Center, + ) + } + } + } +} + +@Composable +private fun SectionHeader(title: String) { + Box( + modifier = + Modifier + .fillMaxWidth() + .background(colorResource(id = R.color.bg_gray_f1f)) + .border(BorderWidth, colorResource(id = R.color.divider_bg)) + .padding(horizontal = Spacing.Spacing16, vertical = Spacing.Spacing8), + ) { + Text( + text = title, + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold, + color = colorResource(id = R.color.blue_fab), + ) + } +} + +@Composable +private fun TableDataRow( + row: EventHistoryTableRow, + horizontalScrollState: ScrollState, +) { + Row(modifier = Modifier.heightIntrinsicRow()) { + LabelCell( + text = row.label, + width = RowHeaderWidth, + ) + Row( + modifier = + Modifier.horizontalScroll( + state = horizontalScrollState, + overscrollEffect = null, + ), + ) { + row.values.forEach { value -> + ValueCell( + text = value, + width = DataCellWidth, + ) + } + } + } +} + +@Composable +private fun HeaderCell( + text: String, + width: Dp, + textAlign: TextAlign, +) { + Box( + modifier = + Modifier + .width(width) + .fillMaxHeight() + .defaultMinSize(minHeight = CellMinHeight) + .background(colorResource(id = R.color.gray_f5f5)) + .border(BorderWidth, colorResource(id = R.color.divider_bg)) + .padding(horizontal = Spacing.Spacing8, vertical = Spacing.Spacing8), + contentAlignment = Alignment.Center, + ) { + Text( + text = text, + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, + textAlign = textAlign, + color = colorResource(id = R.color.text_black_333), + ) + } +} + +@Composable +private fun LabelCell( + text: String, + width: Dp, +) { + Box( + modifier = + Modifier + .width(width) + .fillMaxHeight() + .defaultMinSize(minHeight = CellMinHeight) + .background(colorResource(id = R.color.form_field_background)) + .border(BorderWidth, colorResource(id = R.color.divider_bg)) + .padding(horizontal = Spacing.Spacing8, vertical = Spacing.Spacing8), + contentAlignment = Alignment.CenterStart, + ) { + Text( + text = text, + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Medium, + color = colorResource(id = R.color.text_black_333), + ) + } +} + +@Composable +private fun ValueCell( + text: String, + width: Dp, +) { + Box( + modifier = + Modifier + .width(width) + .fillMaxHeight() + .defaultMinSize(minHeight = CellMinHeight) + .background(colorResource(id = R.color.white)) + .border(BorderWidth, colorResource(id = R.color.divider_bg)) + .padding(horizontal = Spacing.Spacing8, vertical = Spacing.Spacing8), + contentAlignment = Alignment.Center, + ) { + Text( + text = text, + style = MaterialTheme.typography.bodySmall, + textAlign = TextAlign.Center, + color = colorResource(id = R.color.text_black_333), + ) + } +} + +@Composable +private fun EventHistoryTableMessage( + message: String, + modifier: Modifier = Modifier, +) { + Box( + modifier = + modifier + .fillMaxSize() + .background(colorResource(id = R.color.white)) + .padding(Spacing.Spacing24), + contentAlignment = Alignment.Center, + ) { + Text( + text = message, + style = MaterialTheme.typography.bodyMedium, + color = colorResource(id = R.color.text_black_333), + textAlign = TextAlign.Center, + ) + } +} + +private fun Modifier.heightIntrinsicRow(): Modifier = + this + .fillMaxWidth() + .height(IntrinsicSize.Min) + +private val RowHeaderWidth = 168.dp +private val DataCellWidth = 88.dp +private val CellMinHeight = 48.dp +private val BorderWidth = 0.5.dp diff --git a/app/src/main/java/org/dhis2/simprints/ramp/ui/EventHistoryTableViewModel.kt b/app/src/main/java/org/dhis2/simprints/ramp/ui/EventHistoryTableViewModel.kt new file mode 100644 index 00000000000..72970361622 --- /dev/null +++ b/app/src/main/java/org/dhis2/simprints/ramp/ui/EventHistoryTableViewModel.kt @@ -0,0 +1,43 @@ +package org.dhis2.simprints.ramp.ui + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import org.dhis2.commons.viewmodel.DispatcherProvider +import org.dhis2.simprints.ramp.data.GetEventHistoryTableUseCase +import org.dhis2.simprints.ramp.model.EventHistoryTableUiState + +class EventHistoryTableViewModel( + private val getEventHistoryTable: GetEventHistoryTableUseCase, + private val dispatcherProvider: DispatcherProvider, +) : ViewModel() { + private val _uiState = MutableStateFlow(EventHistoryTableUiState.Loading) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + load() + } + + fun load() { + viewModelScope.launch(dispatcherProvider.io()) { + if (_uiState.value !is EventHistoryTableUiState.Success) { + _uiState.value = EventHistoryTableUiState.Loading + } + _uiState.value = + getEventHistoryTable() + .fold( + onSuccess = { table -> + table + ?.let(EventHistoryTableUiState::Success) + ?: EventHistoryTableUiState.Empty + }, + onFailure = { error -> + EventHistoryTableUiState.Error(error.message) + }, + ) + } + } +} diff --git a/app/src/main/java/org/dhis2/simprints/ramp/ui/EventHistoryTableViewModelFactory.kt b/app/src/main/java/org/dhis2/simprints/ramp/ui/EventHistoryTableViewModelFactory.kt new file mode 100644 index 00000000000..e756fe742c7 --- /dev/null +++ b/app/src/main/java/org/dhis2/simprints/ramp/ui/EventHistoryTableViewModelFactory.kt @@ -0,0 +1,18 @@ +package org.dhis2.simprints.ramp.ui + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import org.dhis2.commons.viewmodel.DispatcherProvider +import org.dhis2.simprints.ramp.data.GetEventHistoryTableUseCase + +class EventHistoryTableViewModelFactory( + private val getEventHistoryTable: GetEventHistoryTableUseCase, + private val dispatcherProvider: DispatcherProvider, +) : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T = + EventHistoryTableViewModel( + getEventHistoryTable = getEventHistoryTable, + dispatcherProvider = dispatcherProvider, + ) as T +} diff --git a/app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventCapture/EventCaptureActivity.kt b/app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventCapture/EventCaptureActivity.kt index 1341577322f..91a450ffd7f 100644 --- a/app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventCapture/EventCaptureActivity.kt +++ b/app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventCapture/EventCaptureActivity.kt @@ -21,6 +21,8 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.constraintlayout.widget.ConstraintSet import androidx.databinding.DataBindingUtil import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData @@ -112,6 +114,7 @@ class EventCaptureActivity : private var adapter: EventCapturePagerAdapter? = null private var eventViewPager: ViewPager2? = null private var dashboardViewModel: DashboardViewModel? = null + private var isSimprintsRampHistoryTableLandscapeFullscreen = false override fun onCreate(savedInstanceState: Bundle?) { eventUid = intent.getStringExtra(Constants.EVENT_UID) @@ -165,6 +168,7 @@ class EventCaptureActivity : intent.getStringExtra(Constants.PROGRAM_UID) ?: "", intent.getStringExtra(Constants.EVENT_UID) ?: "", pageConfigurator!!.displayAnalytics(), + pageConfigurator!!.displayTableView(), pageConfigurator!!.displayRelationships(), intent.getBooleanExtra(OPEN_ERROR_LOCATION, false), eventMode, @@ -174,12 +178,12 @@ class EventCaptureActivity : object : OnPageChangeCallback() { override fun onPageSelected(position: Int) { super.onPageSelected(position) - if (position == 0 && eventMode !== EventMode.NEW) { + if (adapter?.isFormScreenShown(position) == true && eventMode !== EventMode.NEW) { binding.syncButton.visibility = View.VISIBLE } else { binding.syncButton.visibility = View.GONE } - if (position != 1) { + if (adapter?.isAnalyticsScreenShown(position) != true) { hideProgress() } } @@ -192,7 +196,9 @@ class EventCaptureActivity : object : OnPageChangeCallback() { override fun onPageSelected(position: Int) { super.onPageSelected(position) - presenter.onSetNavigationPage(position) + adapter + ?.getNavigationPage(position) + ?.let(presenter::onNavigationPageChanged) } }, ) @@ -217,14 +223,82 @@ class EventCaptureActivity : items = uiState.items, selectedItemIndex = selectedItemIndex, ) { page -> - presenter.onNavigationPageChanged(page) - eventViewPager?.currentItem = adapter!!.getDynamicTabIndex(page) + onNavigationPageSelected(page) } } } } } + private fun onNavigationPageSelected(page: NavigationPage) { + if (this.isLandscape()) { + when (page) { + NavigationPage.TABLE_VIEW -> { + presenter.onNavigationPageChanged(page) + presenter.setForceDisplayDataEntryNavigationItemForSimprintsRampTable(true) + setSimprintsRampHistoryTableLandscapeFullscreen(true) + selectPagerPage(page) + } + + NavigationPage.DATA_ENTRY -> { + restoreSimprintsRampHistoryTableLandscapeLayoutAndSelectDefaultPage() + } + + else -> { + setSimprintsRampHistoryTableLandscapeFullscreen(false) + presenter.setForceDisplayDataEntryNavigationItemForSimprintsRampTable(false) + selectPagerPage(page) + presenter.onNavigationPageChanged(page) + } + } + } else { + presenter.onNavigationPageChanged(page) + selectPagerPage(page) + } + } + + private fun selectPagerPage(page: NavigationPage) { + val tabIndex = adapter?.getDynamicTabIndex(page) ?: EventCapturePagerAdapter.NO_POSITION + if (tabIndex != EventCapturePagerAdapter.NO_POSITION) { + eventViewPager?.setCurrentItem(tabIndex, false) + } + } + + private fun restoreSimprintsRampHistoryTableLandscapeLayoutAndSelectDefaultPage() { + setSimprintsRampHistoryTableLandscapeFullscreen(false) + presenter.setForceDisplayDataEntryNavigationItemForSimprintsRampTable(false) + adapter + ?.defaultLandscapeNavigationPage() + ?.let { defaultPage -> + selectPagerPage(defaultPage) + presenter.onNavigationPageChanged(defaultPage) + } + } + + private fun setSimprintsRampHistoryTableLandscapeFullscreen(enabled: Boolean) { + if (!this.isLandscape() || isSimprintsRampHistoryTableLandscapeFullscreen == enabled) { + return + } + + val layoutContainer = findViewById(R.id.layoutContainer) ?: return + ConstraintSet() + .apply { + clone(layoutContainer) + setVisibility(R.id.tei_column, if (enabled) View.GONE else View.VISIBLE) + setVisibility(R.id.form_column, if (enabled) View.GONE else View.VISIBLE) + clear(R.id.stats_column, ConstraintSet.START) + connect( + R.id.stats_column, + ConstraintSet.START, + if (enabled) ConstraintSet.PARENT_ID else R.id.guideline625, + if (enabled) ConstraintSet.START else ConstraintSet.END, + ) + }.applyTo(layoutContainer) + + isSimprintsRampHistoryTableLandscapeFullscreen = enabled + eventViewPager?.post { eventViewPager?.requestLayout() } + } + private fun setUpEventCaptureFormLandscape(eventUid: String) { if (this.isLandscape()) { supportFragmentManager @@ -250,6 +324,7 @@ class EventCaptureActivity : private fun updateLandscapeViewsOnEventChange(newEventUid: String) { if (newEventUid != this.eventUid) { + setSimprintsRampHistoryTableLandscapeFullscreen(false) this.eventUid = newEventUid setUpEventCaptureComponent(newEventUid) setUpViewPagerAdapter() diff --git a/app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventCapture/EventCaptureComponent.java b/app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventCapture/EventCaptureComponent.java index 7dd3bbd7223..f3a57471819 100644 --- a/app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventCapture/EventCaptureComponent.java +++ b/app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventCapture/EventCaptureComponent.java @@ -3,6 +3,8 @@ import org.dhis2.commons.di.dagger.PerActivity; import org.dhis2.usescases.eventsWithoutRegistration.eventCapture.eventCaptureFragment.EventCaptureFormComponent; import org.dhis2.usescases.eventsWithoutRegistration.eventCapture.eventCaptureFragment.EventCaptureFormModule; +import org.dhis2.simprints.ramp.di.EventHistoryTableComponent; +import org.dhis2.simprints.ramp.di.EventHistoryTableModule; import org.dhis2.usescases.eventsWithoutRegistration.eventDetails.injection.EventDetailsComponent; import org.dhis2.usescases.eventsWithoutRegistration.eventDetails.injection.EventDetailsModule; import org.dhis2.usescases.teiDashboard.dashboardfragments.indicators.IndicatorsComponent; @@ -20,4 +22,6 @@ public interface EventCaptureComponent { IndicatorsComponent plus(IndicatorsModule indicatorsModule); EventDetailsComponent plus(EventDetailsModule eventDetailsModule); + + EventHistoryTableComponent plus(EventHistoryTableModule simprintsRampHistoryTableModule); } diff --git a/app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventCapture/EventCaptureContract.kt b/app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventCapture/EventCaptureContract.kt index ac344e9bf8f..f7bd7885c37 100644 --- a/app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventCapture/EventCaptureContract.kt +++ b/app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventCapture/EventCaptureContract.kt @@ -95,6 +95,8 @@ class EventCaptureContract { fun isDataEntrySelected(): Boolean fun updateNotesBadge(numberOfNotes: Int) + + fun setForceDisplayDataEntryNavigationItemForSimprintsRampTable(forceDisplay: Boolean) } interface EventCaptureRepository { @@ -133,6 +135,8 @@ class EventCaptureContract { fun hasRelationships(): Boolean + fun hasSimprintsRampProgramStageHistoryTable(): Boolean + fun validationStrategy(): ValidationStrategy fun getTeiUid(): String? diff --git a/app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventCapture/EventCapturePagerAdapter.kt b/app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventCapture/EventCapturePagerAdapter.kt index 810b1550e3a..6c3e60f8a32 100644 --- a/app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventCapture/EventCapturePagerAdapter.kt +++ b/app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventCapture/EventCapturePagerAdapter.kt @@ -5,6 +5,7 @@ import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentActivity import androidx.viewpager2.adapter.FragmentStateAdapter import org.dhis2.form.model.EventMode +import org.dhis2.simprints.ramp.ui.EventHistoryTableFragment import org.dhis2.usescases.eventsWithoutRegistration.eventCapture.eventCaptureFragment.EventCaptureFormFragment import org.dhis2.usescases.notes.NotesFragment.Companion.newEventInstance import org.dhis2.usescases.teiDashboard.dashboardfragments.indicators.IndicatorsFragment @@ -19,6 +20,7 @@ class EventCapturePagerAdapter( private val programUid: String, private val eventUid: String, displayAnalyticScreen: Boolean, + displaySimprintsRampHistoryTableScreen: Boolean, displayRelationshipScreen: Boolean, private val shouldOpenErrorSection: Boolean, private val eventMode: EventMode, @@ -26,10 +28,19 @@ class EventCapturePagerAdapter( private val landscapePages: MutableList = ArrayList() private val portraitPages: MutableList = ArrayList() - fun isFormScreenShown(currentItem: Int?): Boolean = currentItem != null && portraitPages[currentItem] == EventPageType.DATA_ENTRY + fun isFormScreenShown(currentItem: Int?): Boolean = + currentItem != null && + currentItem in activePages.indices && + activePages[currentItem] == EventPageType.DATA_ENTRY + + fun isAnalyticsScreenShown(currentItem: Int?): Boolean = + currentItem != null && + currentItem in activePages.indices && + activePages[currentItem] == EventPageType.ANALYTICS private enum class EventPageType { DATA_ENTRY, + SIMPRINTS_RAMP_HISTORY_TABLE, ANALYTICS, RELATIONSHIPS, NOTES, @@ -50,6 +61,11 @@ class EventCapturePagerAdapter( } portraitPages.add(EventPageType.NOTES) landscapePages.add(EventPageType.NOTES) + + if (displaySimprintsRampHistoryTableScreen) { + portraitPages.add(EventPageType.SIMPRINTS_RAMP_HISTORY_TABLE) + landscapePages.add(EventPageType.SIMPRINTS_RAMP_HISTORY_TABLE) + } } override fun createFragment(position: Int): Fragment = @@ -83,7 +99,11 @@ class EventCapturePagerAdapter( newEventInstance(programUid, eventUid) } - else -> { + EventPageType.SIMPRINTS_RAMP_HISTORY_TABLE -> { + EventHistoryTableFragment.newInstance(eventUid) + } + + EventPageType.DATA_ENTRY -> { EventCaptureFormFragment.newInstance( eventUid, shouldOpenErrorSection, @@ -92,9 +112,22 @@ class EventCapturePagerAdapter( } } + fun getNavigationPage(position: Int): NavigationPage? = + activePages + .getOrNull(position) + ?.toNavigationPage() + + fun defaultLandscapeNavigationPage(): NavigationPage? = + landscapePages + .firstOrNull { it != EventPageType.SIMPRINTS_RAMP_HISTORY_TABLE } + ?.toNavigationPage() + ?: landscapePages.firstOrNull()?.toNavigationPage() + fun getDynamicTabIndex(navigationPage: NavigationPage?): Int { val pageType = when (navigationPage) { + NavigationPage.DATA_ENTRY -> EventPageType.DATA_ENTRY + NavigationPage.TABLE_VIEW -> EventPageType.SIMPRINTS_RAMP_HISTORY_TABLE NavigationPage.ANALYTICS -> EventPageType.ANALYTICS NavigationPage.RELATIONSHIPS -> EventPageType.RELATIONSHIPS NavigationPage.NOTES -> EventPageType.NOTES @@ -112,6 +145,15 @@ class EventCapturePagerAdapter( } } + private fun EventPageType.toNavigationPage(): NavigationPage = + when (this) { + EventPageType.DATA_ENTRY -> NavigationPage.DATA_ENTRY + EventPageType.SIMPRINTS_RAMP_HISTORY_TABLE -> NavigationPage.TABLE_VIEW + EventPageType.ANALYTICS -> NavigationPage.ANALYTICS + EventPageType.RELATIONSHIPS -> NavigationPage.RELATIONSHIPS + EventPageType.NOTES -> NavigationPage.NOTES + } + override fun getItemCount(): Int = if (isPortrait) { portraitPages.size @@ -122,6 +164,9 @@ class EventCapturePagerAdapter( val isPortrait: Boolean get() = fragmentActivity.resources.configuration.orientation == 1 + private val activePages: List + get() = if (isPortrait) portraitPages else landscapePages + companion object { const val NO_POSITION: Int = -1 } diff --git a/app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventCapture/EventCapturePresenterImpl.kt b/app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventCapture/EventCapturePresenterImpl.kt index d04654d06c8..81a1ce393a6 100644 --- a/app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventCapture/EventCapturePresenterImpl.kt +++ b/app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventCapture/EventCapturePresenterImpl.kt @@ -5,8 +5,10 @@ import androidx.compose.material.icons.automirrored.filled.StickyNote2 import androidx.compose.material.icons.automirrored.outlined.StickyNote2 import androidx.compose.material.icons.filled.BarChart import androidx.compose.material.icons.filled.Hub +import androidx.compose.material.icons.filled.TableChart import androidx.compose.material.icons.outlined.BarChart import androidx.compose.material.icons.outlined.Hub +import androidx.compose.material.icons.outlined.TableChart import androidx.compose.runtime.State import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.LiveData @@ -51,6 +53,7 @@ class EventCapturePresenterImpl( var compositeDisposable: CompositeDisposable = CompositeDisposable() private var hasExpired = false private val notesCounterProcessor: PublishProcessor = PublishProcessor.create() + private var forceDisplayDataEntryNavigationItemForSimprintsRampTable = false val actions = MutableLiveData() @@ -105,7 +108,7 @@ class EventCapturePresenterImpl( private fun loadBottomBarItems() { val navItems = mutableListOf>() - if (pageConfigurator.displayDataEntry()) { + if (pageConfigurator.displayDataEntry() || forceDisplayDataEntryNavigationItemForSimprintsRampTable) { navItems.add( NavigationBarItem( id = NavigationPage.DATA_ENTRY, @@ -149,12 +152,30 @@ class EventCapturePresenterImpl( ) } + if (pageConfigurator.displayTableView()) { + navItems.add( + NavigationBarItem( + id = NavigationPage.TABLE_VIEW, + icon = Icons.Outlined.TableChart, + selectedIcon = Icons.Filled.TableChart, + label = resourceManager.getString(R.string.navigation_simprints_ramp_history), + ), + ) + } + navigationBarUIState.value = navigationBarUIState.value.copy( items = navItems.takeIf { it.size > 1 }.orEmpty(), ) } + override fun setForceDisplayDataEntryNavigationItemForSimprintsRampTable(forceDisplay: Boolean) { + if (forceDisplayDataEntryNavigationItemForSimprintsRampTable != forceDisplay) { + forceDisplayDataEntryNavigationItemForSimprintsRampTable = forceDisplay + loadBottomBarItems() + } + } + override fun onNavigationPageChanged(page: NavigationPage) { navigationBarUIState.value = navigationBarUIState.value.copy(selectedItem = page) } diff --git a/app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventCapture/EventCaptureRepositoryImpl.java b/app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventCapture/EventCaptureRepositoryImpl.java index 036c40507d4..dee4c552f48 100644 --- a/app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventCapture/EventCaptureRepositoryImpl.java +++ b/app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventCapture/EventCaptureRepositoryImpl.java @@ -3,7 +3,11 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import com.google.gson.Gson; + import org.dhis2.commons.bindings.SdkExtensionsKt; +import org.dhis2.commons.simprints.ramp.model.ProgramStageHistoryTableConfig; +import org.dhis2.commons.simprints.ramp.repository.RampDatastoreRepository; import org.dhis2.data.dhislogic.AuthoritiesKt; import org.hisp.dhis.android.core.D2; import org.hisp.dhis.android.core.common.BaseIdentifiableObject; @@ -32,6 +36,7 @@ public class EventCaptureRepositoryImpl implements EventCaptureContract.EventCap private final String eventUid; private final D2 d2; + private Boolean hasSimprintsRampProgramStageHistoryTable; public EventCaptureRepositoryImpl(String eventUid, D2 d2) { this.eventUid = eventUid; @@ -208,6 +213,24 @@ public boolean hasRelationships() { .blockingIsEmpty(); } + @Override + public boolean hasSimprintsRampProgramStageHistoryTable() { + if (hasSimprintsRampProgramStageHistoryTable != null) { + return hasSimprintsRampProgramStageHistoryTable; + } + + Event currentEvent = getCurrentEvent(); + hasSimprintsRampProgramStageHistoryTable = false; + for (ProgramStageHistoryTableConfig config : new RampDatastoreRepository(d2, new Gson()).getConfig().getProgramStageHistoryTables()) { + if (Objects.equals(trimToValue(config.getProgramId()), currentEvent.program()) && + Objects.equals(trimToValue(config.getFollowUpVisitProgramStageId()), currentEvent.programStage())) { + hasSimprintsRampProgramStageHistoryTable = true; + break; + } + } + return hasSimprintsRampProgramStageHistoryTable; + } + @NonNull @Override public ValidationStrategy validationStrategy() { @@ -235,5 +258,11 @@ public String getTeiUid() { Enrollment enrollment = d2.enrollmentModule().enrollments().uid(getEnrollmentUid()).blockingGet(); return enrollment != null ? enrollment.trackedEntityInstance() : null; } -} + private static String trimToValue(String value) { + if (value == null || value.trim().isEmpty()) { + return null; + } + return value.trim(); + } +} 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 bad067c2fce..4e8fd182b8b 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 @@ -15,4 +15,6 @@ class EventPageConfigurator( override fun displayRelationships(): Boolean = eventCaptureRepository.hasRelationships() override fun displayNotes(): Boolean = true + + override fun displayTableView(): Boolean = eventCaptureRepository.hasSimprintsRampProgramStageHistoryTable() } diff --git a/app/src/main/java/org/dhis2/usescases/teiDashboard/DashboardRepository.kt b/app/src/main/java/org/dhis2/usescases/teiDashboard/DashboardRepository.kt index b9a85cbdcdc..6d14f0090e9 100644 --- a/app/src/main/java/org/dhis2/usescases/teiDashboard/DashboardRepository.kt +++ b/app/src/main/java/org/dhis2/usescases/teiDashboard/DashboardRepository.kt @@ -94,6 +94,8 @@ interface DashboardRepository { fun programHasAnalytics(): Boolean + fun programHasSimprintsRampProgramStageHistoryTable(): Boolean + fun getTETypeName(): String? fun getAttributesMap( 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 db2f64169c2..ca280d77c45 100644 --- a/app/src/main/java/org/dhis2/usescases/teiDashboard/DashboardRepositoryImpl.kt +++ b/app/src/main/java/org/dhis2/usescases/teiDashboard/DashboardRepositoryImpl.kt @@ -38,6 +38,7 @@ import org.hisp.dhis.android.core.trackedentity.TrackedEntityAttributeValue import org.hisp.dhis.android.core.trackedentity.TrackedEntityInstance import org.hisp.dhis.mobile.ui.designsystem.theme.SurfaceColor import timber.log.Timber +import org.dhis2.commons.simprints.ramp.repository.RampDatastoreRepository as SimprintsRampDatastoreRepository class DashboardRepositoryImpl( private val d2: D2, @@ -813,6 +814,18 @@ class DashboardRepositoryImpl( false } + override fun programHasSimprintsRampProgramStageHistoryTable(): Boolean = + try { + !programUid.isNullOrBlank() && + !enrollmentUid.isNullOrBlank() && + SimprintsRampDatastoreRepository(d2) + .getConfig() + .programStageHistoryTables + .any { config -> config.programId?.trim() == programUid } + } catch (_: D2Error) { + false + } + override fun getGrouping(): Boolean = getGroupingOptions().getOrDefault(programUid, true) override fun setGrouping(groupEvent: Boolean) { diff --git a/app/src/main/java/org/dhis2/usescases/teiDashboard/DashboardViewModel.kt b/app/src/main/java/org/dhis2/usescases/teiDashboard/DashboardViewModel.kt index 0fb7b499c36..eb0bdf3f4b5 100644 --- a/app/src/main/java/org/dhis2/usescases/teiDashboard/DashboardViewModel.kt +++ b/app/src/main/java/org/dhis2/usescases/teiDashboard/DashboardViewModel.kt @@ -7,8 +7,10 @@ import androidx.compose.material.icons.automirrored.outlined.Assignment import androidx.compose.material.icons.automirrored.outlined.StickyNote2 import androidx.compose.material.icons.filled.BarChart import androidx.compose.material.icons.filled.Hub +import androidx.compose.material.icons.filled.TableChart import androidx.compose.material.icons.outlined.BarChart import androidx.compose.material.icons.outlined.Hub +import androidx.compose.material.icons.outlined.TableChart import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel @@ -165,6 +167,17 @@ class DashboardViewModel( ), ) + if (pageConfigurator.displayTableView()) { + enrollmentItems.add( + NavigationBarItem( + id = TEIDashboardItems.SIMPRINTS_RAMP_HISTORY_TABLE, + icon = Icons.Outlined.TableChart, + selectedIcon = Icons.Filled.TableChart, + label = resourcesManager.getString(R.string.navigation_simprints_ramp_history), + ), + ) + } + _navigationBarUIState.value = _navigationBarUIState.value.copy(items = enrollmentItems) if (navigationBarUIState.value.items.none { it.id == navigationBarUIState.value.selectedItem }) { diff --git a/app/src/main/java/org/dhis2/usescases/teiDashboard/TeiDashboardComponent.java b/app/src/main/java/org/dhis2/usescases/teiDashboard/TeiDashboardComponent.java index 76f0b54e9bd..132c7cbc871 100644 --- a/app/src/main/java/org/dhis2/usescases/teiDashboard/TeiDashboardComponent.java +++ b/app/src/main/java/org/dhis2/usescases/teiDashboard/TeiDashboardComponent.java @@ -1,6 +1,8 @@ package org.dhis2.usescases.teiDashboard; import org.dhis2.commons.di.dagger.PerActivity; +import org.dhis2.simprints.ramp.di.EventHistoryTableComponent; +import org.dhis2.simprints.ramp.di.EventHistoryTableModule; import org.dhis2.usescases.teiDashboard.dashboardfragments.indicators.IndicatorsComponent; import org.dhis2.usescases.teiDashboard.dashboardfragments.indicators.IndicatorsModule; import org.dhis2.usescases.notes.NotesComponent; @@ -26,6 +28,9 @@ public interface TeiDashboardComponent { @NonNull TEIDataComponent plus(TEIDataModule teiDataModule); + @NonNull + EventHistoryTableComponent plus(EventHistoryTableModule simprintsRampHistoryTableModule); + DashboardViewModelFactory dashboardViewModelFactory(); void inject(TeiDashboardMobileActivity mobileActivity); diff --git a/app/src/main/java/org/dhis2/usescases/teiDashboard/TeiDashboardMobileActivity.kt b/app/src/main/java/org/dhis2/usescases/teiDashboard/TeiDashboardMobileActivity.kt index aeb40312665..a0d604f464a 100644 --- a/app/src/main/java/org/dhis2/usescases/teiDashboard/TeiDashboardMobileActivity.kt +++ b/app/src/main/java/org/dhis2/usescases/teiDashboard/TeiDashboardMobileActivity.kt @@ -20,6 +20,8 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.text.style.TextAlign +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.constraintlayout.widget.ConstraintSet import androidx.core.content.ContextCompat import androidx.core.view.ViewCompat import androidx.databinding.DataBindingUtil @@ -47,6 +49,7 @@ import org.dhis2.commons.sync.SyncContext import org.dhis2.databinding.ActivityDashboardMobileBinding import org.dhis2.form.model.EnrollmentMode import org.dhis2.mobile.commons.orgunit.OrgUnitSelectorScope +import org.dhis2.simprints.ramp.ui.EventHistoryTableFragment import org.dhis2.tracker.TEIDashboardItems import org.dhis2.tracker.relationships.ui.state.RelationshipTopBarIconState import org.dhis2.ui.ThemeManager @@ -133,6 +136,7 @@ class TeiDashboardMobileActivity : private var elevation = 0f private var restartingActivity = false + private var isSimprintsRampHistoryTableLandscapeFullscreen = false private val detailsLauncher = registerForActivityResult( @@ -360,6 +364,8 @@ class TeiDashboardMobileActivity : } private fun navigateToFragment(item: TEIDashboardItems) { + setSimprintsRampHistoryTableLandscapeFullscreen(item == TEIDashboardItems.SIMPRINTS_RAMP_HISTORY_TABLE) + val fragment = when (item) { TEIDashboardItems.DETAILS -> @@ -396,6 +402,16 @@ class TeiDashboardMobileActivity : presenter.trackDashboardNotes() NotesFragment.newTrackerInstance(programUid!!, teiUid!!) } + + TEIDashboardItems.SIMPRINTS_RAMP_HISTORY_TABLE -> { + val simprintsRampProgramUid = programUid ?: return + val simprintsRampEnrollmentUid = enrollmentUid ?: return + + EventHistoryTableFragment.newEnrollmentInstance( + simprintsRampProgramUid, + simprintsRampEnrollmentUid, + ) + } } supportFragmentManager @@ -406,6 +422,40 @@ class TeiDashboardMobileActivity : updateTopBar(item) } + private fun setSimprintsRampHistoryTableLandscapeFullscreen(enabled: Boolean) { + if (!this.isLandscape() || isSimprintsRampHistoryTableLandscapeFullscreen == enabled) { + return + } + + val mainView = findViewById(R.id.main_view) ?: return + ConstraintSet() + .apply { + clone(mainView) + setVisibility(R.id.tei_primary_color_view, if (enabled) View.GONE else View.VISIBLE) + setVisibility(R.id.tei_form_view, if (enabled) View.GONE else View.VISIBLE) + clear(R.id.fragmentContainer, ConstraintSet.START) + connect( + R.id.fragmentContainer, + ConstraintSet.START, + if (enabled) ConstraintSet.PARENT_ID else R.id.guideline625, + if (enabled) ConstraintSet.START else ConstraintSet.END, + ) + clear(R.id.navigationBar, ConstraintSet.START) + connect( + R.id.navigationBar, + ConstraintSet.START, + if (enabled) ConstraintSet.PARENT_ID else R.id.guideline625, + ConstraintSet.START, + ) + }.applyTo(mainView) + + isSimprintsRampHistoryTableLandscapeFullscreen = enabled + binding.fragmentContainer.post { + binding.fragmentContainer.requestLayout() + binding.navigationBar.requestLayout() + } + } + private fun updateTopBar(item: TEIDashboardItems) { if (item === TEIDashboardItems.RELATIONSHIPS) { binding.relationshipIcon.visibility = View.VISIBLE 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 3fbe0c1bacb..23f524b7edb 100644 --- a/app/src/main/java/org/dhis2/usescases/teiDashboard/TeiDashboardPageConfigurator.kt +++ b/app/src/main/java/org/dhis2/usescases/teiDashboard/TeiDashboardPageConfigurator.kt @@ -13,4 +13,6 @@ class TeiDashboardPageConfigurator( override fun displayRelationships(): Boolean = dashboardRepository.programHasRelationships() override fun displayNotes(): Boolean = true + + override fun displayTableView(): Boolean = dashboardRepository.programHasSimprintsRampProgramStageHistoryTable() } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1dbaf500cf1..7f80b4e9f55 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -759,9 +759,13 @@ Charts Relations Notes + History Data entry Form Details + Visit + Date + No history available View as Bar View as Line View as Table diff --git a/app/src/test/java/org/dhis2/simprints/ramp/data/EventHistoryTableRepositoryTest.kt b/app/src/test/java/org/dhis2/simprints/ramp/data/EventHistoryTableRepositoryTest.kt new file mode 100644 index 00000000000..bac904bc234 --- /dev/null +++ b/app/src/test/java/org/dhis2/simprints/ramp/data/EventHistoryTableRepositoryTest.kt @@ -0,0 +1,383 @@ +package org.dhis2.simprints.ramp.data + +import org.dhis2.commons.simprints.ramp.model.ProgramStageHistoryTableConfig +import org.dhis2.commons.simprints.ramp.model.RampDatastoreConfig +import org.dhis2.commons.simprints.ramp.repository.RampDatastoreRepository +import org.hisp.dhis.android.core.D2 +import org.hisp.dhis.android.core.arch.repositories.filters.internal.StringFilterConnector +import org.hisp.dhis.android.core.arch.repositories.`object`.ReadOnlyOneObjectRepositoryFinalImpl +import org.hisp.dhis.android.core.arch.repositories.scope.RepositoryScope +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.option.Option +import org.hisp.dhis.android.core.option.OptionCollectionRepository +import org.hisp.dhis.android.core.program.ProgramStageDataElement +import org.hisp.dhis.android.core.program.ProgramStageDataElementCollectionRepository +import org.hisp.dhis.android.core.program.ProgramStageSection +import org.hisp.dhis.android.core.program.ProgramStageSectionsCollectionRepository +import org.hisp.dhis.android.core.trackedentity.TrackedEntityDataValue +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +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 + +class EventHistoryTableRepositoryTest { + private val d2: D2 = Mockito.mock(D2::class.java, Mockito.RETURNS_DEEP_STUBS) + private val simprintsRampDatastoreRepository: RampDatastoreRepository = mock() + private val events: EventCollectionRepository = mock() + private val eventsWithDataValues: EventCollectionRepository = mock() + private val eventUidFilter: StringFilterConnector = mock() + private val currentEventCollection: EventCollectionRepository = mock() + private val currentEventRepository: ReadOnlyOneObjectRepositoryFinalImpl = mock() + private val enrollmentFilter: StringFilterConnector = mock() + private val enrollmentEvents: EventCollectionRepository = mock() + private val programStageFilter: StringFilterConnector = mock() + private val followUpEvents: EventCollectionRepository = mock() + private val programStageDataElements: ProgramStageDataElementCollectionRepository = mock() + private val programStageDataElementsWithRenderType: ProgramStageDataElementCollectionRepository = mock() + private val programStageDataElementProgramStageFilter: + StringFilterConnector = mock() + private val programStageDataElementsByStage: ProgramStageDataElementCollectionRepository = mock() + private val sortedProgramStageDataElements: ProgramStageDataElementCollectionRepository = mock() + private val programStageSections: ProgramStageSectionsCollectionRepository = mock() + private val programStageSectionFilter: StringFilterConnector = mock() + private val programStageSectionsByStage: ProgramStageSectionsCollectionRepository = mock() + private val programStageSectionsWithDataElements: ProgramStageSectionsCollectionRepository = mock() + private val options: OptionCollectionRepository = mock() + private val optionSetFilter: StringFilterConnector = mock() + private val optionsBySet: OptionCollectionRepository = mock() + + @Test + fun `table should map configured follow-up events into fixed visit columns`() { + stubRampConfig() + stubCurrentEvent( + event( + uid = CURRENT_EVENT_UID, + eventDate = Date(2_000), + values = + listOf( + value(VISIT_NUMBER_UID, "1"), + value(WEIGHT_UID, "9.0"), + value(STATUS_UID, "A"), + ), + ), + ) + stubRowsAndSections() + stubFollowUpEvents( + listOf( + event( + uid = "previous", + eventDate = Date(1_000), + values = + listOf( + value(VISIT_NUMBER_UID, "0"), + value(WEIGHT_UID, "8.0"), + value(STATUS_UID, "A"), + ), + ), + event( + uid = "previous-duplicate", + eventDate = Date(1_500), + values = + listOf( + value(VISIT_NUMBER_UID, "0"), + value(WEIGHT_UID, "8.5"), + value(STATUS_UID, "B"), + ), + ), + event( + uid = "future", + eventDate = Date(3_000), + values = + listOf( + value(VISIT_NUMBER_UID, "2"), + value(WEIGHT_UID, "10.0"), + value(STATUS_UID, "A"), + ), + ), + ), + ) + stubOptions() + + val table = + EventHistoryTableRepository( + d2 = d2, + simprintsRampDatastoreRepository = simprintsRampDatastoreRepository, + eventUid = CURRENT_EVENT_UID, + ).getTable() + + assertEquals(listOf("0", "1", "2", "3"), table?.columns?.map { it.label }) + assertEquals("previous-duplicate", table?.columns?.get(0)?.eventUid) + assertEquals(CURRENT_EVENT_UID, table?.columns?.get(1)?.eventUid) + assertNull(table?.columns?.get(2)?.eventUid) + assertEquals(listOf("Follow up"), table?.sections?.map { it.title }) + assertEquals( + listOf("Weight", "Status"), + table + ?.sections + ?.single() + ?.rows + ?.map { it.label }, + ) + assertEquals( + listOf("8.5", "9.0", "", ""), + table + ?.sections + ?.single() + ?.rows + ?.get(0) + ?.values, + ) + // Visit 0 keeps the later duplicate event with OptionB + assertEquals( + listOf("OptionB", "OptionA", "", ""), + table + ?.sections + ?.single() + ?.rows + ?.get(1) + ?.values, + ) + } + + @Test + fun `table should include all enrollment follow-up events when opened from dashboard`() { + stubRampConfig() + stubRowsAndSections() + stubFollowUpEvents( + listOf( + event( + uid = "visit-two", + eventDate = Date(3_000), + values = + listOf( + value(VISIT_NUMBER_UID, "2"), + value(WEIGHT_UID, "10.0"), + ), + ), + ), + ) + stubOptions() + + val table = + EventHistoryTableRepository( + d2 = d2, + simprintsRampDatastoreRepository = simprintsRampDatastoreRepository, + programUid = PROGRAM_UID, + enrollmentUid = ENROLLMENT_UID, + ).getTable() + + assertEquals("visit-two", table?.columns?.get(2)?.eventUid) + assertEquals( + "10.0", + table + ?.sections + ?.single() + ?.rows + ?.first() + ?.values + ?.get(2), + ) + } + + private fun stubRampConfig() { + whenever(simprintsRampDatastoreRepository.getConfig()) doReturn + RampDatastoreConfig( + programStageHistoryTables = + listOf( + ProgramStageHistoryTableConfig( + programId = PROGRAM_UID, + followUpVisitProgramStageId = PROGRAM_STAGE_UID, + followUpVisitMaxNumber = 3, + headerVisitNumberDataElementId = VISIT_NUMBER_UID, + excludedFollowUpVisitDataElementIds = listOf(EXCLUDED_UID), + ), + ), + ) + } + + private fun stubCurrentEvent(event: Event) { + whenever(d2.eventModule().events()) doReturn events + whenever(events.withTrackedEntityDataValues()) doReturn eventsWithDataValues + whenever(eventsWithDataValues.byUid()) doReturn eventUidFilter + whenever(eventUidFilter.eq(CURRENT_EVENT_UID)) doReturn currentEventCollection + whenever(currentEventCollection.one()) doReturn currentEventRepository + whenever(currentEventRepository.blockingGet()) doReturn event + } + + private fun stubFollowUpEvents(events: List) { + whenever(d2.eventModule().events()) doReturn this.events + whenever(this.events.withTrackedEntityDataValues()) doReturn eventsWithDataValues + whenever(eventsWithDataValues.byEnrollmentUid()) doReturn enrollmentFilter + whenever(enrollmentFilter.eq(ENROLLMENT_UID)) doReturn enrollmentEvents + whenever(enrollmentEvents.byProgramStageUid()) doReturn programStageFilter + whenever(programStageFilter.eq(PROGRAM_STAGE_UID)) doReturn followUpEvents + whenever(followUpEvents.blockingGet()) doReturn events + } + + private fun stubRowsAndSections() { + val programStage = ObjectWithUid.create(PROGRAM_STAGE_UID) + val weightDataElement = dataElement(WEIGHT_UID) + val statusDataElement = dataElement(STATUS_UID) + val excludedDataElement = dataElement(EXCLUDED_UID) + val visitNumberDataElement = dataElement(VISIT_NUMBER_UID) + val programStageDataElementsForStage = + listOf( + programStageDataElement(programStage, visitNumberDataElement), + programStageDataElement(programStage, weightDataElement), + programStageDataElement(programStage, statusDataElement), + programStageDataElement(programStage, excludedDataElement), + ) + + whenever(d2.programModule().programStageDataElements()) doReturn programStageDataElements + whenever(programStageDataElements.withRenderType()) doReturn programStageDataElementsWithRenderType + whenever(programStageDataElementsWithRenderType.byProgramStage()) doReturn programStageDataElementProgramStageFilter + whenever(programStageDataElementProgramStageFilter.eq(PROGRAM_STAGE_UID)) doReturn programStageDataElementsByStage + whenever( + programStageDataElementsByStage.orderBySortOrder(RepositoryScope.OrderByDirection.ASC), + ) doReturn sortedProgramStageDataElements + whenever(sortedProgramStageDataElements.blockingGet()) doReturn programStageDataElementsForStage + whenever(d2.programModule().programStageSections()) doReturn programStageSections + whenever(programStageSections.byProgramStageUid()) doReturn programStageSectionFilter + whenever(programStageSectionFilter.eq(PROGRAM_STAGE_UID)) doReturn programStageSectionsByStage + whenever(programStageSectionsByStage.withDataElements()) doReturn programStageSectionsWithDataElements + whenever(programStageSectionsWithDataElements.blockingGet()) doReturn + listOf( + ProgramStageSection + .builder() + .uid("section") + .displayName("Follow up") + .sortOrder(1) + .programStage(programStage) + .dataElements(listOf(weightDataElement, statusDataElement, excludedDataElement)) + .build(), + ProgramStageSection + .builder() + .uid("empty-section") + .displayName("Hidden") + .sortOrder(2) + .programStage(programStage) + .dataElements(listOf(excludedDataElement)) + .build(), + ) + whenever( + d2 + .dataElementModule() + .dataElements() + .uid(WEIGHT_UID) + .blockingGet(), + ) doReturn + DataElement + .builder() + .uid(WEIGHT_UID) + .displayShortName("Weight") + .displayName("Weight (kg)") + .valueType(ValueType.NUMBER) + .build() + whenever( + d2 + .dataElementModule() + .dataElements() + .uid(STATUS_UID) + .blockingGet(), + ) doReturn + DataElement + .builder() + .uid(STATUS_UID) + .displayShortName("Status") + .valueType(ValueType.TEXT) + .optionSet(ObjectWithUid.create(OPTION_SET_UID)) + .build() + whenever( + d2 + .dataElementModule() + .dataElements() + .uid(EXCLUDED_UID) + .blockingGet(), + ) doReturn + DataElement + .builder() + .uid(EXCLUDED_UID) + .displayShortName("Excluded") + .valueType(ValueType.TEXT) + .build() + } + + private fun stubOptions() { + val statusOptions = + listOf( + Option + .builder() + .uid("option-a") + .code("A") + .displayName("OptionA") + .build(), + Option + .builder() + .uid("option-b") + .code("B") + .displayName("OptionB") + .build(), + ) + + whenever(d2.optionModule().options()) doReturn options + whenever(options.byOptionSetUid()) doReturn optionSetFilter + whenever(optionSetFilter.eq(OPTION_SET_UID)) doReturn optionsBySet + whenever(optionsBySet.blockingGet()) doReturn statusOptions + } + + private fun programStageDataElement( + programStage: ObjectWithUid, + dataElement: DataElement, + ): ProgramStageDataElement = + mock { + on { programStage() } doReturn programStage + on { dataElement() } doReturn dataElement + } + + private fun event( + uid: String, + eventDate: Date, + values: List, + ): Event = + Event + .builder() + .uid(uid) + .program(PROGRAM_UID) + .programStage(PROGRAM_STAGE_UID) + .enrollment(ENROLLMENT_UID) + .eventDate(eventDate) + .trackedEntityDataValues(values) + .build() + + private fun value( + dataElementUid: String, + value: String, + ): TrackedEntityDataValue = + TrackedEntityDataValue + .builder() + .dataElement(dataElementUid) + .value(value) + .build() + + private fun dataElement(uid: String): DataElement = DataElement.builder().uid(uid).build() + + private companion object { + const val CURRENT_EVENT_UID = "current" + const val ENROLLMENT_UID = "enrollment" + const val PROGRAM_UID = "program" + const val PROGRAM_STAGE_UID = "follow-stage" + const val VISIT_NUMBER_UID = "visit-number" + const val WEIGHT_UID = "weight" + const val STATUS_UID = "status" + const val EXCLUDED_UID = "excluded" + const val OPTION_SET_UID = "option-set" + } +} diff --git a/app/src/test/java/org/dhis2/simprints/ramp/data/GetEventHistoryTableUseCaseTest.kt b/app/src/test/java/org/dhis2/simprints/ramp/data/GetEventHistoryTableUseCaseTest.kt new file mode 100644 index 00000000000..3b6ed470aa8 --- /dev/null +++ b/app/src/test/java/org/dhis2/simprints/ramp/data/GetEventHistoryTableUseCaseTest.kt @@ -0,0 +1,42 @@ +package org.dhis2.simprints.ramp.data + +import org.dhis2.simprints.ramp.model.EventHistoryTable +import org.dhis2.simprints.ramp.model.EventHistoryTableColumn +import org.dhis2.simprints.ramp.model.EventHistoryTableSection +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.doThrow +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever + +class GetEventHistoryTableUseCaseTest { + private val repository: EventHistoryTableRepository = mock() + private val useCase = GetEventHistoryTableUseCase(repository) + + @Test + fun `invoke should return repository table result`() { + val table = + EventHistoryTable( + columns = listOf(EventHistoryTableColumn(eventUid = "event", label = "0")), + sections = listOf(EventHistoryTableSection(title = "Section", rows = emptyList())), + ) + whenever(repository.getTable()) doReturn table + + val result = useCase() + + assertTrue(result.isSuccess) + assertEquals(table, result.getOrNull()) + } + + @Test + fun `invoke should wrap repository failure`() { + whenever(repository.getTable()) doThrow IllegalStateException("broken") + + val result = useCase() + + assertTrue(result.isFailure) + assertEquals("broken", result.exceptionOrNull()?.message) + } +} diff --git a/app/src/test/java/org/dhis2/simprints/ramp/ui/EventHistoryTableViewModelTest.kt b/app/src/test/java/org/dhis2/simprints/ramp/ui/EventHistoryTableViewModelTest.kt new file mode 100644 index 00000000000..70975077b85 --- /dev/null +++ b/app/src/test/java/org/dhis2/simprints/ramp/ui/EventHistoryTableViewModelTest.kt @@ -0,0 +1,93 @@ +package org.dhis2.simprints.ramp.ui + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.dhis2.commons.viewmodel.DispatcherProvider +import org.dhis2.simprints.ramp.data.GetEventHistoryTableUseCase +import org.dhis2.simprints.ramp.model.EventHistoryTable +import org.dhis2.simprints.ramp.model.EventHistoryTableColumn +import org.dhis2.simprints.ramp.model.EventHistoryTableSection +import org.dhis2.simprints.ramp.model.EventHistoryTableUiState +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever + +@OptIn(ExperimentalCoroutinesApi::class) +class EventHistoryTableViewModelTest { + private lateinit var testDispatcher: TestDispatcher + private lateinit var dispatcherProvider: DispatcherProvider + private lateinit var getEventHistoryTable: GetEventHistoryTableUseCase + + @Before + fun setUp() { + testDispatcher = StandardTestDispatcher() + Dispatchers.setMain(testDispatcher) + dispatcherProvider = + object : DispatcherProvider { + override fun io(): CoroutineDispatcher = testDispatcher + + override fun computation(): CoroutineDispatcher = testDispatcher + + override fun ui(): CoroutineDispatcher = testDispatcher + } + getEventHistoryTable = mock() + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `when table exists and ViewModel loads, then success state is shown`() { + val table = table() + whenever(getEventHistoryTable()) doReturn Result.success(table) + + val viewModel = viewModel() + + assertEquals(EventHistoryTableUiState.Loading, viewModel.uiState.value) + testDispatcher.scheduler.advanceUntilIdle() + assertEquals(EventHistoryTableUiState.Success(table), viewModel.uiState.value) + } + + @Test + fun `when table is missing and ViewModel loads, then empty state is shown`() { + whenever(getEventHistoryTable()) doReturn Result.success(null) + + val viewModel = viewModel() + + testDispatcher.scheduler.advanceUntilIdle() + assertEquals(EventHistoryTableUiState.Empty, viewModel.uiState.value) + } + + @Test + fun `when use case fails and ViewModel loads, then error state is shown`() { + whenever(getEventHistoryTable()) doReturn Result.failure(IllegalStateException("broken")) + + val viewModel = viewModel() + + testDispatcher.scheduler.advanceUntilIdle() + assertEquals(EventHistoryTableUiState.Error("broken"), viewModel.uiState.value) + } + + private fun viewModel(): EventHistoryTableViewModel = + EventHistoryTableViewModel( + getEventHistoryTable = getEventHistoryTable, + dispatcherProvider = dispatcherProvider, + ) + + private fun table(): EventHistoryTable = + EventHistoryTable( + columns = listOf(EventHistoryTableColumn(eventUid = "event", label = "0")), + sections = listOf(EventHistoryTableSection(title = "Section", rows = emptyList())), + ) +} 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 new file mode 100644 index 00000000000..fe1d5213fcc --- /dev/null +++ b/app/src/test/java/org/dhis2/usescases/eventsWithoutRegistration/eventCapture/EventPageConfiguratorTest.kt @@ -0,0 +1,27 @@ +package org.dhis2.usescases.eventsWithoutRegistration.eventCapture + +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 +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever + +class EventPageConfiguratorTest { + private val eventCaptureRepository: EventCaptureContract.EventCaptureRepository = mock() + private val pageConfigurator: NavigationPageConfigurator = + EventPageConfigurator(eventCaptureRepository, isPortrait = true) + + @Test + fun `displayTableView should follow repository Simprints RAMP history table availability`() { + whenever(eventCaptureRepository.hasSimprintsRampProgramStageHistoryTable()) doReturn true + assertTrue(pageConfigurator.displayTableView()) + } + + @Test + fun `displayTableView should be false when Simprints RAMP history table is not configured`() { + whenever(eventCaptureRepository.hasSimprintsRampProgramStageHistoryTable()) doReturn false + assertFalse(pageConfigurator.displayTableView()) + } +} 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 8e81092c0b1..1b84c6c4d7c 100644 --- a/app/src/test/java/org/dhis2/usescases/teiDashboard/DashboardRepositoryImplTest.kt +++ b/app/src/test/java/org/dhis2/usescases/teiDashboard/DashboardRepositoryImplTest.kt @@ -19,6 +19,7 @@ import org.hisp.dhis.android.core.maintenance.D2ErrorComponent 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.junit.Assert.assertFalse import org.junit.Before import org.junit.Test import org.mockito.ArgumentMatchers.anyString @@ -57,6 +58,19 @@ class DashboardRepositoryImplTest { ) } + @Test + fun `Should return false when checking Simprints RAMP history table fails`() { + whenever( + d2 + .dataStoreModule() + .dataStore() + .value("simprints", "ramp") + .blockingGet(), + ).thenAnswer { throw d2Error() } + + assertFalse(repository.programHasSimprintsRampProgramStageHistoryTable()) + } + @Test fun `Should return program stage to show display generate event`() { whenever(d2.eventModule()) doReturn mock() @@ -603,4 +617,12 @@ class DashboardRepositoryImplTest { .builder() .uid("program_stage") .build() + + private fun d2Error(): D2Error = + D2Error + .builder() + .errorCode(D2ErrorCode.VALUE_CANT_BE_SET) + .errorComponent(D2ErrorComponent.Database) + .errorDescription("description") + .build() } 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 4849f3c652a..d435dd38336 100644 --- a/app/src/test/java/org/dhis2/usescases/teiDashboard/TeiDashboardPageConfiguratorTest.kt +++ b/app/src/test/java/org/dhis2/usescases/teiDashboard/TeiDashboardPageConfiguratorTest.kt @@ -46,4 +46,16 @@ class TeiDashboardPageConfiguratorTest { fun `Should display the notes screen`() { assertTrue(pageConfigurator.displayNotes()) } + + @Test + fun `Should display history screen if the program is configured`() { + whenever(dashboardRepository.programHasSimprintsRampProgramStageHistoryTable()) doReturn true + assertTrue(pageConfigurator.displayTableView()) + } + + @Test + fun `Should not display history screen if the program is not configured`() { + whenever(dashboardRepository.programHasSimprintsRampProgramStageHistoryTable()) doReturn false + assertTrue(!pageConfigurator.displayTableView()) + } } 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 a53a3dbfd96..a1aa4af258a 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 @@ -4,8 +4,11 @@ import com.google.gson.annotations.SerializedName data class RampDatastoreConfig( val dataElementHistoryCharts: List = emptyList(), + val programStageHistoryTables: List = emptyList(), ) { - fun isNotEmpty(): Boolean = dataElementHistoryCharts.isNotEmpty() + fun isNotEmpty(): Boolean = + dataElementHistoryCharts.isNotEmpty() || + programStageHistoryTables.isNotEmpty() } data class DataElementHistoryChartConfig( @@ -27,3 +30,22 @@ data class DataElementHistoryChartConfig( !xAxisVisitNumberDataElementId.isNullOrBlank() && (followUpVisitMaxNumber ?: -1) >= 0 } + +data class ProgramStageHistoryTableConfig( + @SerializedName("programId") + val programId: String? = null, + @SerializedName("followUpVisitProgramStageId") + val followUpVisitProgramStageId: String? = null, + @SerializedName("followUpVisitMaxNumber") + val followUpVisitMaxNumber: Int? = null, + @SerializedName("headerVisitNumberDataElementId") + val headerVisitNumberDataElementId: String? = null, + @SerializedName("excludedFollowUpVisitDataElementIds") + val excludedFollowUpVisitDataElementIds: List? = null, +) { + fun isValid(): Boolean = + !programId.isNullOrBlank() && + !followUpVisitProgramStageId.isNullOrBlank() && + !headerVisitNumberDataElementId.isNullOrBlank() && + (followUpVisitMaxNumber ?: -1) >= 0 +} 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 e7a3c687f1d..1f4d36277fe 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 @@ -2,10 +2,13 @@ package org.dhis2.commons.simprints.ramp.repository import com.google.gson.Gson 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.ProgramStageHistoryTableConfig import org.dhis2.commons.simprints.ramp.model.RampDatastoreConfig import org.hisp.dhis.android.core.D2 +import timber.log.Timber class RampDatastoreRepository( private val d2: D2, @@ -35,20 +38,17 @@ class RampDatastoreRepository( } private fun getLocalRampDatastoreValue(): String? = - runCatching { - d2 - .dataStoreModule() - .dataStore() - .value(RAMP_DATASTORE_NAMESPACE, RAMP_DATASTORE_KEY) - .blockingGet() - ?.value() - }.getOrNull() + d2 + .dataStoreModule() + .dataStore() + .value(RAMP_DATASTORE_NAMESPACE, RAMP_DATASTORE_KEY) + .blockingGet() + ?.value() private fun parseRawValue(value: String): RampDatastoreConfig = - runCatching { - val root = - parseDatastoreJsonElement(value)?.asJsonObject - ?: return@runCatching RampDatastoreConfig() + try { + val root = parseDatastoreJsonElement(value).asJsonObject + RampDatastoreConfig( dataElementHistoryCharts = root @@ -56,24 +56,33 @@ class RampDatastoreRepository( ?.parseList() .orEmpty() .filter { it.isValid() }, + programStageHistoryTables = + root + .get(PROGRAM_STAGE_HISTORY_TABLE_KEY) + ?.parseList() + .orEmpty() + .filter { it.isValid() }, ) - }.getOrDefault(RampDatastoreConfig()) + } catch (exception: Exception) { + Timber.e(exception, RAMP_DATASTORE_PARSE_ERROR) + RampDatastoreConfig() + } catch (exception: IllegalStateException) { + Timber.e(exception, RAMP_DATASTORE_PARSE_ERROR) + RampDatastoreConfig() + } - private fun JsonElement.parseDatastoreJsonElement(): JsonElement? = - runCatching { - if (isJsonPrimitive && asJsonPrimitive.isString) { - parseDatastoreJsonElement(asString) ?: this - } else { - this - } - }.getOrNull() + private fun JsonElement.parseDatastoreJsonElement(): JsonElement = + if (isJsonPrimitive && asJsonPrimitive.isString) { + parseDatastoreJsonElement(asString) + } else { + this + } - private fun parseDatastoreJsonElement(value: String): JsonElement? = parseJsonElement(value) ?: parseJsonWrapperElement(value) + private fun parseDatastoreJsonElement(value: String): JsonElement = + parseJsonWrapperElement(value) ?: parseJsonElement(value) - private fun parseJsonElement(value: String): JsonElement? = - runCatching { - JsonParser.parseString(value).parseDatastoreJsonElement() - }.getOrNull() + private fun parseJsonElement(value: String): JsonElement = + JsonParser.parseString(value).parseDatastoreJsonElement() private fun parseJsonWrapperElement(value: String): JsonElement? = value @@ -103,7 +112,16 @@ class RampDatastoreRepository( else -> emptyList() } - private inline fun JsonElement.parseObject(): T? = runCatching { gson.fromJson(this, T::class.java) }.getOrNull() + private inline fun JsonElement.parseObject(): T? = + try { + gson.fromJson(this, T::class.java) + } catch (exception: JsonParseException) { + Timber.e(exception, RAMP_DATASTORE_PARSE_ERROR) + null + } catch (exception: IllegalStateException) { + Timber.e(exception, RAMP_DATASTORE_PARSE_ERROR) + null + } private data class CachedConfig( val rawValue: String?, @@ -114,7 +132,9 @@ class RampDatastoreRepository( private const val RAMP_DATASTORE_NAMESPACE = "simprints" private const val RAMP_DATASTORE_KEY = "ramp" private const val DATA_ELEMENT_HISTORY_CHARTS_KEY = "dataElementHistoryCharts" + private const val PROGRAM_STAGE_HISTORY_TABLE_KEY = "programStageHistoryTable" private const val DATASTORE_JSON_WRAPPER_NAME = "JsonWrapper" private const val DATASTORE_JSON_WRAPPER_JSON_FIELD = "json" + private const val RAMP_DATASTORE_PARSE_ERROR = "Failed to parse Simprints RAMP datastore config" } } 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 88c4d380b3c..62196b1f40b 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 @@ -3,7 +3,11 @@ package org.dhis2.commons.simprints.ramp.repository import com.google.gson.Gson import org.hisp.dhis.android.core.D2 import org.hisp.dhis.android.core.datastore.DataStoreEntry +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.junit.Assert.assertEquals +import org.junit.Assert.assertThrows import org.junit.Test import org.mockito.Mockito import org.mockito.kotlin.doReturn @@ -16,7 +20,7 @@ class RampDatastoreRepositoryTest { private val repository = RampDatastoreRepository(d2) @Test - fun `getConfig should parse history chart config and ignore invalid entries`() { + fun `getConfig should parse history chart or table config and ignore invalid entries`() { stubRampConfigRawValue( """ { @@ -37,6 +41,20 @@ class RampDatastoreRepositoryTest { ], "otherConfigs": [ { "ignored": true } + ], + "programStageHistoryTable": [ + { + "programId": "program", + "followUpVisitProgramStageId": "follow-stage", + "followUpVisitMaxNumber": 12, + "headerVisitNumberDataElementId": "visit-number", + "excludedFollowUpVisitDataElementIds": ["excluded"] + }, + { + "programId": "program", + "followUpVisitProgramStageId": "follow-stage", + "followUpVisitMaxNumber": 12 + } ] } """.trimIndent(), @@ -52,6 +70,14 @@ class RampDatastoreRepositoryTest { assertEquals("visit-number", chart.xAxisVisitNumberDataElementId) assertEquals(12, chart.followUpVisitMaxNumber) } + assertEquals(1, config.programStageHistoryTables.size) + config.programStageHistoryTables.first().let { table -> + assertEquals("program", table.programId) + assertEquals("follow-stage", table.followUpVisitProgramStageId) + assertEquals(12, table.followUpVisitMaxNumber) + assertEquals("visit-number", table.headerVisitNumberDataElementId) + assertEquals(listOf("excluded"), table.excludedFollowUpVisitDataElementIds) + } } @Test @@ -84,6 +110,31 @@ class RampDatastoreRepositoryTest { assertEquals("height", config.dataElementHistoryCharts.single().dataElementId) } + @Test + fun `getConfig should return default config when datastore value is invalid`() { + stubRampConfigRawValue("{invalid") + + val config = repository.getConfig() + + assertEquals(0, config.dataElementHistoryCharts.size) + assertEquals(0, config.programStageHistoryTables.size) + } + + @Test + fun `getConfig should propagate datastore read failure`() { + whenever( + d2 + .dataStoreModule() + .dataStore() + .value("simprints", "ramp") + .blockingGet(), + ).thenAnswer { throw d2Error() } + + assertThrows(D2Error::class.java) { + repository.getConfig() + } + } + @Test fun `getConfig should reload config when local datastore value changes`() { whenever( @@ -98,8 +149,22 @@ class RampDatastoreRepositoryTest { rampEntry(getRampRawUnwrappedValue(dataElementId = "value2")), ) - assertEquals("value1", repository.getConfig().dataElementHistoryCharts.single().dataElementId) - assertEquals("value2", repository.getConfig().dataElementHistoryCharts.single().dataElementId) + assertEquals( + "value1", + repository + .getConfig() + .dataElementHistoryCharts + .single() + .dataElementId, + ) + assertEquals( + "value2", + repository + .getConfig() + .dataElementHistoryCharts + .single() + .dataElementId, + ) } @Test @@ -145,4 +210,12 @@ class RampDatastoreRepositoryTest { } } """.trimIndent() + + private fun d2Error(): D2Error = + D2Error + .builder() + .errorCode(D2ErrorCode.VALUE_CANT_BE_SET) + .errorComponent(D2ErrorComponent.Database) + .errorDescription("description") + .build() } diff --git a/tracker/src/commonMain/kotlin/org/dhis2/tracker/TEIDashboardItems.kt b/tracker/src/commonMain/kotlin/org/dhis2/tracker/TEIDashboardItems.kt index 36d461a7513..12509fbb257 100644 --- a/tracker/src/commonMain/kotlin/org/dhis2/tracker/TEIDashboardItems.kt +++ b/tracker/src/commonMain/kotlin/org/dhis2/tracker/TEIDashboardItems.kt @@ -1,3 +1,3 @@ package org.dhis2.tracker -enum class TEIDashboardItems { DETAILS, ANALYTICS, RELATIONSHIPS, NOTES } +enum class TEIDashboardItems { DETAILS, ANALYTICS, RELATIONSHIPS, NOTES, SIMPRINTS_RAMP_HISTORY_TABLE } From 460ad83c657afc72af74333c2b3dfd94a92e40a1 Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 2 Jun 2026 13:24:07 +0100 Subject: [PATCH 04/12] Simprints RAMP release build workflow. RAMP base branch: ramp-main --- .../rampcapture-github-release-signed-apk.yml | 130 ++++++++++++++++++ 1 file changed, 130 insertions(+) create mode 100644 .github/workflows/rampcapture-github-release-signed-apk.yml diff --git a/.github/workflows/rampcapture-github-release-signed-apk.yml b/.github/workflows/rampcapture-github-release-signed-apk.yml new file mode 100644 index 00000000000..1153174ad1c --- /dev/null +++ b/.github/workflows/rampcapture-github-release-signed-apk.yml @@ -0,0 +1,130 @@ +name: Build signed RAMPcapture APK release + +on: + workflow_dispatch: + push: + branches: + - ramp-main + +concurrency: + group: rampcapture-github-release-signed-apk + cancel-in-progress: false + +env: + MAIN_PROJECT_MODULE: app + +jobs: + rampcapture-github-release-signed-apk: + runs-on: ubuntu-latest + permissions: + contents: write + env: + CURRENT_FORK_REPOSITORY: ${{ github.repository }} + steps: + - uses: actions/checkout@v6 + with: + submodules: recursive + + - name: Get current date and time + id: date-time + run: echo "dateTimeUtc=$(date -u +'%Y-%m-%d-%H%M')" >> "$GITHUB_OUTPUT" + + - name: Set up JDK + uses: actions/setup-java@v5 + with: + distribution: zulu + java-version: '17' + cache: gradle + + - name: Change wrapper permissions + run: chmod +x ./gradlew + + - name: Read upstream app version + id: read-version + working-directory: ./gradle + run: echo "vName=$(grep '^vName' libs.versions.toml | awk -F' = ' '{print $2}' | tr -d '\"')" >> "$GITHUB_OUTPUT" + + - name: Determine next RAMPcapture fork release + id: release-info + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + + last_fork_number=0 + + while IFS= read -r tag_name; do + [[ "$tag_name" =~ ^RAMPcapture-DHIS2-v[0-9]+(\.[0-9]+)*-fork-([0-9]+)$ ]] || continue + + candidate_fork_number="${BASH_REMATCH[2]}" + if (( candidate_fork_number > last_fork_number )); then + last_fork_number="$candidate_fork_number" + fi + done < <( + gh api --paginate "repos/$CURRENT_FORK_REPOSITORY/releases?per_page=100" --jq '.[].tag_name' + ) + + next_fork_number=$((last_fork_number + 1)) + release_tag="RAMPcapture-DHIS2-v${{ steps.read-version.outputs.vName }}-fork-${next_fork_number}" + release_apk_name="${release_tag}-signed-release.apk" + release_apk_path="$RUNNER_TEMP/$release_apk_name" + + { + echo "forkNumber=$next_fork_number" + echo "releaseTag=$release_tag" + echo "releaseApkName=$release_apk_name" + echo "releaseApkPath=$release_apk_path" + } >> "$GITHUB_OUTPUT" + + - name: Decode keystore + id: decode-keystore + # Third-party action - pinned to commit SHA. + uses: timheuer/base64-to-file@604a8926a81a2da120d09b06bb76da9bba5aee6e + with: + fileName: dhis_keystore.jks + encodedString: ${{ secrets.KEYSTORE_BASE64 }} + + - name: Build signed release APK + run: ./gradlew app:assembleDhis2Release + env: + SIGNING_KEY_ALIAS: ${{ secrets.KEY_ALIAS }} + SIGNING_KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }} + SIGNING_STORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }} + SIGNING_KEYSTORE_PATH: ${{ steps.decode-keystore.outputs.filePath }} + + - name: Rename signed release APK for RAMPcapture release + run: | + set -euo pipefail + + cp \ + "${MAIN_PROJECT_MODULE}/build/outputs/apk/dhis2/release/dhis2-v${{ steps.read-version.outputs.vName }}.apk" \ + "${{ steps.release-info.outputs.releaseApkPath }}" + + - name: Upload signed release APK artifact + uses: actions/upload-artifact@v7.0.0 + with: + name: ${{ steps.release-info.outputs.releaseTag }} + path: ${{ steps.release-info.outputs.releaseApkPath }} + + - name: Create GitHub release with signed APK + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + + release_notes=$(cat < Date: Tue, 2 Jun 2026 13:27:08 +0100 Subject: [PATCH 05/12] Simprints RAMP README updated --- README.md | 49 +++++++++++++++++++++++++++++++++++-------------- 1 file changed, 35 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 6ef96328ed8..f2aacb4d8f4 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,9 @@ -# SimCapture +# RAMPcapture (based on SimCapture) DHIS2 data and tracker capture app for Android to support integration with SimprintsID. +Base branch: `ramp-main`. + ### Changes Summary of changes comparing to @@ -19,9 +21,9 @@ upstream [dhis2/dhis2-android-capture-app](https://github.com/dhis2/dhis2-androi | Enrolment+ Possible Duplicates | [FormView.kt#L654](form/src/main/java/org/dhis2/form/ui/FormView.kt#L654) | Code addition | Stores the returned SID session and hands the enrolment form off to the possible duplicates search flow | | Enrolment+ Possible Duplicates | [EnrollmentActivity.kt#L281](app/src/main/java/org/dhis2/usescases/enrollment/EnrollmentActivity.kt#L281) | Code addition | Launches possible duplicates search from the enrolment form, carrying the biometric field and returned GUID matches | | Enrolment+ Possible Duplicates | [SimprintsLoadPossibleDuplicatesSearchResultsUseCase.kt](app/src/main/java/org/dhis2/simprints/SimprintsLoadPossibleDuplicatesSearchResultsUseCase.kt) | New file | Resolves returned GUIDs one by one into TEI search results for the possible duplicates review list | -| Enrolment+ Possible Duplicates | [SearchTEIViewModel.kt#L554](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEIViewModel.kt#L554) | 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 | [SearchTEIViewModel.kt#L572](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEIViewModel.kt#L572) | 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#L307](app/src/main/java/org/dhis2/usescases/searchTrackEntity/listView/SearchTEList.kt#L307) | Code addition | Replaces the default "+ New" action button with `None of the above` while reviewing possible duplicates | -| Enrolment+ Enrol Last | [SearchTEIViewModel.kt#L502](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEIViewModel.kt#L502) | Code addition | Marks `None of the above` as a pending enrolment action and closes the possible duplicates search | +| Enrolment+ Enrol Last | [SearchTEIViewModel.kt#L521](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEIViewModel.kt#L521) | Code addition | Marks `None of the above` as a pending enrolment action and closes the possible duplicates search | | Enrolment+ Enrol Last | [SearchTEActivity.kt#L655](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEActivity.kt#L655) | 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#L145](app/src/main/java/org/dhis2/usescases/enrollment/EnrollmentActivity.kt#L145) | 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 | @@ -33,11 +35,11 @@ upstream [dhis2/dhis2-android-capture-app](https://github.com/dhis2/dhis2-androi | 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#L571](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEActivity.kt#L571) | Code change | Carries biometric search context into create/enroll actions via prepared enrollment query data | | Identification+ Enrol Last | [SimprintsSearchViewModel.kt#L96](app/src/main/java/org/dhis2/simprints/SimprintsSearchViewModel.kt#L96) | Code addition | Prepares Simprints enrollment query data and tracks whether the create action should use last biometrics | -| Identification+ Enrol Last | [SearchTEIViewModel.kt#L1266](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEIViewModel.kt#L1266) | Code change | Preserves existing search parameter values when fields reload so pending Enrol Last state survives configuration changes | +| Identification+ Enrol Last | [SearchTEIViewModel.kt#L1359](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEIViewModel.kt#L1359) | Code change | Preserves existing search parameter values when fields reload so pending Enrol Last state survives configuration changes | | Identification+ Enrol Last | [SearchTEList.kt#L289](app/src/main/java/org/dhis2/usescases/searchTrackEntity/listView/SearchTEList.kt#L289) | Code addition | Observes the Simprints create-action label state for if the new TEI enrollment button in the result list should use Enrol Last | | Identification+ Enrol Last | [SearchTEUi.kt#L688](app/src/main/java/org/dhis2/usescases/searchTrackEntity/ui/SearchTEUi.kt#L688) | Code change | Switches the new TEI enrollment button text to the last-biometrics label when a pending biometric session exists | | Identification+ Enrol Last | [FormRepositoryImpl.kt#L61](form/src/main/java/org/dhis2/form/data/FormRepositoryImpl.kt#L61) | Code change | Counts pending Enrol Last values as present for form completion, sections, and mandatory validation | -| Identification+ Enrol Last | [Injector.kt#L268](form/src/main/java/org/dhis2/form/di/Injector.kt#L268) | Code addition | DI for separate Simprints-specific components for Identification+ Enrol Last | +| Identification+ Enrol Last | [Injector.kt#L279](form/src/main/java/org/dhis2/form/di/Injector.kt#L279) | Code addition | DI for separate Simprints-specific components for Identification+ Enrol Last | | Identification+ Enrol Last | [CustomIntentProvider.kt#L75](form/src/main/java/org/dhis2/form/ui/provider/inputfield/CustomIntentProvider.kt#L75) | Code addition | Shows pending biometric placeholder state, clears it on user clear, and routes Simprints enrollment results | | Identification+ Enrol Last | [SimprintsCustomIntentResultMapper.kt](app/src/main/java/org/dhis2/simprints/SimprintsCustomIntentResultMapper.kt) | New file | Maps SID result intents through the existing custom-intent response contract | | Identification+ Enrol Last | [SimprintsEnrollmentViewModel.kt](app/src/main/java/org/dhis2/simprints/SimprintsEnrollmentViewModel.kt) | New file | Coordinates pending Enrol Last launch, save-time result persistence, and session clearing | @@ -49,25 +51,44 @@ upstream [dhis2/dhis2-android-capture-app](https://github.com/dhis2/dhis2-androi | Identification+ Enrol Last | [SimprintsCustomIntentFormPresenter.kt](form/src/main/java/org/dhis2/form/simprints/SimprintsCustomIntentFormPresenter.kt) | New file | Handles Simprints form callout launch, session capture, placeholder display, and pending-state clearing | | Identification+ Enrol Last | [SimprintsRememberCustomIntentFormPresenter.kt](form/src/main/java/org/dhis2/form/simprints/SimprintsRememberCustomIntentFormPresenter.kt) | New file | Builds the Simprints form presenter from Compose field state and placeholder resources | | Identification Confirm Identity | [SearchTEActivity.kt#L128](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEActivity.kt#L128) | Code addition | Registers the confirm-identity SID launcher and waits for Simprints navigation before opening dashboards | -| Identification Confirm Identity | [SearchTEIViewModel.kt#L824](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEIViewModel.kt#L824) | Code addition | Intercepts TEI openings from biometric search to launch Confirm Identity when required | -| Identification Confirm Identity | [SearchTEModule.java#L358](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEModule.java#L358) | Code addition | DI for separate Simprints-specific components for Identification Confirm Identity | -| Identification Confirm Identity | [SearchTeiViewModelFactory.kt#L46](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTeiViewModelFactory.kt#L46) | Code addition | Passes Simprints search state into `SearchTEIViewModel` | +| Identification Confirm Identity | [SearchTEIViewModel.kt#L847](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEIViewModel.kt#L847) | Code addition | Intercepts TEI openings from biometric search to launch Confirm Identity when required | +| Identification Confirm Identity | [SearchTEModule.java#L361](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEModule.java#L361) | Code addition | DI for separate Simprints-specific components for Identification Confirm Identity | +| Identification Confirm Identity | [SearchTeiViewModelFactory.kt#L48](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTeiViewModelFactory.kt#L48) | Code addition | Passes Simprints search state into `SearchTEIViewModel` | | Identification Confirm Identity | [SimprintsSearchViewModelFactory.kt](app/src/main/java/org/dhis2/simprints/di/SimprintsSearchViewModelFactory.kt) | New file | Creates activity-scoped Simprints search ViewModel so Confirm Identity and Enrol Last state survives configuration changes | | 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 | -| Identification result ordering by score | [SearchTEIViewModel.kt#L692](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEIViewModel.kt#L692) | 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#L350](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEModule.java#L350) | Code change | DI for separate Simprints-specific components for Identification result ordering by score | -| Identification result ordering by score | [SearchTeiViewModelFactory.kt#L47](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTeiViewModelFactory.kt#L47) | Code addition | Passes the ordered-result use case into `SearchTEIViewModel` | +| Identification result ordering by score | [SearchTEIViewModel.kt#L715](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEIViewModel.kt#L715) | 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#L353](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEModule.java#L353) | Code change | DI for separate Simprints-specific components for Identification result ordering by score | +| Identification result ordering by score | [SearchTeiViewModelFactory.kt#L49](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTeiViewModelFactory.kt#L49) | Code addition | Passes the ordered-result use case into `SearchTEIViewModel` | | Identification result ordering by score | [SimprintsOrderSearchResultsByIdentifyResponseUseCase.kt](commons/src/main/java/org/dhis2/commons/simprints/usecases/SimprintsOrderSearchResultsByIdentifyResponseUseCase.kt) | New file | Orders matching TEIs to follow the SID identification response order | | Identification with less buttons | [ParameterSelectorItemProvider.kt#L92](app/src/main/java/org/dhis2/usescases/searchTrackEntity/searchparameters/provider/ParameterSelectorItemProvider.kt#L92) | Code addition | Starts Simprints identification callout immediately on biometric search field click instead of expanding the field | | Identification with less buttons | [SearchTEUi.kt#L211](app/src/main/java/org/dhis2/usescases/searchTrackEntity/ui/SearchTEUi.kt#L211) | Code change | Keeps the Biometric search label read-only | -| Identification with less buttons | [SearchParametersScreen.kt#L140](app/src/main/java/org/dhis2/usescases/searchTrackEntity/searchparameters/SearchParametersScreen.kt#L140) | 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#L1231](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEIViewModel.kt#L1231) | Code addition | Rehydrates carried query values and performs the search immediately when biometric query data is present | +| Identification with less buttons | [SearchParametersScreen.kt#L120](app/src/main/java/org/dhis2/usescases/searchTrackEntity/searchparameters/SearchParametersScreen.kt#L120) | 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#L1324](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEIViewModel.kt#L1324) | Code addition | Rehydrates carried query values and performs the search immediately when biometric query data is present | | Identification with less buttons | [SearchTEActivity.kt#L620](app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEActivity.kt#L620) | Code addition | Skips the transient search editor and jumps straight to the biometric result list without backdrop animation | -| MFID Auto-Open Record | [SearchParametersScreen.kt#L498](app/src/main/java/org/dhis2/usescases/searchTrackEntity/searchparameters/SearchParametersScreen.kt#L498) | Code addition | Detects credential-linked identification results that qualify for the MFID direct-open shortcut | +| MFID Auto-Open Record | [SimprintsMapBiometricSearchResultUseCase.kt#L48](app/src/main/java/org/dhis2/simprints/SimprintsMapBiometricSearchResultUseCase.kt#L48) | Code addition | Detects credential-linked identification results that qualify for the MFID direct-open shortcut | | MFID Auto-Open Record | [SimprintsSearchViewModel.kt#L187](app/src/main/java/org/dhis2/simprints/SimprintsSearchViewModel.kt#L187) | 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 | +| 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 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 | [RampDatastoreRepository.kt#L13](commons/src/main/java/org/dhis2/commons/simprints/ramp/repository/RampDatastoreRepository.kt#L13) | 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 | [SyncPresenterImpl.kt#L272](app/src/main/java/org/dhis2/data/service/SyncPresenterImpl.kt#L272) | 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#L776](form/src/main/java/org/dhis2/form/data/EventRepository.kt#L776) | 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 | [FormHistoryChartView.kt#L48](form/src/main/java/org/dhis2/form/simprints/ramp/ui/FormHistoryChartView.kt#L48) | 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#L164](form/src/main/java/org/dhis2/form/ui/provider/inputfield/FieldProvider.kt#L164) | Code change | Displays the RAMP history chart directly under the matching input field | +| RAMP ACF-1: field history charts | [FormViewModel.kt#L313](form/src/main/java/org/dhis2/form/ui/FormViewModel.kt#L313) | Code change | Keeps the plotted current-event value live while the user edits a field | +| RAMP ACF-39,40,41: history tables | [EventHistoryTableRepository.kt#L19](app/src/main/java/org/dhis2/simprints/ramp/data/EventHistoryTableRepository.kt#L19) | 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 | [EventHistoryTableScreen.kt#L41](app/src/main/java/org/dhis2/simprints/ramp/ui/EventHistoryTableScreen.kt#L41) | New file | Renders the history table with visit columns, date row, section headers, loading, empty, and error states | +| RAMP ACF-39,40,41: history tables | [EventHistoryTableFragment.kt#L23](app/src/main/java/org/dhis2/simprints/ramp/ui/EventHistoryTableFragment.kt#L23) | New file | Shows the history table in event-capture and TEI dashboard contexts using event or enrollment arguments | +| RAMP ACF-39,40,41: history tables | [EventCaptureRepositoryImpl.java#L217](app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventCapture/EventCaptureRepositoryImpl.java#L217) | Code addition | Detects whether the current event's program stage has a configured RAMP history table | +| RAMP ACF-39,40,41: history tables | [EventCapturePagerAdapter.kt#L66](app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventCapture/EventCapturePagerAdapter.kt#L66) | Code change | Adds the event-capture History page and maps it to the table navigation item | +| RAMP ACF-39,40,41: history tables | [EventCapturePresenterImpl.kt#L155](app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventCapture/EventCapturePresenterImpl.kt#L155) | Code change | Adds the RAMP History navigation item to program stage (event) screens (where configured) | +| RAMP ACF-39,40,41: history tables | [EventCaptureActivity.kt#L278](app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventCapture/EventCaptureActivity.kt#L278) | 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#L817](app/src/main/java/org/dhis2/usescases/teiDashboard/DashboardRepositoryImpl.kt#L817) | Code addition | Detects whether the current enrollment program has a configured RAMP history table | +| RAMP ACF-39,40,41: history tables | [DashboardViewModel.kt#L170](app/src/main/java/org/dhis2/usescases/teiDashboard/DashboardViewModel.kt#L170) | Code addition | Adds the RAMP History navigation item to program enrollment screens (where configured) | +| RAMP ACF-39,40,41: history tables | [TeiDashboardMobileActivity.kt#L406](app/src/main/java/org/dhis2/usescases/teiDashboard/TeiDashboardMobileActivity.kt#L406) | Code addition | Opens the enrollment history table from the TEI dashboard and expands it to full width in landscape (to use space on tablets) | ### Releases From de8d3ce19fb823ec0e42e4109ab30fc5ba724967 Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 2 Jun 2026 14:49:37 +0100 Subject: [PATCH 06/12] Simprints RAMP: datastore exception handling fix --- .../simprints/ramp/repository/RampDatastoreRepository.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 1f4d36277fe..83b35415c21 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 @@ -63,7 +63,7 @@ class RampDatastoreRepository( .orEmpty() .filter { it.isValid() }, ) - } catch (exception: Exception) { + } catch (exception: JsonParseException) { Timber.e(exception, RAMP_DATASTORE_PARSE_ERROR) RampDatastoreConfig() } catch (exception: IllegalStateException) { From 5f2843f88e323dabb127005a7cb05df3e9d0fbeb Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 2 Jun 2026 15:12:07 +0100 Subject: [PATCH 07/12] Simprints RAMP: null handling fix in history table visibility calculation --- .../EventCaptureRepositoryImpl.java | 19 +++- .../EventCaptureRepositoryImplTest.kt | 90 +++++++++++++++++++ 2 files changed, 105 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventCapture/EventCaptureRepositoryImpl.java b/app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventCapture/EventCaptureRepositoryImpl.java index dee4c552f48..bbbb55d4067 100644 --- a/app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventCapture/EventCaptureRepositoryImpl.java +++ b/app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventCapture/EventCaptureRepositoryImpl.java @@ -220,14 +220,25 @@ public boolean hasSimprintsRampProgramStageHistoryTable() { } Event currentEvent = getCurrentEvent(); - hasSimprintsRampProgramStageHistoryTable = false; + if (currentEvent == null) { + return false; + } + + String programUid = trimToValue(currentEvent.program()); + String programStageUid = trimToValue(currentEvent.programStage()); + if (programUid == null || programStageUid == null) { + return false; + } + + boolean hasProgramStageHistoryTable = false; for (ProgramStageHistoryTableConfig config : new RampDatastoreRepository(d2, new Gson()).getConfig().getProgramStageHistoryTables()) { - if (Objects.equals(trimToValue(config.getProgramId()), currentEvent.program()) && - Objects.equals(trimToValue(config.getFollowUpVisitProgramStageId()), currentEvent.programStage())) { - hasSimprintsRampProgramStageHistoryTable = true; + if (Objects.equals(trimToValue(config.getProgramId()), programUid) && + Objects.equals(trimToValue(config.getFollowUpVisitProgramStageId()), programStageUid)) { + hasProgramStageHistoryTable = true; break; } } + hasSimprintsRampProgramStageHistoryTable = hasProgramStageHistoryTable; return hasSimprintsRampProgramStageHistoryTable; } diff --git a/app/src/test/java/org/dhis2/usescases/eventsWithoutRegistration/eventCapture/EventCaptureRepositoryImplTest.kt b/app/src/test/java/org/dhis2/usescases/eventsWithoutRegistration/eventCapture/EventCaptureRepositoryImplTest.kt index e79e8acf17a..6bdd8436996 100644 --- a/app/src/test/java/org/dhis2/usescases/eventsWithoutRegistration/eventCapture/EventCaptureRepositoryImplTest.kt +++ b/app/src/test/java/org/dhis2/usescases/eventsWithoutRegistration/eventCapture/EventCaptureRepositoryImplTest.kt @@ -6,6 +6,7 @@ import org.dhis2.data.dhislogic.AUTH_ALL import org.dhis2.data.dhislogic.AUTH_UNCOMPLETE_EVENT import org.hisp.dhis.android.core.D2 import org.hisp.dhis.android.core.dataelement.DataElement +import org.hisp.dhis.android.core.datastore.DataStoreEntry.builder import org.hisp.dhis.android.core.enrollment.Enrollment import org.hisp.dhis.android.core.enrollment.EnrollmentStatus import org.hisp.dhis.android.core.event.Event @@ -22,6 +23,7 @@ import org.junit.Test import org.mockito.Mockito import org.mockito.kotlin.any import org.mockito.kotlin.doReturn +import org.mockito.kotlin.doReturnConsecutively import org.mockito.kotlin.mock import org.mockito.kotlin.times import org.mockito.kotlin.verify @@ -617,6 +619,80 @@ class EventCaptureRepositoryImplTest { assertTrue(!repository.showCompletionPercentage()) } + @Test + fun `hasSimprintsRampProgramStageHistoryTable should return true if RAMP program stage history table is configured for event`() { + mockEvent() + stubRampConfigRawValue( + """ + { + "programStageHistoryTable": [ + { + "programId": " $testEventProgramUid ", + "followUpVisitProgramStageId": " $testEventStageUid ", + "followUpVisitMaxNumber": 3, + "headerVisitNumberDataElementId": "visitNumberUid" + } + ] + } + """.trimIndent(), + ) + + val repository = + EventCaptureRepositoryImpl( + eventUid, + d2, + ) + + assertTrue(repository.hasSimprintsRampProgramStageHistoryTable()) + } + + @Test + fun `hasSimprintsRampProgramStageHistoryTable should not cache false if event is unavailable for RAMP program stage history table`() { + whenever( + d2 + .eventModule() + .events() + .uid(eventUid) + .blockingGet(), + ) doReturnConsecutively + listOf( + null, + Event + .builder() + .uid(eventUid) + .programStage(testEventStageUid) + .eventDate(GregorianCalendar(2021, 0, 1).time) + .organisationUnit(testEventOrgUnitUid) + .deleted(false) + .status(EventStatus.ACTIVE) + .program(testEventProgramUid) + .build(), + ) + stubRampConfigRawValue( + """ + { + "programStageHistoryTable": [ + { + "programId": "$testEventProgramUid", + "followUpVisitProgramStageId": "$testEventStageUid", + "followUpVisitMaxNumber": 3, + "headerVisitNumberDataElementId": "visitNumberUid" + } + ] + } + """.trimIndent(), + ) + + val repository = + EventCaptureRepositoryImpl( + eventUid, + d2, + ) + + assertFalse(repository.hasSimprintsRampProgramStageHistoryTable()) + assertTrue(repository.hasSimprintsRampProgramStageHistoryTable()) + } + @Test fun `Should have analytics if there are indicators`() { mockEvent() @@ -692,6 +768,20 @@ class EventCaptureRepositoryImplTest { .build() } + private fun stubRampConfigRawValue(value: String) { + whenever( + d2 + .dataStoreModule() + .dataStore() + .value("simprints", "ramp") + .blockingGet(), + ) doReturn builder() + .namespace("simprints") + .key("ramp") + .value(value) + .build() + } + private fun mockEmptySections() { whenever( d2 From 872b7fef5fcf170b5531f5d1c26c40eca70a25ff Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 2 Jun 2026 16:43:18 +0100 Subject: [PATCH 08/12] Simprints RAMP-39,40,41: Yes/No instead of true/false in history tables --- .../ramp/data/EventHistoryTableRepository.kt | 19 +++++---- .../ramp/model/EventHistoryTableModels.kt | 23 +++++++++- .../ramp/ui/EventHistoryTableScreen.kt | 19 +++++++-- .../data/EventHistoryTableRepositoryTest.kt | 42 +++++++++++++++++-- 4 files changed, 87 insertions(+), 16 deletions(-) diff --git a/app/src/main/java/org/dhis2/simprints/ramp/data/EventHistoryTableRepository.kt b/app/src/main/java/org/dhis2/simprints/ramp/data/EventHistoryTableRepository.kt index 52da1cbdfbb..d7b2f65410c 100644 --- a/app/src/main/java/org/dhis2/simprints/ramp/data/EventHistoryTableRepository.kt +++ b/app/src/main/java/org/dhis2/simprints/ramp/data/EventHistoryTableRepository.kt @@ -4,6 +4,7 @@ import org.dhis2.bindings.userFriendlyValue import org.dhis2.commons.simprints.ramp.model.ProgramStageHistoryTableConfig import org.dhis2.commons.simprints.ramp.repository.RampDatastoreRepository import org.dhis2.simprints.ramp.model.EventHistoryTable +import org.dhis2.simprints.ramp.model.EventHistoryTableCell import org.dhis2.simprints.ramp.model.EventHistoryTableColumn import org.dhis2.simprints.ramp.model.EventHistoryTableRow import org.dhis2.simprints.ramp.model.EventHistoryTableSection @@ -76,13 +77,17 @@ class EventHistoryTableRepository( values = columnIndexes.map { columnIndex -> val event = eventForColumn(columnIndex) - row.displayValue( - rawValue = - event - ?.let { eventDataValuesByUid[it.uid()] } - ?.get(row.dataElementUid) - .orEmpty(), - optionDisplayNamesBySet = optionDisplayNamesBySet, + EventHistoryTableCell( + value = + row.displayValue( + rawValue = + event + ?.let { eventDataValuesByUid[it.uid()] } + ?.get(row.dataElementUid) + .orEmpty(), + optionDisplayNamesBySet = optionDisplayNamesBySet, + ), + valueType = row.valueType, ) }, ) diff --git a/app/src/main/java/org/dhis2/simprints/ramp/model/EventHistoryTableModels.kt b/app/src/main/java/org/dhis2/simprints/ramp/model/EventHistoryTableModels.kt index 4140e8ec68b..657d1a7b6d1 100644 --- a/app/src/main/java/org/dhis2/simprints/ramp/model/EventHistoryTableModels.kt +++ b/app/src/main/java/org/dhis2/simprints/ramp/model/EventHistoryTableModels.kt @@ -1,5 +1,7 @@ package org.dhis2.simprints.ramp.model +import org.hisp.dhis.android.core.common.ValueType + sealed interface EventHistoryTableUiState { data object Loading : EventHistoryTableUiState @@ -32,5 +34,24 @@ data class EventHistoryTableSection( data class EventHistoryTableRow( val label: String, - val values: List, + val values: List, ) + +data class EventHistoryTableCell( + val value: String, + val valueType: ValueType? = null, +) { + fun displayValue( + yesLabel: String, + noLabel: String, + ): String = + if (valueType == ValueType.BOOLEAN || valueType == ValueType.TRUE_ONLY) { + when (value.toBooleanStrictOrNull()) { + true -> yesLabel + false -> noLabel + null -> value + } + } else { + value + } +} 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 6503c46c6fb..e4058c417ea 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 @@ -30,12 +30,14 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import org.dhis2.R import org.dhis2.simprints.ramp.model.EventHistoryTable +import org.dhis2.simprints.ramp.model.EventHistoryTableCell import org.dhis2.simprints.ramp.model.EventHistoryTableColumn import org.dhis2.simprints.ramp.model.EventHistoryTableRow import org.dhis2.simprints.ramp.model.EventHistoryTableUiState import org.hisp.dhis.mobile.ui.designsystem.component.ProgressIndicator import org.hisp.dhis.mobile.ui.designsystem.component.ProgressIndicatorType import org.hisp.dhis.mobile.ui.designsystem.theme.Spacing +import org.dhis2.commons.R as CommonsR @Composable fun EventHistoryTableScreen( @@ -100,7 +102,10 @@ private fun HistoryTable( row = EventHistoryTableRow( label = stringResource(R.string.simprints_ramp_history_table_date), - values = table.dateRowValues, + values = + table.dateRowValues.map { value -> + EventHistoryTableCell(value) + }, ), horizontalScrollState = horizontalScrollState, ) @@ -183,9 +188,9 @@ private fun TableDataRow( overscrollEffect = null, ), ) { - row.values.forEach { value -> + row.values.forEach { cell -> ValueCell( - text = value, + cell = cell, width = DataCellWidth, ) } @@ -247,9 +252,15 @@ private fun LabelCell( @Composable private fun ValueCell( - text: String, + cell: EventHistoryTableCell, width: Dp, ) { + val text = + cell.displayValue( + yesLabel = stringResource(CommonsR.string.yes), + noLabel = stringResource(CommonsR.string.no), + ) + Box( modifier = Modifier diff --git a/app/src/test/java/org/dhis2/simprints/ramp/data/EventHistoryTableRepositoryTest.kt b/app/src/test/java/org/dhis2/simprints/ramp/data/EventHistoryTableRepositoryTest.kt index bac904bc234..35cc534220a 100644 --- a/app/src/test/java/org/dhis2/simprints/ramp/data/EventHistoryTableRepositoryTest.kt +++ b/app/src/test/java/org/dhis2/simprints/ramp/data/EventHistoryTableRepositoryTest.kt @@ -66,6 +66,7 @@ class EventHistoryTableRepositoryTest { value(VISIT_NUMBER_UID, "1"), value(WEIGHT_UID, "9.0"), value(STATUS_UID, "A"), + value(BOOLEAN_UID, "true"), ), ), ) @@ -80,6 +81,7 @@ class EventHistoryTableRepositoryTest { value(VISIT_NUMBER_UID, "0"), value(WEIGHT_UID, "8.0"), value(STATUS_UID, "A"), + value(BOOLEAN_UID, "true"), ), ), event( @@ -90,6 +92,7 @@ class EventHistoryTableRepositoryTest { value(VISIT_NUMBER_UID, "0"), value(WEIGHT_UID, "8.5"), value(STATUS_UID, "B"), + value(BOOLEAN_UID, "false"), ), ), event( @@ -100,6 +103,7 @@ class EventHistoryTableRepositoryTest { value(VISIT_NUMBER_UID, "2"), value(WEIGHT_UID, "10.0"), value(STATUS_UID, "A"), + value(BOOLEAN_UID, "true"), ), ), ), @@ -119,7 +123,7 @@ class EventHistoryTableRepositoryTest { assertNull(table?.columns?.get(2)?.eventUid) assertEquals(listOf("Follow up"), table?.sections?.map { it.title }) assertEquals( - listOf("Weight", "Status"), + listOf("Weight", "Status", "Confirmed"), table ?.sections ?.single() @@ -133,7 +137,8 @@ class EventHistoryTableRepositoryTest { ?.single() ?.rows ?.get(0) - ?.values, + ?.values + ?.map { it.value }, ) // Visit 0 keeps the later duplicate event with OptionB assertEquals( @@ -143,7 +148,19 @@ class EventHistoryTableRepositoryTest { ?.single() ?.rows ?.get(1) - ?.values, + ?.values + ?.map { it.value }, + ) + val booleanRowValues = + table + ?.sections + ?.single() + ?.rows + ?.get(2) + ?.values + assertEquals( + listOf("No", "Yes", "", ""), + booleanRowValues?.map { it.displayValue(yesLabel = "Yes", noLabel = "No") }, ) } @@ -183,6 +200,7 @@ class EventHistoryTableRepositoryTest { ?.rows ?.first() ?.values + ?.map { it.value } ?.get(2), ) } @@ -226,6 +244,7 @@ class EventHistoryTableRepositoryTest { val programStage = ObjectWithUid.create(PROGRAM_STAGE_UID) val weightDataElement = dataElement(WEIGHT_UID) val statusDataElement = dataElement(STATUS_UID) + val booleanDataElement = dataElement(BOOLEAN_UID) val excludedDataElement = dataElement(EXCLUDED_UID) val visitNumberDataElement = dataElement(VISIT_NUMBER_UID) val programStageDataElementsForStage = @@ -233,6 +252,7 @@ class EventHistoryTableRepositoryTest { programStageDataElement(programStage, visitNumberDataElement), programStageDataElement(programStage, weightDataElement), programStageDataElement(programStage, statusDataElement), + programStageDataElement(programStage, booleanDataElement), programStageDataElement(programStage, excludedDataElement), ) @@ -256,7 +276,7 @@ class EventHistoryTableRepositoryTest { .displayName("Follow up") .sortOrder(1) .programStage(programStage) - .dataElements(listOf(weightDataElement, statusDataElement, excludedDataElement)) + .dataElements(listOf(weightDataElement, statusDataElement, booleanDataElement, excludedDataElement)) .build(), ProgramStageSection .builder() @@ -295,6 +315,19 @@ class EventHistoryTableRepositoryTest { .valueType(ValueType.TEXT) .optionSet(ObjectWithUid.create(OPTION_SET_UID)) .build() + whenever( + d2 + .dataElementModule() + .dataElements() + .uid(BOOLEAN_UID) + .blockingGet(), + ) doReturn + DataElement + .builder() + .uid(BOOLEAN_UID) + .displayShortName("Confirmed") + .valueType(ValueType.BOOLEAN) + .build() whenever( d2 .dataElementModule() @@ -377,6 +410,7 @@ class EventHistoryTableRepositoryTest { const val VISIT_NUMBER_UID = "visit-number" const val WEIGHT_UID = "weight" const val STATUS_UID = "status" + const val BOOLEAN_UID = "boolean" const val EXCLUDED_UID = "excluded" const val OPTION_SET_UID = "option-set" } From 98ffbe08d713b1c1840829659672f1975d5ce48d Mon Sep 17 00:00:00 2001 From: Alex Date: Wed, 3 Jun 2026 17:29:20 +0100 Subject: [PATCH 09/12] Simprints RAMP datastore sync exceptions considerations: logging sync errors, test case. --- .../dhis2/data/service/SyncPresenterImpl.kt | 4 +- .../dhis2/data/services/SyncPresenterTest.kt | 37 +++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/org/dhis2/data/service/SyncPresenterImpl.kt b/app/src/main/java/org/dhis2/data/service/SyncPresenterImpl.kt index 32f726cc145..164eccceb00 100644 --- a/app/src/main/java/org/dhis2/data/service/SyncPresenterImpl.kt +++ b/app/src/main/java/org/dhis2/data/service/SyncPresenterImpl.kt @@ -269,7 +269,9 @@ class SyncPresenterImpl( .download(), ), ).andThen( - Completable.fromAction { simprintsRampDatastoreRepository.sync() }, + Completable + .fromAction { simprintsRampDatastoreRepository.sync() } + .doOnError { Timber.e(it, "Error syncing Simprints RAMP datastore") }, ).blockingAwait() } diff --git a/app/src/test/java/org/dhis2/data/services/SyncPresenterTest.kt b/app/src/test/java/org/dhis2/data/services/SyncPresenterTest.kt index f32d2ea6e45..0803f07bf51 100644 --- a/app/src/test/java/org/dhis2/data/services/SyncPresenterTest.kt +++ b/app/src/test/java/org/dhis2/data/services/SyncPresenterTest.kt @@ -31,12 +31,14 @@ import org.hisp.dhis.android.core.settings.ProgramSetting import org.hisp.dhis.android.core.settings.ProgramSettings import org.hisp.dhis.android.core.trackedentity.TrackedEntityInstance import org.hisp.dhis.android.core.tracker.exporter.TrackerD2Progress +import org.junit.Assert.assertThrows import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import org.mockito.Mockito import org.mockito.kotlin.any import org.mockito.kotlin.doReturn +import org.mockito.kotlin.doThrow import org.mockito.kotlin.mock import org.mockito.kotlin.times import org.mockito.kotlin.verify @@ -284,6 +286,41 @@ class SyncPresenterTest { verify(simprintsRampDatastoreRepository).sync() } + @Test + fun `Should throw when simprints ramp datastore sync fails`() { + whenever( + d2.metadataModule().download(), + ) doReturn fromArray(BaseD2Progress.empty(2)) + whenever( + d2.settingModule().generalSetting().blockingGet(), + ) doReturn + builder() + .encryptDB(false) + .build() + whenever( + d2.mapsModule().mapLayersDownloader().downloadMetadata(), + ) doReturn complete() + whenever( + d2 + .fileResourceModule() + .fileResourceDownloader() + .byDomainType() + .eq(FileResourceDomainType.ICON) + .download(), + ) doReturn just(BaseD2Progress.empty(1)) + + val syncError = RuntimeException("network down") + whenever(simprintsRampDatastoreRepository.sync()) doThrow syncError + + val thrown = + assertThrows(RuntimeException::class.java) { + presenter.syncMetadata { } + } + + assertTrue(thrown === syncError) + verify(simprintsRampDatastoreRepository).sync() + } + @Test fun `Should return successfully SYNC if tei enrollment and events are ok`() { whenever( From c50287dab087c3470eb727b3f36297d9deeadc57 Mon Sep 17 00:00:00 2001 From: Alex Date: Wed, 3 Jun 2026 18:14:24 +0100 Subject: [PATCH 10/12] Simprints RAMP ACF-1 chart realtime updates on typing done locally for its field. --- .../java/org/dhis2/form/ui/FormViewModel.kt | 15 +---------- .../ui/provider/inputfield/FieldProvider.kt | 27 +++++++++++++++---- .../org/dhis2/form/ui/FormViewModelTest.kt | 14 +++++++--- 3 files changed, 34 insertions(+), 22 deletions(-) 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 e379a14d33e..e1eb21e6108 100644 --- a/form/src/main/java/org/dhis2/form/ui/FormViewModel.kt +++ b/form/src/main/java/org/dhis2/form/ui/FormViewModel.kt @@ -301,26 +301,13 @@ class FormViewModel( } private fun handleOnTextChangeAction(action: RowAction): StoreResult { - updateSimprintsRampHistoryChart( - repository.updateValueOnList(action.id, action.value, action.valueType), - ) + repository.updateValueOnList(action.id, action.value, action.valueType) return StoreResult( action.id, ValueStoreResult.TEXT_CHANGING, ) } - private fun updateSimprintsRampHistoryChart(updatedField: FieldUiModel?) { - val chartField = updatedField?.takeIf { it.simprintsRampHistoryChart != null } ?: return - val currentItems = _items.value ?: return - - _items.postValue( - currentItems.map { item -> - if (item.uid == chartField.uid) chartField else item - }, - ) - } - private fun handleOnSectionChangeAction(action: RowAction): StoreResult { repository.updateSectionOpened(action) return StoreResult( diff --git a/form/src/main/java/org/dhis2/form/ui/provider/inputfield/FieldProvider.kt b/form/src/main/java/org/dhis2/form/ui/provider/inputfield/FieldProvider.kt index 198c1b38561..9c3bd975e6a 100644 --- a/form/src/main/java/org/dhis2/form/ui/provider/inputfield/FieldProvider.kt +++ b/form/src/main/java/org/dhis2/form/ui/provider/inputfield/FieldProvider.kt @@ -76,6 +76,23 @@ fun FieldProvider( var visibleArea by remember { mutableStateOf(Rect.Zero) } val scope = rememberCoroutineScope() val keyboardState by keyboardAsState() + var simprintsRampHistoryChart by remember( + fieldUiModel.uid, + fieldUiModel.simprintsRampHistoryChart, + ) { + mutableStateOf(fieldUiModel.simprintsRampHistoryChart) + } + val fieldIntentHandler: (FormIntent) -> Unit = + if (fieldUiModel.simprintsRampHistoryChart == null) { + intentHandler + } else { + { intent -> + if (intent is FormIntent.OnTextChange && intent.uid == fieldUiModel.uid) { + simprintsRampHistoryChart = simprintsRampHistoryChart?.withCurrentValue(intent.value) + } + intentHandler(intent) + } + } var modifierWithFocus = modifier @@ -116,9 +133,9 @@ fun FieldProvider( modifier = modifierWithFocus, inputStyle = inputStyle, fieldUiModel = fieldUiModel, - intentHandler = intentHandler, + intentHandler = fieldIntentHandler, fetchOptions = { - intentHandler( + fieldIntentHandler( FormIntent.FetchOptions( fieldUiModel.uid, fieldUiModel.optionSet!!, @@ -131,7 +148,7 @@ fun FieldProvider( fieldUiModel.customIntent != null -> { ProvideCustomIntentInput( fieldUiModel = fieldUiModel, - intentHandler = intentHandler, + intentHandler = fieldIntentHandler, uiEventHandler = uiEventHandler, resources = resources, inputStyle = inputStyle, @@ -152,7 +169,7 @@ fun FieldProvider( modifier = modifierWithFocus, inputStyle = inputStyle, fieldUiModel = fieldUiModel, - intentHandler = intentHandler, + intentHandler = fieldIntentHandler, uiEventHandler = uiEventHandler, resources = resources, focusRequester = focusRequester, @@ -161,7 +178,7 @@ fun FieldProvider( onFileSelected = onFileSelected, ) } - fieldUiModel.simprintsRampHistoryChart?.let { SimprintsRampFormHistoryChartView(it) } + simprintsRampHistoryChart?.let { SimprintsRampFormHistoryChartView(it) } } } 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 5214987aaa5..eca5c6396ca 100644 --- a/form/src/test/java/org/dhis2/form/ui/FormViewModelTest.kt +++ b/form/src/test/java/org/dhis2/form/ui/FormViewModelTest.kt @@ -2,6 +2,7 @@ package org.dhis2.form.ui import android.content.Intent import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import androidx.lifecycle.Observer import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.StandardTestDispatcher @@ -126,7 +127,7 @@ class FormViewModelTest { } @Test - fun `Should publish updated simprints ramp chart field when text is changing`() = + fun `Should not emit form items when simprints ramp chart field text is changing`() = runTest { val initialField = FieldUiModelImpl( @@ -153,15 +154,22 @@ 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() viewModel.submitIntent(FormIntent.OnTextChange("weight", "", ValueType.NUMBER)) advanceUntilIdle() - assertEquals(updatedField, viewModel.items.value?.first()) + assertTrue(emittedItems.isEmpty()) + assertEquals(initialField, viewModel.items.value?.first()) verify(repository).updateValueOnList("weight", "", ValueType.NUMBER) + + viewModel.items.removeObserver(itemsObserver) } private val futureDate: String = LocalDate.now().plusDays(1).format(DateTimeFormatter.ISO_DATE) From f154ad5704d7d85e60f39d1fa8b83fb294fbbf36 Mon Sep 17 00:00:00 2001 From: Alex Date: Wed, 3 Jun 2026 18:35:32 +0100 Subject: [PATCH 11/12] Simprints biometric search launch made non-replayable --- .../searchTrackEntity/SearchTEIViewModel.kt | 7 ++++--- .../SearchTEIViewModelTest.kt | 18 ++++++++++++++++++ 2 files changed, 22 insertions(+), 3 deletions(-) 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 807287ff535..74bc1e7a49b 100644 --- a/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEIViewModel.kt +++ b/app/src/main/java/org/dhis2/usescases/searchTrackEntity/SearchTEIViewModel.kt @@ -25,6 +25,7 @@ import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.emitAll import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.flatMapLatest @@ -151,9 +152,9 @@ class SearchTEIViewModel( val isScrollingDown = MutableLiveData(false) val simprintsBiometricSearchNavigation: Flow = simprintsSearchViewModel.simprintsBiometricSearchNavigation - private val _simprintsBiometricIdentificationLaunch = Channel(Channel.BUFFERED) + private val _simprintsBiometricIdentificationLaunch = MutableSharedFlow(replay = 0) val simprintsBiometricIdentificationLaunch: Flow = - _simprintsBiometricIdentificationLaunch.receiveAsFlow() + _simprintsBiometricIdentificationLaunch.asSharedFlow() val isSimprintsBiometricSearch: LiveData = simprintsSearchViewModel.isSimprintsBiometricSearch val isSimprintsUseLastBiometricsLabel: LiveData = @@ -418,7 +419,7 @@ class SearchTEIViewModel( if (shouldLaunchSimprintsBiometricIdentification()) { simprintsSearchViewModel.clearPendingSession() viewModelScope.launch { - _simprintsBiometricIdentificationLaunch.send(Unit) + _simprintsBiometricIdentificationLaunch.emit(Unit) } } else { setSearchScreen() diff --git a/app/src/test/java/org/dhis2/usescases/searchTrackEntity/SearchTEIViewModelTest.kt b/app/src/test/java/org/dhis2/usescases/searchTrackEntity/SearchTEIViewModelTest.kt index 45dfff1bdb3..cfcce366651 100644 --- a/app/src/test/java/org/dhis2/usescases/searchTrackEntity/SearchTEIViewModelTest.kt +++ b/app/src/test/java/org/dhis2/usescases/searchTrackEntity/SearchTEIViewModelTest.kt @@ -231,6 +231,24 @@ class SearchTEIViewModelTest { } } + @Test + fun `Should not replay Simprints biometric identification launch when no collector is listening`() = + runTest { + viewModel.setListScreen() + viewModel.searchParametersUiState = + viewModel.searchParametersUiState.copy( + items = listOf(simprintsBiometricSearchField()), + ) + + viewModel.onSearchFormRequested() + testingDispatcher.scheduler.advanceUntilIdle() + + viewModel.simprintsBiometricIdentificationLaunch.test { + expectNoEvents() + cancelAndIgnoreRemainingEvents() + } + } + @Test fun `Should open search form when program does not have Simprints biometric search`() { viewModel.searchParametersUiState = From 75454e947f5d0f6479664c5e39c37ed253a40690 Mon Sep 17 00:00:00 2001 From: Alex Date: Wed, 3 Jun 2026 19:41:15 +0100 Subject: [PATCH 12/12] Simprints RAMP ACF-39,40,41 table inquiry optimized: datastore repository pre-initialized --- .../eventCapture/EventCaptureRepositoryImpl.java | 12 ++++-------- .../teiDashboard/DashboardRepositoryImpl.kt | 4 +++- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventCapture/EventCaptureRepositoryImpl.java b/app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventCapture/EventCaptureRepositoryImpl.java index bbbb55d4067..40a1492b71b 100644 --- a/app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventCapture/EventCaptureRepositoryImpl.java +++ b/app/src/main/java/org/dhis2/usescases/eventsWithoutRegistration/eventCapture/EventCaptureRepositoryImpl.java @@ -36,11 +36,12 @@ public class EventCaptureRepositoryImpl implements EventCaptureContract.EventCap private final String eventUid; private final D2 d2; - private Boolean hasSimprintsRampProgramStageHistoryTable; + private final RampDatastoreRepository rampDatastoreRepository; public EventCaptureRepositoryImpl(String eventUid, D2 d2) { this.eventUid = eventUid; this.d2 = d2; + this.rampDatastoreRepository = new RampDatastoreRepository(d2, new Gson()); } private Event getCurrentEvent() { @@ -215,10 +216,6 @@ public boolean hasRelationships() { @Override public boolean hasSimprintsRampProgramStageHistoryTable() { - if (hasSimprintsRampProgramStageHistoryTable != null) { - return hasSimprintsRampProgramStageHistoryTable; - } - Event currentEvent = getCurrentEvent(); if (currentEvent == null) { return false; @@ -231,15 +228,14 @@ public boolean hasSimprintsRampProgramStageHistoryTable() { } boolean hasProgramStageHistoryTable = false; - for (ProgramStageHistoryTableConfig config : new RampDatastoreRepository(d2, new Gson()).getConfig().getProgramStageHistoryTables()) { + for (ProgramStageHistoryTableConfig config : rampDatastoreRepository.getConfig().getProgramStageHistoryTables()) { if (Objects.equals(trimToValue(config.getProgramId()), programUid) && Objects.equals(trimToValue(config.getFollowUpVisitProgramStageId()), programStageUid)) { hasProgramStageHistoryTable = true; break; } } - hasSimprintsRampProgramStageHistoryTable = hasProgramStageHistoryTable; - return hasSimprintsRampProgramStageHistoryTable; + return hasProgramStageHistoryTable; } @NonNull 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 ca280d77c45..c55cd32356d 100644 --- a/app/src/main/java/org/dhis2/usescases/teiDashboard/DashboardRepositoryImpl.kt +++ b/app/src/main/java/org/dhis2/usescases/teiDashboard/DashboardRepositoryImpl.kt @@ -52,6 +52,8 @@ class DashboardRepositoryImpl( private val programConfigurationRepository: ProgramConfigurationRepository, private val featureConfigRepository: FeatureConfigRepository, ) : DashboardRepository { + private val simprintsRampDatastoreRepository = SimprintsRampDatastoreRepository(d2) + override fun getTeiHeader(): String? = d2 .trackedEntityModule() @@ -818,7 +820,7 @@ class DashboardRepositoryImpl( try { !programUid.isNullOrBlank() && !enrollmentUid.isNullOrBlank() && - SimprintsRampDatastoreRepository(d2) + simprintsRampDatastoreRepository .getConfig() .programStageHistoryTables .any { config -> config.programId?.trim() == programUid }