diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index c2c32e463e..54e8e0fde3 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -65,6 +65,7 @@ jobs: feature:alert feature:exit-form feature:select-subject-age-group + feature:external-credential reportsId: feature1 feature-unit-tests2: diff --git a/feature/external-credential/build.gradle.kts b/feature/external-credential/build.gradle.kts index 2db2841b64..b3d0f060fa 100644 --- a/feature/external-credential/build.gradle.kts +++ b/feature/external-credential/build.gradle.kts @@ -8,5 +8,9 @@ android { } dependencies { + implementation(project(":infra:config-store")) + implementation(project(":infra:config-sync")) + implementation(project(":infra:ui-base")) implementation(project(":feature:exit-form")) + implementation(libs.androidX.cameraX.view) } diff --git a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/ext/ResourceExt.kt b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/ext/ResourceExt.kt new file mode 100644 index 0000000000..acaa6ed77f --- /dev/null +++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/ext/ResourceExt.kt @@ -0,0 +1,25 @@ +package com.simprints.feature.externalcredential.ext + +import android.content.res.Resources +import androidx.annotation.PluralsRes +import androidx.annotation.StringRes +import com.simprints.core.domain.externalcredential.ExternalCredentialType + +fun Resources.getQuantityCredentialString( + @PluralsRes id: Int, + @StringRes specificCredentialRes: Int, + @StringRes multipleCredentialsRes: Int, + credentialTypes: List, +): String { + val credentialsAmount = credentialTypes.size + val documentTypeRes = if (credentialsAmount == 1) { + specificCredentialRes + } else { + multipleCredentialsRes + } + return getQuantityString( + id, + credentialsAmount, + getString(documentTypeRes) + ) +} diff --git a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/controller/ExternalCredentialControllerFragment.kt b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/controller/ExternalCredentialControllerFragment.kt index e95d488dc3..3cf0cb453f 100644 --- a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/controller/ExternalCredentialControllerFragment.kt +++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/controller/ExternalCredentialControllerFragment.kt @@ -2,23 +2,27 @@ package com.simprints.feature.externalcredential.screens.controller import android.os.Bundle import android.view.View +import androidx.activity.addCallback import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels import androidx.navigation.NavController import androidx.navigation.fragment.findNavController -import androidx.navigation.fragment.navArgs import com.simprints.feature.exitform.ExitFormContract import com.simprints.feature.exitform.ExitFormResult import com.simprints.feature.externalcredential.GraphExternalCredentialInternalDirections import com.simprints.feature.externalcredential.R +import com.simprints.feature.externalcredential.model.ExternalCredentialParams import com.simprints.infra.uibase.navigation.finishWithResult import com.simprints.infra.uibase.navigation.handleResult import com.simprints.infra.uibase.navigation.navigateSafely +import com.simprints.infra.uibase.navigation.navigationParams import dagger.hilt.android.AndroidEntryPoint import kotlin.getValue @AndroidEntryPoint internal class ExternalCredentialControllerFragment : Fragment(R.layout.fragment_external_credential_controller) { - private val args: ExternalCredentialControllerFragmentArgs by navArgs() + private val params: ExternalCredentialParams by navigationParams() + private val viewModel: ExternalCredentialViewModel by activityViewModels() private val hostFragment: Fragment? get() = childFragmentManager.findFragmentById(R.id.external_credential_host_fragment) @@ -32,6 +36,8 @@ internal class ExternalCredentialControllerFragment : Fragment(R.layout.fragment override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) + viewModel.init(params) + findNavController().handleResult( this, R.id.externalCredentialControllerFragment, @@ -48,5 +54,29 @@ internal class ExternalCredentialControllerFragment : Fragment(R.layout.fragment } } internalNavController?.setGraph(R.navigation.graph_external_credential_internal) + + initObservers() + initListeners() + } + + private fun initObservers() { + viewModel.stateLiveData.observe(viewLifecycleOwner) { + } + } + + private fun initListeners() { + requireActivity().onBackPressedDispatcher.addCallback(viewLifecycleOwner) { + when (internalNavController?.currentDestination?.id) { + R.id.externalCredentialSelectFragment, R.id.externalCredentialSearch -> { + // Exit form navigation + findNavController().navigateSafely( + this@ExternalCredentialControllerFragment, + R.id.action_global_refusalFragment, + ) + } + + else -> internalNavController?.popBackStack() + } + } } } diff --git a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/controller/ExternalCredentialState.kt b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/controller/ExternalCredentialState.kt new file mode 100644 index 0000000000..9ceb7409db --- /dev/null +++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/controller/ExternalCredentialState.kt @@ -0,0 +1,20 @@ +package com.simprints.feature.externalcredential.screens.controller + +import com.simprints.core.domain.common.FlowType +import com.simprints.core.domain.externalcredential.ExternalCredentialType + +internal data class ExternalCredentialState( + val subjectId: String?, + val flowType: FlowType, + val credentialValue: String?, + val selectedType: ExternalCredentialType? +) { + companion object { + val EMPTY = ExternalCredentialState( + subjectId = null, + flowType = FlowType.VERIFY, + credentialValue = null, + selectedType = null, + ) + } +} diff --git a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/controller/ExternalCredentialViewModel.kt b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/controller/ExternalCredentialViewModel.kt new file mode 100644 index 0000000000..53dea8553c --- /dev/null +++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/controller/ExternalCredentialViewModel.kt @@ -0,0 +1,50 @@ +package com.simprints.feature.externalcredential.screens.controller + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import com.simprints.core.domain.externalcredential.ExternalCredentialType +import com.simprints.feature.externalcredential.model.ExternalCredentialParams +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import com.simprints.infra.resources.R as IDR + +@HiltViewModel +internal class ExternalCredentialViewModel @Inject internal constructor() : ViewModel() { + + private var isInitialized = false + private var state: ExternalCredentialState = ExternalCredentialState.EMPTY + set(value) { + field = value + _stateLiveData.postValue(value) + } + private val _stateLiveData = MutableLiveData(ExternalCredentialState.EMPTY) + val stateLiveData: LiveData = _stateLiveData + + private fun updateState(state: (ExternalCredentialState) -> ExternalCredentialState) { + this.state = state(this.state) + } + + fun setSelectedExternalCredentialType(selectedType: ExternalCredentialType?) { + updateState { it.copy(selectedType = selectedType) } + } + + fun setExternalCredentialValue(value: String) { + updateState { it.copy(credentialValue = value) } + } + + fun init(params: ExternalCredentialParams) { + if (!isInitialized) { + isInitialized = true + updateState { ExternalCredentialState.EMPTY.copy(subjectId = params.subjectId, flowType = params.flowType) } + } + } + + + fun mapTypeToStringResource(type: ExternalCredentialType?) = when (type) { + ExternalCredentialType.NHISCard -> IDR.string.mfid_type_nhis_card + ExternalCredentialType.GhanaIdCard -> IDR.string.mfid_type_ghana_id_card + ExternalCredentialType.QRCode -> IDR.string.mfid_type_qr_code + null -> IDR.string.mfid_type_any_document + } +} diff --git a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanqr/ExternalCredentialScanQrFragment.kt b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanqr/ExternalCredentialScanQrFragment.kt index 93c452cc05..f842b52525 100644 --- a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanqr/ExternalCredentialScanQrFragment.kt +++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/scanqr/ExternalCredentialScanQrFragment.kt @@ -1,9 +1,259 @@ package com.simprints.feature.externalcredential.screens.scanqr +import android.Manifest.permission.CAMERA +import android.app.Dialog +import android.content.Intent +import android.graphics.Rect +import android.os.Bundle +import android.provider.Settings +import android.view.View +import android.widget.Button +import android.widget.TextView +import android.widget.Toast +import androidx.activity.result.contract.ActivityResultContracts +import androidx.core.net.toUri +import androidx.core.view.isVisible import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import androidx.navigation.fragment.findNavController +import com.google.android.material.bottomsheet.BottomSheetBehavior +import com.google.android.material.bottomsheet.BottomSheetDialog +import com.simprints.core.tools.extentions.getCurrentPermissionStatus +import com.simprints.core.tools.extentions.hasPermission +import com.simprints.core.tools.extentions.permissionFromResult import com.simprints.feature.externalcredential.R +import com.simprints.feature.externalcredential.databinding.FragmentExternalCredentialScanQrBinding +import com.simprints.feature.externalcredential.screens.controller.ExternalCredentialViewModel +import com.simprints.infra.logging.LoggingConstants +import com.simprints.infra.logging.LoggingConstants.CrashReportTag.MULTI_FACTOR_ID +import com.simprints.infra.logging.Simber +import com.simprints.infra.uibase.camera.qrscan.CameraHelper +import com.simprints.infra.uibase.camera.qrscan.QrCodeAnalyzer +import com.simprints.infra.uibase.navigation.navigateSafely +import com.simprints.infra.uibase.view.applySystemBarInsets +import com.simprints.infra.uibase.viewbinding.viewBinding import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import javax.inject.Inject +import kotlin.getValue +import com.simprints.infra.resources.R as IDR @AndroidEntryPoint internal class ExternalCredentialScanQrFragment : Fragment(R.layout.fragment_external_credential_scan_qr) { + + private val binding by viewBinding(FragmentExternalCredentialScanQrBinding::bind) + private val crashReportTag = MULTI_FACTOR_ID + private val mainViewModel: ExternalCredentialViewModel by activityViewModels() + private val viewModel by viewModels() + + private var dialog: Dialog? = null + private var isCameraInitialized = false + + @Inject + lateinit var cameraHelperFactory: CameraHelper.Factory + private val cameraHelper: CameraHelper by lazy { + cameraHelperFactory.create(crashReportTag) + } + + @Inject + lateinit var qrCodeAnalyzerFactory: QrCodeAnalyzer.Factory + private lateinit var qrCodeAnalyzer: QrCodeAnalyzer + + private val launchPermissionRequest = registerForActivityResult( + ActivityResultContracts.RequestPermission(), + ) { granted -> + val cameraPermissionStatus = requireActivity().permissionFromResult(CAMERA, granted) + viewModel.updateCameraPermissionStatus(cameraPermissionStatus) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + applySystemBarInsets(view) + Simber.i("ExternalCredentialScanQrFragment started", tag = MULTI_FACTOR_ID) + + + initObservers() + + if (!requireActivity().hasPermission(CAMERA)) { + launchPermissionRequest.launch(CAMERA) + } + } + + override fun onResume() { + super.onResume() + val cameraPermissionStatus = requireActivity().getCurrentPermissionStatus(CAMERA) + viewModel.updateCameraPermissionStatus(cameraPermissionStatus) + } + + override fun onDestroy() { + dismissDialog() + super.onDestroy() + } + + private fun dismissDialog() { + dialog?.dismiss() + dialog = null + } + + private fun initObservers() { + viewModel.stateLiveData.observe(viewLifecycleOwner) { state -> + when (state) { + ScanQrState.ReadyToScan -> { + renderInitialState() + initCamera() + } + + is ScanQrState.QrCodeCaptured -> renderScanComplete(state) + is ScanQrState.NoCameraPermission -> renderNoPermission(state.shouldOpenPhoneSettings) + } + } + } + + private fun renderInitialState() = with(binding) { + permissionRequestView.isVisible = false + qrInstructionsText.isVisible = true + qrPreviewCard.isVisible = false + buttonScan.setText(IDR.string.mfid_qr_scan_no_qr_detected) + buttonScan.isVisible = true + buttonScan.isEnabled = false + buttonScan.setOnClickListener {} + } + + private fun renderScanComplete(state: ScanQrState.QrCodeCaptured) = with(binding) { + val qrCodeValue = state.qrCodeValue + qrInstructionsText.isVisible = false + qrPreviewCard.isVisible = true + qrPreviewText.text = state.qrCodeValue + buttonScan.setText(IDR.string.mfid_continue) + buttonScan.isEnabled = true + buttonScan.setOnClickListener { + if (viewModel.isValidQrCodeFormat(qrCodeValue)) { + mainViewModel.setExternalCredentialValue(qrCodeValue) + findNavController().navigateSafely( + this@ExternalCredentialScanQrFragment, + R.id.action_externalCredentialSelectScanQr_to_externalCredentialSearch + ) + } else { + showInvalidQrCodeFormatDialog( + qrCodeValue = qrCodeValue, + onDismiss = { + dismissDialog() + viewModel.updateCapturedValue(null) + } + ) + } + } + } + + private fun showInvalidQrCodeFormatDialog( + qrCodeValue: String, + onDismiss: () -> Unit, + ) { + dismissDialog() + dialog = BottomSheetDialog(requireContext()).also { + val view = layoutInflater.inflate(R.layout.dialog_qr_wrong_value, null) + .also { view -> + val qrValueTextView = view.findViewById(R.id.qrValue) + val buttonRescan = view.findViewById