Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
f70e08c
Simprints RAMP: Option from datastore to hide unfiltered list items i…
alex-vt Aug 4, 2026
e202661
Simprints RAMP: Search button direct to biometrics in landscape
alex-vt Aug 4, 2026
38eb6b1
Simprints RAMP: Moved program enrolment detailed listings from TEI li…
alex-vt Aug 10, 2026
4d0d75d
Simprints RAMP: No match user-visible placeholder text simplified
alex-vt Aug 12, 2026
01d9541
Simprints RAMP: No match (from SID) search screen auto-skip
alex-vt Aug 12, 2026
3d705d5
Simprints RAMP: Textual value history graphs with Y-axis inverted ord…
alex-vt Aug 13, 2026
8c2b52f
Simprints RAMP: Org unit UID as moduleId in Identification
alex-vt Aug 14, 2026
fbbffa2
Simprints RAMP: 1-level-up org unit UID as moduleId
alex-vt Aug 16, 2026
f18ce00
Simprints RAMP: Visit scheduling logic anchored to visit 0 for select…
alex-vt Aug 27, 2026
c4f5193
Simprints RAMP: Biometrics capture-only custom button
alex-vt Aug 31, 2026
3819e45
Simprints RAMP: Quick toggle filters for Overdue and With sync error …
alex-vt Sep 1, 2026
2048586
Simprints RAMP: External credential (QR code) saving
alex-vt Sep 8, 2026
91bb9dd
Simprints RAMP: Full list and ascending order for follow-up visits
alex-vt Sep 9, 2026
c110e61
Simprints RAMP: Module ID prefixes for specific programs
alex-vt Sep 15, 2026
904fce7
Simprints RAMP: Chart table custom visit column label & initial visit…
alex-vt Sep 23, 2026
d734992
Simprints RAMP: External credential (QR code) saving fix
alex-vt Sep 23, 2026
aab4031
Simprints RAMP: Biometrics capture-only button in-section more robust…
alex-vt Sep 24, 2026
9227fb9
Simprints RAMP: Follow-up visit listing performance improvement
alex-vt Sep 24, 2026
e5b9e46
Simprints RAMP: External credential save failure no longer blocks bio…
alex-vt Oct 1, 2026
423e782
Simprints RAMP: Biometrics capture only button follows program rules …
alex-vt Oct 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 56 additions & 33 deletions README.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,12 @@ package org.dhis2.simprints
import android.app.Activity.RESULT_OK
import android.content.Intent
import androidx.lifecycle.ViewModel
import kotlinx.coroutines.CancellationException
import org.dhis2.commons.simprints.repository.SimprintsD2Repository
import org.dhis2.commons.simprints.repository.SimprintsSessionRepository
import org.dhis2.commons.simprints.usecases.SimprintsResolvePendingEnrollmentActionUseCase
import org.dhis2.commons.simprints.utils.SimprintsExternalCredentialUtils
import timber.log.Timber
import kotlin.concurrent.atomics.AtomicReference
import kotlin.concurrent.atomics.ExperimentalAtomicApi

Expand Down Expand Up @@ -87,6 +90,25 @@ class SimprintsEnrollmentViewModel(
attributeUid = resolvedAction.fieldUid,
value = value,
)
val externalCredentialValue =
SimprintsExternalCredentialUtils.enrollmentExternalCredentialValue(
resolvedAction.callout.launchIntent.action,
value,
data?.extras,
)
if (externalCredentialValue != null && enrollmentUid != null) {
try {
simprintsD2Repository.saveEnrollmentExternalCredential(
enrollmentUid = enrollmentUid,
biometricAttributeUid = resolvedAction.fieldUid,
externalCredentialValue = externalCredentialValue,
)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Timber.e(e, "Unable to save Simprints external credential after enrollment")
}
}
sessionRepository.clear()
RegisterLastResult.CONTINUE_FINISH
}
Expand Down
67 changes: 59 additions & 8 deletions app/src/main/java/org/dhis2/simprints/SimprintsSearchViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,23 @@ package org.dhis2.simprints

import android.app.Activity.RESULT_OK
import android.content.Intent
import android.os.Bundle
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.receiveAsFlow
import org.dhis2.commons.simprints.repository.SimprintsD2Repository
import org.dhis2.commons.simprints.repository.SimprintsSessionRepository
import org.dhis2.commons.simprints.usecases.SimprintsResolveConfirmIdentityCalloutUseCase
import org.dhis2.commons.simprints.utils.SimprintsExternalCredentialUtils
import org.dhis2.commons.simprints.utils.SimprintsIntentUtils
import org.dhis2.commons.simprints.utils.SimprintsSearchUtils
import org.dhis2.form.model.FieldUiModel
import org.dhis2.form.model.FieldUiModelImpl
import timber.log.Timber
import kotlin.concurrent.atomics.AtomicReference
import kotlin.concurrent.atomics.ExperimentalAtomicApi

Expand All @@ -22,6 +27,7 @@ class SimprintsSearchViewModel(
private val resolveConfirmIdentityCallout: SimprintsResolveConfirmIdentityCalloutUseCase,
private val sessionRepository: SimprintsSessionRepository,
private val resolveSingleBiometricSearchNavigation: SimprintsResolveSingleBiometricSearchNavigationUseCase,
private val simprintsD2Repository: SimprintsD2Repository,
) : ViewModel() {
private data class PendingSimprintsMfidBiometricIdentification(
val uid: String,
Expand All @@ -33,6 +39,7 @@ class SimprintsSearchViewModel(
val programUid: String?,
val enrollmentUid: String?,
val isOnline: Boolean = false,
val biometricAttributeUid: String? = null,
)

sealed class DashboardAction {
Expand All @@ -46,6 +53,7 @@ class SimprintsSearchViewModel(
}

private val pendingDashboardNavigation = AtomicReference<PendingDashboardNavigation?>(null)
private var confirmIdentityStateRestored = false
private val pendingSimprintsMfidBiometricIdentification =
AtomicReference<PendingSimprintsMfidBiometricIdentification?>(null)
private var useLastBiometricsForSequentialSearch = false
Expand All @@ -71,23 +79,24 @@ class SimprintsSearchViewModel(
sessionRepository
.get()
?.takeIf { searchState.hasBiometricIdentificationQuery || useSequentialSearchLastBiometrics }
val confirmIdentityIntent =
val confirmIdentityCallout =
sessionId?.let {
resolveConfirmIdentityCallout(
teiUid = teiUid,
searchFields = searchFields,
sessionId = it,
allowBlankSearchValue = useSequentialSearchLastBiometrics,
)?.launchIntent
)
}

val navigation =
PendingDashboardNavigation(
teiUid = teiUid,
programUid = programUid,
enrollmentUid = enrollmentUid,
biometricAttributeUid = confirmIdentityCallout?.biometricAttributeUid,
)
if (confirmIdentityIntent == null) {
if (confirmIdentityCallout == null) {
return DashboardAction.OpenDashboard(navigation)
}

Expand All @@ -97,7 +106,7 @@ class SimprintsSearchViewModel(
useLastBiometricsForSequentialSearch = false
sessionRepository.clear()
}
return DashboardAction.LaunchConfirmIdentity(confirmIdentityIntent)
return DashboardAction.LaunchConfirmIdentity(confirmIdentityCallout.launchIntent)
}

fun prepareEnrollmentQueryData(
Expand All @@ -124,10 +133,52 @@ class SimprintsSearchViewModel(
sessionRepository.markPendingEnrollmentFromPossibleDuplicates()
}

fun onConfirmIdentityResult(resultCode: Int): PendingDashboardNavigation? =
pendingDashboardNavigation
.exchange(null)
?.takeIf { resultCode == RESULT_OK }
fun savePendingConfirmIdentity(outState: Bundle) {
val navigation = pendingDashboardNavigation.load()
outState.putString("simprints.confirmIdentity.teiUid", navigation?.teiUid)
outState.putString("simprints.confirmIdentity.programUid", navigation?.programUid)
outState.putString("simprints.confirmIdentity.enrollmentUid", navigation?.enrollmentUid)
outState.putString("simprints.confirmIdentity.biometricAttributeUid", navigation?.biometricAttributeUid)
}

fun restorePendingConfirmIdentity(savedState: Bundle?) {
if (confirmIdentityStateRestored) return
confirmIdentityStateRestored = true
val teiUid = savedState?.getString("simprints.confirmIdentity.teiUid")?.takeIf(String::isNotBlank) ?: return
pendingDashboardNavigation.compareAndSet(
null,
PendingDashboardNavigation(
teiUid = teiUid,
programUid = savedState.getString("simprints.confirmIdentity.programUid"),
enrollmentUid = savedState.getString("simprints.confirmIdentity.enrollmentUid"),
biometricAttributeUid = savedState.getString("simprints.confirmIdentity.biometricAttributeUid"),
),
)
}

suspend fun onConfirmIdentityResult(
resultCode: Int,
data: Intent? = null,
): PendingDashboardNavigation? {
val navigation = pendingDashboardNavigation.exchange(null) ?: return null

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The pending navigation is cleared (exchange(null)) before saveExternalCredential runs. If the save throws, SearchTEIViewModel.onConfirmIdentityResult catches it and shows custom_intent_error, but the navigation is already gone. The user confirmed their identity in SID and still ends up stuck on the search screen.

The credential is optional, so I'd wrap the save in its own try/catch (log it and carry on) and always return navigation when resultCode == RESULT_OK. Also, the Timber.e(...) in the SearchTEIViewModel catch doesn't pass the exception, so the cause is lost. Timber.e(e, "...") would keep it.

if (resultCode != RESULT_OK) return null

SimprintsExternalCredentialUtils.externalCredentialValue(data?.extras)?.let { externalCredentialValue ->
try {
simprintsD2Repository.saveExternalCredential(
teiUid = navigation.teiUid,
programUid = navigation.programUid,
biometricAttributeUid = navigation.biometricAttributeUid,
externalCredentialValue = externalCredentialValue,
)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Timber.e(e, "Unable to save Simprints external credential after identity confirmation")
}
}
return navigation
}

fun onConfirmIdentityLaunchFailed() {
pendingDashboardNavigation.store(null)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package org.dhis2.simprints.di

import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import org.dhis2.commons.simprints.repository.SimprintsD2Repository
import org.dhis2.commons.simprints.repository.SimprintsSessionRepository
import org.dhis2.commons.simprints.usecases.SimprintsResolveConfirmIdentityCalloutUseCase
import org.dhis2.simprints.SimprintsResolveSingleBiometricSearchNavigationUseCase
Expand All @@ -11,11 +12,13 @@ class SimprintsSearchViewModelFactory(
private val resolveConfirmIdentityCallout: SimprintsResolveConfirmIdentityCalloutUseCase,
private val sessionRepository: SimprintsSessionRepository,
private val resolveSingleBiometricSearchNavigation: SimprintsResolveSingleBiometricSearchNavigationUseCase,
private val simprintsD2Repository: SimprintsD2Repository,
) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T =
SimprintsSearchViewModel(
resolveConfirmIdentityCallout = resolveConfirmIdentityCallout,
sessionRepository = sessionRepository,
resolveSingleBiometricSearchNavigation = resolveSingleBiometricSearchNavigation,
simprintsD2Repository = simprintsD2Repository,
) as T
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,46 +3,39 @@ package org.dhis2.simprints.ramp.data
import org.dhis2.bindings.userFriendlyValue
import org.dhis2.commons.simprints.ramp.model.DetailedEnrollmentListingSettings
import org.dhis2.simprints.ramp.model.DetailedEnrollment
import org.dhis2.tracker.search.model.DomainEnrollment
import org.dhis2.tracker.search.model.DomainProgram
import org.hisp.dhis.android.core.D2
import org.hisp.dhis.android.core.enrollment.Enrollment
import org.hisp.dhis.android.core.event.Event
import java.util.Date

internal class DetailedEnrollmentRepository(
private val d2: D2,
) {
fun get(
enrollments: List<DomainEnrollment>?,
programs: List<DomainProgram>?,
enrollments: List<Enrollment>,
settings: DetailedEnrollmentListingSettings,
excludedProgramUid: String? = null,
): List<DetailedEnrollment> {
val includedEnrollments = enrollments?.filter { it.program != excludedProgramUid }.orEmpty()
if (includedEnrollments.isEmpty() || programs.isNullOrEmpty()) return emptyList()
): Map<String, DetailedEnrollment> {
val includedEnrollments = enrollments.filter { !it.uid().isNullOrBlank() }
if (includedEnrollments.isEmpty()) return emptyMap()

val programNames = programs.associate { it.uid to it.displayName }
val siteNames = getSiteNames(includedEnrollments)
val eventDetails = getEventDetails(includedEnrollments, settings)

return includedEnrollments.mapNotNull { enrollment ->
programNames[enrollment.program]
?.takeIf { it.isNotBlank() }
?.let { programName ->
val details = eventDetails[enrollment.uid]
DetailedEnrollment(
programName = programName,
admitted = details?.admitted,
discharge = details?.discharge,
outcome = details?.outcome,
site = siteNames[enrollment.orgUnit],
)
}
return includedEnrollments.associate { enrollment ->
val enrollmentUid = enrollment.uid()
val details = eventDetails[enrollmentUid]
enrollmentUid to
DetailedEnrollment(
admitted = details?.admitted,
discharge = details?.discharge,
outcome = details?.outcome,
site = siteNames[enrollment.organisationUnit()],
)
}
}

private fun getSiteNames(enrollments: List<DomainEnrollment>): Map<String, String> {
val orgUnitIds = enrollments.mapNotNull { it.orgUnit }.distinct()
private fun getSiteNames(enrollments: List<Enrollment>): Map<String, String> {
val orgUnitIds = enrollments.mapNotNull { it.organisationUnit() }.distinct()
if (orgUnitIds.isEmpty()) return emptyMap()

return d2
Expand All @@ -59,7 +52,7 @@ internal class DetailedEnrollmentRepository(
}

private fun getEventDetails(
enrollments: List<DomainEnrollment>,
enrollments: List<Enrollment>,
settings: DetailedEnrollmentListingSettings,
): Map<String, EnrollmentEventDetails> {
if (
Expand All @@ -69,7 +62,7 @@ internal class DetailedEnrollmentRepository(
return emptyMap()
}

val enrollmentIds = enrollments.map { it.uid }.distinct()
val enrollmentIds = enrollments.map { it.uid() }.distinct()
if (enrollmentIds.isEmpty()) return emptyMap()

return d2
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,9 @@ class EventHistoryTableRepository(
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 followUpVisitNumbers =
((config.followUpVisitMinNumber ?: 0)..(config.followUpVisitMaxNumber ?: -1))
.takeIf { it.first in 0..it.last } ?: return null
val headerVisitNumberDataElementUid = config.headerVisitNumberDataElementId.trimToValue() ?: return null
val excludedDataElementIds =
(
Expand All @@ -49,9 +51,9 @@ class EventHistoryTableRepository(
events = events,
eventDataValuesByUid = eventDataValuesByUid,
headerVisitNumberDataElementUid = headerVisitNumberDataElementUid,
followUpVisitMaxNumber = followUpVisitMaxNumber,
followUpVisitNumbers = followUpVisitNumbers,
)
val columnIndexes = (0..followUpVisitMaxNumber).toList()
val columnIndexes = followUpVisitNumbers.toList()
val optionDisplayNamesBySet = getOptionDisplayNamesBySet(rowDefinitions)

fun eventForColumn(columnIndex: Int): Event? = eventsByColumnIndex[columnIndex]
Expand Down Expand Up @@ -107,6 +109,7 @@ class EventHistoryTableRepository(
columns = tableColumns,
sections = it,
dateRowValues = dateRowValues,
visitLabel = config.followUpVisitLabel.trimToValue(),
)
}
}
Expand Down Expand Up @@ -325,14 +328,14 @@ class EventHistoryTableRepository(
events: List<Event>,
eventDataValuesByUid: Map<String, Map<String, String>>,
headerVisitNumberDataElementUid: String,
followUpVisitMaxNumber: Int,
followUpVisitNumbers: IntRange,
): Map<Int, Event> =
events
.mapNotNull { event ->
eventDataValuesByUid[event.uid()]
?.get(headerVisitNumberDataElementUid)
?.toVisitNumber()
?.takeIf { it in 0..followUpVisitMaxNumber }
?.takeIf { it in followUpVisitNumbers }
?.let { visitNumber -> visitNumber to event }
}.toMap()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ package org.dhis2.simprints.ramp.model
import java.util.Date

data class DetailedEnrollment(
val programName: String,
val admitted: Date?,
val discharge: Date?,
val outcome: String?,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ data class EventHistoryTable(
val columns: List<EventHistoryTableColumn>,
val sections: List<EventHistoryTableSection>,
val dateRowValues: List<String> = emptyList(),
val visitLabel: String? = null,
)

data class EventHistoryTableColumn(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ private fun HistoryTable(
.background(colorResource(id = R.color.white)),
) {
VisitHeaderRow(
label = table.visitLabel ?: stringResource(R.string.simprints_ramp_visit),
columns = table.columns,
horizontalScrollState = horizontalScrollState,
)
Expand Down Expand Up @@ -125,12 +126,13 @@ private fun HistoryTable(

@Composable
private fun VisitHeaderRow(
label: String,
columns: List<EventHistoryTableColumn>,
horizontalScrollState: ScrollState,
) {
Row(modifier = Modifier.heightIntrinsicRow()) {
HeaderCell(
text = stringResource(R.string.simprints_ramp_visit),
text = label,
width = RowHeaderWidth,
textAlign = TextAlign.Start,
)
Expand Down
Loading