Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ internal class GhanaIdCardOcrSelectorUseCase @Inject constructor() {
operator fun invoke(readoutValue: String): Boolean = GHANA_ID_PATTERN.matches(readoutValue)

companion object {
// Ghana ID card number pattern is "GHA-12345789-0"
// Ghana ID card number pattern is "GHA-123456789-0"
private val GHANA_ID_PATTERN = Regex("^GHA-\\d{9}-\\d$")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@ package com.simprints.feature.externalcredential.screens.search
import android.content.Context
import android.graphics.BitmapFactory
import android.os.Bundle
import android.text.TextWatcher
import android.view.View
import android.view.inputmethod.InputMethodManager
import androidx.core.content.ContextCompat
import androidx.core.view.isVisible
import androidx.core.widget.addTextChangedListener
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.fragment.app.viewModels
Expand Down Expand Up @@ -58,7 +60,7 @@ internal class ExternalCredentialSearchFragment : Fragment(R.layout.fragment_ext

@Inject
lateinit var zoomOntoCredentialUseCase: ZoomOntoCredentialUseCase
private var isEditingCredential: Boolean = false
private var credentialTextWatcher: TextWatcher? = null

override fun onViewCreated(
view: View,
Expand Down Expand Up @@ -92,7 +94,9 @@ internal class ExternalCredentialSearchFragment : Fragment(R.layout.fragment_ext
val credentialType = state.scannedCredential.credentialType
val credentialField = resources.getCredentialFieldTitle(credentialType)
val currentEditTextValue = credentialEditText.text.toString()
val isEditingCredential = state.isEditingCredential
renderImage(state.scannedCredential)
renderCredentialEdit(state)
credential.takeIf { currentEditTextValue.isEmpty() }?.let {
credentialEditText.setText(it) // Setting only once at the start
}
Expand All @@ -102,18 +106,25 @@ internal class ExternalCredentialSearchFragment : Fragment(R.layout.fragment_ext
credentialValue.text = currentEditTextValue
confirmCredentialCheckbox.isVisible = state.searchState != SearchState.Searching
confirmCredentialCheckbox.text = getString(IDR.string.mfid_confirmation_checkbox_text, credentialField)
confirmCredentialCheckbox.isChecked = state.isConfirmed
confirmCredentialCheckbox.isChecked = state.isConfirmed && !state.isEditingCredential
confirmCredentialCheckbox.isEnabled = !state.isEditingCredential

iconEditCredential.setOnClickListener {
viewModel.updateConfirmation(isConfirmed = false)
toggleCredentialEdit()
if (!isEditingCredential) {
viewModel.confirmCredentialUpdate(credentialEditText.text.toString().asTokenizableRaw())
if (isEditingCredential) {
viewModel.confirmCredentialUpdate(updatedCredential = credentialEditText.text.toString().asTokenizableRaw())
}
viewModel.updateIsEditingCredential(isEditing = !isEditingCredential)
}
confirmCredentialCheckbox.setOnCheckedChangeListener { _, checkedId ->
viewModel.updateConfirmation(isConfirmed = checkedId)
}

credentialTextWatcher?.let(credentialEditText::removeTextChangedListener)
credentialTextWatcher = credentialEditText.addTextChangedListener(
afterTextChanged = { _ ->
renderEditIcon(isEditingCredential)
},
)
}

private fun renderSearchProgress(
Expand Down Expand Up @@ -196,7 +207,7 @@ internal class ExternalCredentialSearchFragment : Fragment(R.layout.fragment_ext
val isSearching = state.searchState != SearchState.Searching
buttonRecapture.isVisible = isSearching
buttonConfirm.isVisible = isSearching
buttonConfirm.isEnabled = state.isConfirmed
buttonConfirm.isEnabled = state.isConfirmed && !state.isEditingCredential
viewModel.getButtonTextResource(state.searchState, state.flowType)?.run(buttonConfirm::setText)
buttonConfirm.setOnClickListener {
viewModel.finish(state)
Expand Down Expand Up @@ -224,8 +235,9 @@ internal class ExternalCredentialSearchFragment : Fragment(R.layout.fragment_ext
}
}

private fun toggleCredentialEdit() = with(binding) {
isEditingCredential = !isEditingCredential
private fun renderCredentialEdit(state: SearchCredentialState) = with(binding) {
val isEditingCredential = state.isEditingCredential
renderEditIcon(isEditingCredential)
val iconRes = if (isEditingCredential) {
R.drawable.ic_done
} else {
Expand All @@ -248,4 +260,14 @@ internal class ExternalCredentialSearchFragment : Fragment(R.layout.fragment_ext
private fun hideKeyboard() {
requireActivity().hideKeyboard()
}

private fun renderEditIcon(isEditingCredential: Boolean) = with(binding) {
val isEditIconEnabled = if (isEditingCredential) {
viewModel.isCredentialFormatValid(credentialEditText.text?.toString())
} else {
true
}
iconEditCredential.alpha = if (isEditIconEnabled) 1.0f else 0.5f
iconEditCredential.isEnabled = isEditIconEnabled
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import com.simprints.core.livedata.send
import com.simprints.core.tools.time.TimeHelper
import com.simprints.feature.externalcredential.ExternalCredentialSearchResult
import com.simprints.feature.externalcredential.model.ExternalCredentialParams
import com.simprints.feature.externalcredential.screens.scanocr.usecase.GhanaIdCardOcrSelectorUseCase
import com.simprints.feature.externalcredential.screens.scanocr.usecase.GhanaNhisCardOcrSelectorUseCase
import com.simprints.feature.externalcredential.screens.search.model.ScannedCredential
import com.simprints.feature.externalcredential.screens.search.model.SearchCredentialState
import com.simprints.feature.externalcredential.screens.search.model.SearchState
Expand Down Expand Up @@ -41,6 +43,8 @@ internal class ExternalCredentialSearchViewModel @AssistedInject constructor(
private val tokenizationProcessor: TokenizationProcessor,
private val enrolmentRecordRepository: EnrolmentRecordRepository,
private val eventsTracker: ExternalCredentialEventTrackerUseCase,
private val ghanaIdValidationUseCase: GhanaIdCardOcrSelectorUseCase,
private val ghanaNhisCardValidationUseCase: GhanaNhisCardOcrSelectorUseCase,
) : ViewModel() {
@AssistedFactory
interface Factory {
Expand Down Expand Up @@ -79,6 +83,10 @@ internal class ExternalCredentialSearchViewModel @AssistedInject constructor(
updateState { it.copy(isConfirmed = isConfirmed) }
}

fun updateIsEditingCredential(isEditing: Boolean) {
updateState { it.copy(isEditingCredential = isEditing) }
}

fun confirmCredentialUpdate(updatedCredential: TokenizableString.Raw) {
viewModelScope.launch {
val project = configManager.getProject(authStore.signedInProjectId)
Expand Down Expand Up @@ -202,4 +210,22 @@ internal class ExternalCredentialSearchViewModel @AssistedInject constructor(
)
}
}

fun isCredentialFormatValid(credential: String?): Boolean {
if (credential == null) return false
return when (scannedCredential.credentialType) {
ExternalCredentialType.NHISCard -> {
// 8 digits
ghanaNhisCardValidationUseCase(credential)
}
ExternalCredentialType.GhanaIdCard -> {
// Ghana ID card number pattern is "GHA-123456789-0"
ghanaIdValidationUseCase(credential)
}
ExternalCredentialType.QRCode -> {
// No QR code validation as of 2025.4.1
true
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import com.simprints.core.ExcludedFromGeneratedTestCoverageReports
import com.simprints.core.domain.common.FlowType
import com.simprints.core.domain.tokenization.TokenizableString
import com.simprints.feature.externalcredential.model.CredentialMatch
import kotlin.Boolean

@Keep
@ExcludedFromGeneratedTestCoverageReports("Data struct")
Expand All @@ -14,6 +15,7 @@ internal data class SearchCredentialState(
val flowType: FlowType,
val searchState: SearchState,
val isConfirmed: Boolean,
val isEditingCredential: Boolean,
) {
companion object {
fun buildInitial(
Expand All @@ -25,6 +27,7 @@ internal data class SearchCredentialState(
flowType = flowType,
searchState = SearchState.Searching,
isConfirmed = false,
isEditingCredential = false,
)
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:color="@color/simprints_text_grey_light" android:state_enabled="false" />
<item android:color="@color/simprints_blue" />
</selector>
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:color="#FF888888" android:state_enabled="false"/>
<item android:color="#00B3D1"/>
</selector>
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,6 @@
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="#00B3D1"
android:fillColor="@color/ic_edit_color"
android:pathData="M382,720L154,492L211,435L382,606L749,239L806,296L382,720Z" />
</vector>
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@
android:layout_height="wrap_content"
android:layout_marginHorizontal="10dp"
android:layout_marginBottom="@dimen/margin_large"
android:buttonTint="@color/simprints_blue"
android:buttonTint="@color/checkbox_color"
android:checked="false"
android:paddingStart="@dimen/margin_default"
android:visibility="gone"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import com.simprints.core.tools.time.TimeHelper
import com.simprints.core.tools.time.Timestamp
import com.simprints.feature.externalcredential.model.CredentialMatch
import com.simprints.feature.externalcredential.model.ExternalCredentialParams
import com.simprints.feature.externalcredential.screens.scanocr.usecase.GhanaIdCardOcrSelectorUseCase
import com.simprints.feature.externalcredential.screens.scanocr.usecase.GhanaNhisCardOcrSelectorUseCase
import com.simprints.feature.externalcredential.screens.search.model.ScannedCredential
import com.simprints.feature.externalcredential.screens.search.model.SearchCredentialState
import com.simprints.feature.externalcredential.screens.search.model.SearchState
Expand Down Expand Up @@ -79,6 +81,12 @@ internal class ExternalCredentialSearchViewModelTest {
@MockK
lateinit var eventsTracker: ExternalCredentialEventTrackerUseCase

@MockK
lateinit var ghanaIdValidationUseCase: GhanaIdCardOcrSelectorUseCase

@MockK
lateinit var ghanaNhisCardValidationUseCase: GhanaNhisCardOcrSelectorUseCase

private lateinit var viewModel: ExternalCredentialSearchViewModel

private val projectId = "projectId"
Expand All @@ -105,6 +113,8 @@ internal class ExternalCredentialSearchViewModelTest {
tokenizationProcessor = tokenizationProcessor,
enrolmentRecordRepository = enrolmentRecordRepository,
eventsTracker = eventsTracker,
ghanaIdValidationUseCase = ghanaIdValidationUseCase,
ghanaNhisCardValidationUseCase = ghanaNhisCardValidationUseCase,
)

@Test
Expand Down Expand Up @@ -303,4 +313,54 @@ internal class ExternalCredentialSearchViewModelTest {
assertThat(viewModel.stateLiveData.value?.displayedCredential).isEqualTo(decryptedCredential)
coVerify { tokenizationProcessor.decrypt(encryptedCredential, TokenKeyType.ExternalCredential, project) }
}

@Test
fun `isCredentialFormatValid validates NHIS card format`() = runTest {
val validNhisCard = "12345678"
val invalidNhisCard = "invalid"

every { mockScannedCredential.credentialType } returns ExternalCredentialType.NHISCard
every { ghanaNhisCardValidationUseCase(validNhisCard) } returns true
every { ghanaNhisCardValidationUseCase(invalidNhisCard) } returns false

viewModel = createViewModel()

assertThat(viewModel.isCredentialFormatValid(validNhisCard)).isTrue()
assertThat(viewModel.isCredentialFormatValid(invalidNhisCard)).isFalse()
assertThat(viewModel.isCredentialFormatValid(null)).isFalse()
verify { ghanaNhisCardValidationUseCase(validNhisCard) }
verify { ghanaNhisCardValidationUseCase(invalidNhisCard) }
}

@Test
fun `isCredentialFormatValid validates Ghana ID card format`() = runTest {
val validGhanaIdCard = "GHA-12345789-0"
val invalidGhanaIdCard = "invalid"

every { mockScannedCredential.credentialType } returns ExternalCredentialType.GhanaIdCard
every { ghanaIdValidationUseCase(validGhanaIdCard) } returns true
every { ghanaIdValidationUseCase(invalidGhanaIdCard) } returns false

viewModel = createViewModel()

assertThat(viewModel.isCredentialFormatValid(validGhanaIdCard)).isTrue()
assertThat(viewModel.isCredentialFormatValid(invalidGhanaIdCard)).isFalse()
assertThat(viewModel.isCredentialFormatValid(null)).isFalse()
verify { ghanaIdValidationUseCase(validGhanaIdCard) }
verify { ghanaIdValidationUseCase(invalidGhanaIdCard) }
}

@Test
fun `isCredentialFormatValid always returns true for QR code`() = runTest {
val anyValue = "any_value"
val emptyValue = ""

every { mockScannedCredential.credentialType } returns ExternalCredentialType.QRCode

viewModel = createViewModel()

assertThat(viewModel.isCredentialFormatValid(anyValue)).isTrue()
assertThat(viewModel.isCredentialFormatValid(emptyValue)).isTrue()
assertThat(viewModel.isCredentialFormatValid(null)).isFalse()
}
}