From 38cbf52b502efd738976745289df67824045ffcd Mon Sep 17 00:00:00 2001 From: alex Date: Mon, 29 Sep 2025 15:44:20 +0300 Subject: [PATCH 01/22] [MS-1167] Implementing zoom-in onto scanend external credential area --- .../controller/ExternalCredentialViewModel.kt | 4 + .../ExternalCredentialSearchFragment.kt | 76 +++++++++- .../ExternalCredentialSearchViewModel.kt | 35 +++++ .../search/model/SearchCredentialState.kt | 32 +++++ .../usecase/ZoomOntoCredentialUseCase.kt | 102 +++++++++++++ .../src/main/res/drawable/glow_background.xml | 13 ++ .../src/main/res/drawable/ic_done.xml | 10 ++ .../src/main/res/drawable/ic_edit.xml | 10 ++ .../fragment_external_credential_search.xml | 136 ++++++++++++++++-- .../src/main/res/values/styles.xml | 7 + 10 files changed, 411 insertions(+), 14 deletions(-) create mode 100644 feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/search/ExternalCredentialSearchViewModel.kt create mode 100644 feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/search/model/SearchCredentialState.kt create mode 100644 feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/search/usecase/ZoomOntoCredentialUseCase.kt create mode 100644 feature/external-credential/src/main/res/drawable/glow_background.xml create mode 100644 feature/external-credential/src/main/res/drawable/ic_done.xml create mode 100644 feature/external-credential/src/main/res/drawable/ic_edit.xml create mode 100644 feature/external-credential/src/main/res/values/styles.xml 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 index 53dea8553c..82d82f626e 100644 --- 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 @@ -3,6 +3,7 @@ package com.simprints.feature.externalcredential.screens.controller import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel +import com.simprints.core.domain.common.FlowType import com.simprints.core.domain.externalcredential.ExternalCredentialType import com.simprints.feature.externalcredential.model.ExternalCredentialParams import dagger.hilt.android.lifecycle.HiltViewModel @@ -13,6 +14,8 @@ import com.simprints.infra.resources.R as IDR internal class ExternalCredentialViewModel @Inject internal constructor() : ViewModel() { private var isInitialized = false + lateinit var flowType: FlowType + private set private var state: ExternalCredentialState = ExternalCredentialState.EMPTY set(value) { field = value @@ -36,6 +39,7 @@ internal class ExternalCredentialViewModel @Inject internal constructor() : View fun init(params: ExternalCredentialParams) { if (!isInitialized) { isInitialized = true + flowType = params.flowType updateState { ExternalCredentialState.EMPTY.copy(subjectId = params.subjectId, flowType = params.flowType) } } } 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 36a92affcc..9bb45a8bbc 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,27 +3,91 @@ package com.simprints.feature.externalcredential.screens.search import android.graphics.BitmapFactory import android.os.Bundle import android.view.View +import android.widget.Toast +import androidx.core.view.isVisible import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.fragment.app.viewModels +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider import androidx.navigation.fragment.navArgs import com.simprints.feature.externalcredential.R import com.simprints.feature.externalcredential.databinding.FragmentExternalCredentialSearchBinding +import com.simprints.feature.externalcredential.model.BoundingBox +import com.simprints.feature.externalcredential.screens.controller.ExternalCredentialViewModel +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 +import com.simprints.feature.externalcredential.screens.search.usecase.ZoomOntoCredentialUseCase +import com.simprints.infra.logging.LoggingConstants.CrashReportTag.MULTI_FACTOR_ID +import com.simprints.infra.logging.Simber import com.simprints.infra.uibase.viewbinding.viewBinding import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject +import kotlin.getValue @AndroidEntryPoint internal class ExternalCredentialSearchFragment : Fragment(R.layout.fragment_external_credential_search) { private val args: ExternalCredentialSearchFragmentArgs by navArgs() private val binding by viewBinding(FragmentExternalCredentialSearchBinding::bind) + private val mainViewModel: ExternalCredentialViewModel by activityViewModels() + private val viewModel by viewModels { + object : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + @Suppress("UNCHECKED_CAST") + return viewModelFactory.create(args.scannedCredential, mainViewModel.flowType) as T + } + } + } + + @Inject + lateinit var viewModelFactory: ExternalCredentialSearchViewModel.Factory + + + @Inject + lateinit var zoomOntoCredentialUseCase: ZoomOntoCredentialUseCase + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - super.onCreate(savedInstanceState) - val imagePath = args.scannedCredential.previewImagePath!! - loadScannedImage(imagePath) + initObservers() + } + + private fun initObservers() { + viewModel.stateLiveData.observe(viewLifecycleOwner) { state -> + renderCredentialCard(state) + renderSearchProgress(state.searchState) + renderButtons(state) + } + } + + private fun renderCredentialCard(state: SearchCredentialState) { + renderImage(state.scannedCredential) + } + + private fun renderSearchProgress(searchState: SearchState) { + } + + private fun renderButtons(state: SearchCredentialState) { } - private fun loadScannedImage(imagePath: String) { - val bitmap = BitmapFactory.decodeFile(imagePath) - binding.scannedImage.setImageBitmap(bitmap) + private fun renderImage(scannedCredential: ScannedCredential) { + val imagePath: String? = scannedCredential.previewImagePath + val boundingBox: BoundingBox? = scannedCredential.imageBoundingBox + binding.documentPreview.isVisible = imagePath != null + if (imagePath == null) return + + try { + BitmapFactory.decodeFile(imagePath)?.let { bitmap -> + val finalBitmap = if (boundingBox != null) { + zoomOntoCredentialUseCase(bitmap, boundingBox) + } else bitmap + binding.documentPreview.setImageBitmap(finalBitmap) + } + } catch (e: Exception) { + Simber.e("Unable to get [$imagePath] OCR image", e, tag = MULTI_FACTOR_ID) + } + } + } 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 new file mode 100644 index 0000000000..e2fae9c9e1 --- /dev/null +++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/search/ExternalCredentialSearchViewModel.kt @@ -0,0 +1,35 @@ +package com.simprints.feature.externalcredential.screens.search + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import com.simprints.core.domain.common.FlowType +import com.simprints.feature.externalcredential.screens.search.model.ScannedCredential +import com.simprints.feature.externalcredential.screens.search.model.SearchCredentialState +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject + + +internal class ExternalCredentialSearchViewModel @AssistedInject constructor( + @Assisted val scannedCredential: ScannedCredential, + @Assisted val flowType: FlowType, +) : ViewModel() { + + @AssistedFactory + interface Factory { + fun create(scannedCredential: ScannedCredential, flowType: FlowType): ExternalCredentialSearchViewModel + } + + private var state: SearchCredentialState = SearchCredentialState.buildInitial(scannedCredential, flowType) + set(value) { + field = value + _stateLiveData.postValue(value) + } + private val _stateLiveData = MutableLiveData(state) + val stateLiveData: LiveData = _stateLiveData + private fun updateState(state: (SearchCredentialState) -> SearchCredentialState) { + this.state = state(this.state) + } + +} 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 new file mode 100644 index 0000000000..b579c4b5e8 --- /dev/null +++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/search/model/SearchCredentialState.kt @@ -0,0 +1,32 @@ +package com.simprints.feature.externalcredential.screens.search.model + +import androidx.annotation.Keep +import com.simprints.core.domain.common.FlowType + +@Keep +internal data class SearchCredentialState( + val scannedCredential: ScannedCredential, + val flowType: FlowType, + val searchState: SearchState, + val isConfirmed: Boolean, +) { + companion object { + fun buildInitial(scannedCredential: ScannedCredential, flowType: FlowType) = + SearchCredentialState( + scannedCredential = scannedCredential, + flowType = flowType, + searchState = SearchState.Searching, + isConfirmed = false + ) + } +} + +internal sealed class SearchState { + data object Searching : SearchState() + + data class SubjectFound( + val subjectId: String + ) : SearchState() + + data object SubjectNotFound : SearchState() +} diff --git a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/search/usecase/ZoomOntoCredentialUseCase.kt b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/search/usecase/ZoomOntoCredentialUseCase.kt new file mode 100644 index 0000000000..6887b9708c --- /dev/null +++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/search/usecase/ZoomOntoCredentialUseCase.kt @@ -0,0 +1,102 @@ +package com.simprints.feature.externalcredential.screens.search.usecase + +import android.graphics.Bitmap +import com.simprints.feature.externalcredential.model.BoundingBox +import javax.inject.Inject +import kotlin.math.max +import kotlin.math.min + +internal class ZoomOntoCredentialUseCase @Inject constructor() { + + companion object { + // All currently scanned documents are in 16:10 format + private const val TARGET_ASPECT_RATIO = 16f / 10f + // Increase bounding box by this percentage on each side to provide breathing room + private const val BOX_INCREASE_PERCENTAGE = 15 + } + + /** + * Zooms into given image. Zoom area defined by the [boundingBox] + * + * @param bitmap bitmap containing the document to zoom into + * @param boundingBox bounding box that defines the zoom area + * @return zoomed-in bitmap + */ + operator fun invoke(bitmap: Bitmap, boundingBox: BoundingBox): Bitmap { + // Expand the bounding box before processing + val expandedBox = expandBoundingBox(boundingBox, bitmap.width, bitmap.height) + + // Ensuring bounding box is within image bounds + val left = expandedBox.left.coerceIn(0, bitmap.width) + val top = expandedBox.top.coerceIn(0, bitmap.height) + val right = expandedBox.right.coerceIn(left, bitmap.width) + val bottom = expandedBox.bottom.coerceIn(top, bitmap.height) + + val boxWidth = right - left + val boxHeight = bottom - top + + if (boxWidth <= 0 || boxHeight <= 0) { + return bitmap + } + + val boxAspectRatio = boxWidth.toFloat() / boxHeight.toFloat() + + val finalWidth: Int + val finalHeight: Int + if (boxAspectRatio > TARGET_ASPECT_RATIO) { + // Bounding box is wider than 16:10, adding padding to top/bottom + finalWidth = boxWidth + finalHeight = (boxWidth / TARGET_ASPECT_RATIO).toInt() + } else { + // Bounding box is higher than 16:10, adding padding to left/right + finalHeight = boxHeight + finalWidth = (boxHeight * TARGET_ASPECT_RATIO).toInt() + } + + // Center the bounding box within the final dimensions + val extraWidth = finalWidth - boxWidth + val extraHeight = finalHeight - boxHeight + + // Calculate crop region, trying to keep bounding box centered + val cropLeft = max(0, left - extraWidth / 2) + val cropTop = max(0, top - extraHeight / 2) + val cropRight = min(bitmap.width, cropLeft + finalWidth) + val cropBottom = min(bitmap.height, cropTop + finalHeight) + + // Adjust if we hit image boundaries + val adjustedLeft = max(0, cropRight - finalWidth) + val adjustedTop = max(0, cropBottom - finalHeight) + + val actualWidth = cropRight - adjustedLeft + val actualHeight = cropBottom - adjustedTop + return Bitmap.createBitmap(bitmap, adjustedLeft, adjustedTop, actualWidth, actualHeight) + } + + /** + * Expands a bounding box by a percentage on all sides. + * + * @param box Original bounding box + * @param imageWidth Width of the source image + * @param imageHeight Height of the source image + * @return Expanded bounding box (may exceed image bounds, will be clamped later) + */ + private fun expandBoundingBox( + box: BoundingBox, + imageWidth: Int, + imageHeight: Int + ): BoundingBox { + val boxWidth = box.right - box.left + val boxHeight = box.bottom - box.top + + // Calculate the expansion amount for each dimension + val horizontalExpansion = (boxWidth * BOX_INCREASE_PERCENTAGE / 100f).toInt() + val verticalExpansion = (boxHeight * BOX_INCREASE_PERCENTAGE / 100f).toInt() + + return BoundingBox( + left = box.left - horizontalExpansion, + top = box.top - verticalExpansion, + right = box.right + horizontalExpansion, + bottom = box.bottom + verticalExpansion + ) + } +} diff --git a/feature/external-credential/src/main/res/drawable/glow_background.xml b/feature/external-credential/src/main/res/drawable/glow_background.xml new file mode 100644 index 0000000000..783e5e04c5 --- /dev/null +++ b/feature/external-credential/src/main/res/drawable/glow_background.xml @@ -0,0 +1,13 @@ + + + + + diff --git a/feature/external-credential/src/main/res/drawable/ic_done.xml b/feature/external-credential/src/main/res/drawable/ic_done.xml new file mode 100644 index 0000000000..e5cf4c270e --- /dev/null +++ b/feature/external-credential/src/main/res/drawable/ic_done.xml @@ -0,0 +1,10 @@ + + + diff --git a/feature/external-credential/src/main/res/drawable/ic_edit.xml b/feature/external-credential/src/main/res/drawable/ic_edit.xml new file mode 100644 index 0000000000..555e772a31 --- /dev/null +++ b/feature/external-credential/src/main/res/drawable/ic_edit.xml @@ -0,0 +1,10 @@ + + + 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 b9a7fef197..a290e12ebb 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 @@ -1,18 +1,138 @@ + android:layout_height="match_parent" + android:background="@color/simprints_blue" + android:padding="@dimen/margin_large"> - + app:layout_constraintTop_toTopOf="parent"> + + + + + + + + + + + + + + + + + + + + + + + +