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() + } }