diff --git a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/GhanaIdCardOcrSelectorUseCase.kt b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/GhanaIdCardOcrSelectorUseCase.kt
index b5672f17fa..5c6b74f3d2 100644
--- a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/GhanaIdCardOcrSelectorUseCase.kt
+++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanocr/usecase/GhanaIdCardOcrSelectorUseCase.kt
@@ -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$")
}
}
diff --git a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/search/ExternalCredentialSearchFragment.kt b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/search/ExternalCredentialSearchFragment.kt
index 9df99a4d9b..3aa685e6d2 100644
--- a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/search/ExternalCredentialSearchFragment.kt
+++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/search/ExternalCredentialSearchFragment.kt
@@ -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
@@ -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,
@@ -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
}
@@ -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(
@@ -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)
@@ -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 {
@@ -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
+ }
}
diff --git a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/search/ExternalCredentialSearchViewModel.kt b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/search/ExternalCredentialSearchViewModel.kt
index e0c2289fc2..31a781fc88 100644
--- a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/search/ExternalCredentialSearchViewModel.kt
+++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/search/ExternalCredentialSearchViewModel.kt
@@ -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
@@ -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 {
@@ -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)
@@ -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
+ }
+ }
+ }
}
diff --git a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/search/model/SearchCredentialState.kt b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/search/model/SearchCredentialState.kt
index 4493365467..fee2e741da 100644
--- a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/search/model/SearchCredentialState.kt
+++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/search/model/SearchCredentialState.kt
@@ -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")
@@ -14,6 +15,7 @@ internal data class SearchCredentialState(
val flowType: FlowType,
val searchState: SearchState,
val isConfirmed: Boolean,
+ val isEditingCredential: Boolean,
) {
companion object {
fun buildInitial(
@@ -25,6 +27,7 @@ internal data class SearchCredentialState(
flowType = flowType,
searchState = SearchState.Searching,
isConfirmed = false,
+ isEditingCredential = false,
)
}
}
diff --git a/feature/external-credential/src/main/res/color/checkbox_color.xml b/feature/external-credential/src/main/res/color/checkbox_color.xml
new file mode 100644
index 0000000000..2f81cf52fc
--- /dev/null
+++ b/feature/external-credential/src/main/res/color/checkbox_color.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/feature/external-credential/src/main/res/color/ic_edit_color.xml b/feature/external-credential/src/main/res/color/ic_edit_color.xml
new file mode 100644
index 0000000000..982d0cb05b
--- /dev/null
+++ b/feature/external-credential/src/main/res/color/ic_edit_color.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/feature/external-credential/src/main/res/drawable/ic_done.xml b/feature/external-credential/src/main/res/drawable/ic_done.xml
index c7ea9bc53a..6b7bc1618e 100644
--- a/feature/external-credential/src/main/res/drawable/ic_done.xml
+++ b/feature/external-credential/src/main/res/drawable/ic_done.xml
@@ -4,6 +4,6 @@
android:viewportWidth="960"
android:viewportHeight="960">
diff --git a/feature/external-credential/src/main/res/layout/fragment_external_credential_search.xml b/feature/external-credential/src/main/res/layout/fragment_external_credential_search.xml
index 96b349c4a5..3675099657 100644
--- a/feature/external-credential/src/main/res/layout/fragment_external_credential_search.xml
+++ b/feature/external-credential/src/main/res/layout/fragment_external_credential_search.xml
@@ -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"
diff --git a/feature/external-credential/src/test/java/com/simprints/feature/externalcredential/screens/search/ExternalCredentialSearchViewModelTest.kt b/feature/external-credential/src/test/java/com/simprints/feature/externalcredential/screens/search/ExternalCredentialSearchViewModelTest.kt
index e72cf4d9a9..737fd5e00f 100644
--- a/feature/external-credential/src/test/java/com/simprints/feature/externalcredential/screens/search/ExternalCredentialSearchViewModelTest.kt
+++ b/feature/external-credential/src/test/java/com/simprints/feature/externalcredential/screens/search/ExternalCredentialSearchViewModelTest.kt
@@ -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
@@ -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"
@@ -105,6 +113,8 @@ internal class ExternalCredentialSearchViewModelTest {
tokenizationProcessor = tokenizationProcessor,
enrolmentRecordRepository = enrolmentRecordRepository,
eventsTracker = eventsTracker,
+ ghanaIdValidationUseCase = ghanaIdValidationUseCase,
+ ghanaNhisCardValidationUseCase = ghanaNhisCardValidationUseCase,
)
@Test
@@ -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()
+ }
}