diff --git a/lib/src/main/java/com/smileidentity/compose/document/DocumentCaptureScreen.kt b/lib/src/main/java/com/smileidentity/compose/document/DocumentCaptureScreen.kt index 7e4da6c9..9d253452 100644 --- a/lib/src/main/java/com/smileidentity/compose/document/DocumentCaptureScreen.kt +++ b/lib/src/main/java/com/smileidentity/compose/document/DocumentCaptureScreen.kt @@ -4,7 +4,6 @@ import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.PickVisualMediaRequest import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts.PickVisualMedia.ImageOnly -import androidx.annotation.DrawableRes import androidx.compose.animation.core.animateFloatAsState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -17,11 +16,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel import com.smileidentity.R import com.smileidentity.SmileIDCrashReporting import com.smileidentity.compose.components.LocalMetadata -import com.smileidentity.compose.nav.DocumentInstructionParams -import com.smileidentity.compose.nav.ImageConfirmParams import com.smileidentity.compose.nav.ResultCallbacks -import com.smileidentity.compose.nav.Routes -import com.smileidentity.compose.nav.encodeUrl import com.smileidentity.compose.nav.localNavigationState import com.smileidentity.models.v2.Metadatum import com.smileidentity.util.createDocumentFile @@ -48,19 +43,11 @@ internal fun DocumentCaptureScreen( resultCallbacks: ResultCallbacks, jobId: String, side: DocumentCaptureSide, - showInstructions: Boolean, - showAttribution: Boolean, - allowGallerySelection: Boolean, - showSkipButton: Boolean, - @DrawableRes instructionsHeroImage: Int, - instructionsTitleText: String, - instructionsSubtitleText: String, captureTitleText: String, - knownIdAspectRatio: Float?, onConfirm: (File) -> Unit, onError: (Throwable) -> Unit, + knownIdAspectRatio: Float?, modifier: Modifier = Modifier, - showConfirmation: Boolean = true, metadata: SnapshotStateList = LocalMetadata.current, onSkip: () -> Unit = { }, viewModel: DocumentCaptureViewModel = viewModel( @@ -97,7 +84,6 @@ internal fun DocumentCaptureScreen( ) val uiState by viewModel.uiState.collectAsStateWithLifecycle() val documentImageToConfirm = uiState.documentImageToConfirm - val captureError = uiState.captureError resultCallbacks.onDocumentInstructionAcknowledgedSelectFromGallery = { Timber.v("onInstructionsAcknowledgedSelectFromGallery") SmileIDCrashReporting.hub.addBreadcrumb("Selecting document photo from gallery") @@ -108,60 +94,14 @@ internal fun DocumentCaptureScreen( localNavigationState.screensNavigation.getNavController.popBackStack() } resultCallbacks.onDocumentInstructionSkip = onSkip + val aspectRatio by animateFloatAsState( + targetValue = uiState.idAspectRatio, + label = "ID Aspect Ratio", + ) when { - captureError != null -> onError(captureError) - showInstructions && !uiState.acknowledgedInstructions -> { - localNavigationState.screensNavigation.navigateTo( - Routes.Document.InstructionScreen( - params = DocumentInstructionParams( - heroImage = instructionsHeroImage, - title = instructionsTitleText, - subtitle = instructionsSubtitleText, - showAttribution = showAttribution, - allowPhotoFromGallery = allowGallerySelection, - showSkipButton = showSkipButton, - ), - ), - popUpTo = true, - popUpToInclusive = true, - ) - } - - documentImageToConfirm != null -> { - if (showConfirmation) { - resultCallbacks.onConfirmCapturedImage = { - viewModel.onConfirm(documentImageToConfirm, onConfirm) - localNavigationState.screensNavigation.getNavController.popBackStack() - } - resultCallbacks.onImageDialogRetake = { - viewModel.onRetry() - localNavigationState.screensNavigation.getNavController.popBackStack() - } - localNavigationState.screensNavigation.navigateTo( - Routes.Shared.ImageConfirmDialog( - ImageConfirmParams( - titleText = R.string.si_smart_selfie_confirmation_dialog_title, - subtitleText = R.string.si_smart_selfie_confirmation_dialog_subtitle, - imageFilePath = encodeUrl(documentImageToConfirm.absolutePath), - confirmButtonText = - R.string.si_doc_v_confirmation_dialog_confirm_button, - retakeButtonText = R.string.si_doc_v_confirmation_dialog_retake_button, - scaleFactor = 1.0f, - ), - ), - popUpTo = true, - popUpToInclusive = true, - ) - } else { - viewModel.onConfirm(documentImageToConfirm, onConfirm) - } - } - + documentImageToConfirm != null -> + viewModel.onConfirm(documentImageToConfirm, onConfirm) else -> { - val aspectRatio by animateFloatAsState( - targetValue = uiState.idAspectRatio, - label = "ID Aspect Ratio", - ) CaptureScreenContent( titleText = captureTitleText, subtitleText = stringResource(id = uiState.directive.displayText), @@ -176,13 +116,4 @@ internal fun DocumentCaptureScreen( ) } } - - // NavigationBackHandler( - // navController = localNavigationState.screensNavigation.getNavController, - // ) { currentDestination -> - // localNavigationState.screensNavigation.getNavController.popBackStack() - // if (compareRouteStrings(startRoute, currentDestination)) { - // onResult(SmileIDResult.Error(OperationCanceledException("User cancelled"))) - // } - // } } diff --git a/lib/src/main/java/com/smileidentity/compose/document/OrchestratedDocumentVerificationScreen.kt b/lib/src/main/java/com/smileidentity/compose/document/OrchestratedDocumentVerificationScreen.kt index 7527c030..4f440435 100644 --- a/lib/src/main/java/com/smileidentity/compose/document/OrchestratedDocumentVerificationScreen.kt +++ b/lib/src/main/java/com/smileidentity/compose/document/OrchestratedDocumentVerificationScreen.kt @@ -19,21 +19,27 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.smileidentity.R +import com.smileidentity.compose.components.ProcessingState import com.smileidentity.compose.nav.DocumentCaptureParams +import com.smileidentity.compose.nav.DocumentInstructionParams +import com.smileidentity.compose.nav.ImageConfirmParams import com.smileidentity.compose.nav.NavigationBackHandler import com.smileidentity.compose.nav.OrchestratedSelfieCaptureParams import com.smileidentity.compose.nav.ProcessingScreenParams import com.smileidentity.compose.nav.ResultCallbacks import com.smileidentity.compose.nav.Routes import com.smileidentity.compose.nav.SelfieCaptureParams -import com.smileidentity.compose.nav.compareRouteStrings +import com.smileidentity.compose.nav.encodeUrl import com.smileidentity.compose.nav.localNavigationState import com.smileidentity.models.DocumentCaptureFlow import com.smileidentity.results.SmileIDCallback import com.smileidentity.results.SmileIDResult +import com.smileidentity.util.StringResource import com.smileidentity.util.randomJobId import com.smileidentity.util.randomUserId +import com.smileidentity.viewmodel.document.OrchestratedDocumentUiState import com.smileidentity.viewmodel.document.OrchestratedDocumentViewModel +import java.io.File /** * Orchestrates the document capture flow - navigates between instructions, requesting permissions, @@ -54,22 +60,24 @@ internal fun OrchestratedDocumentVerificationScreen( showInstructions: Boolean = true, onResult: SmileIDCallback = {}, ) { - val uiState = viewModel.uiState.collectAsStateWithLifecycle().value + val uiState by viewModel.uiState.collectAsStateWithLifecycle() var startRoute: Routes? by rememberSaveable { mutableStateOf(null) } - resultCallbacks.onDocumentFrontCaptureSuccess = viewModel::onDocumentFrontCaptureSuccess - resultCallbacks.onDocumentBackCaptureSuccess = viewModel::onDocumentBackCaptureSuccess - resultCallbacks.onDocumentCaptureError = viewModel::onError - resultCallbacks.onDocumentBackSkip = viewModel::onDocumentBackSkip - resultCallbacks.onInstructionsAcknowledgedTakePhoto = { - } - resultCallbacks.onProcessingContinue = { viewModel.onFinished(onResult) } - resultCallbacks.onProcessingClose = { viewModel.onFinished(onResult) } - resultCallbacks.onSmartSelfieResult = { - when (it) { - is SmileIDResult.Error -> viewModel.onError(it.throwable) - is SmileIDResult.Success -> viewModel.onSelfieCaptureSuccess(it) + var acknowledgedBackInstructions by rememberSaveable { mutableStateOf(false) } + + resultCallbacks.apply { + onDocumentFrontCaptureSuccess = viewModel::onFrontDocCaptured + onDocumentBackCaptureSuccess = viewModel::onBackDocCaptured + onDocumentCaptureError = viewModel::onError + onDocumentBackSkip = viewModel::onDocumentBackSkip + onProcessingContinue = { viewModel.onFinished(onResult) } + onProcessingClose = { viewModel.onFinished(onResult) } + onSmartSelfieResult = { result -> + when (result) { + is SmileIDResult.Error -> viewModel.onError(result.throwable) + is SmileIDResult.Success -> viewModel.onSelfieCaptureSuccess(result) + } + localNavigationState.orchestratedNavigation.getNavController.popBackStack() } - localNavigationState.orchestratedNavigation.getNavController.popBackStack() } Box( modifier = modifier @@ -80,103 +88,308 @@ internal fun OrchestratedDocumentVerificationScreen( ) { content() } - when (val currentStep = uiState.currentStep) { - DocumentCaptureFlow.FrontDocumentCapture -> { - startRoute = Routes.Document.CaptureFrontScreen( - DocumentCaptureParams( - jobId = jobId, - userId = userId, - showInstructions = showInstructions, - showAttribution = showAttribution, - allowGallerySelection = allowGalleryUpload, - showSkipButton = false, - instructionsHeroImage = R.drawable.si_doc_v_front_hero, - instructionsTitleText = R.string.si_doc_v_instruction_title, - instructionsSubtitleText = R.string.si_verify_identity_instruction_subtitle, - captureTitleText = R.string.si_doc_v_capture_instructions_front_title, - knownIdAspectRatio = idAspectRatio, - ), - ) - startRoute?.let { - localNavigationState.screensNavigation.navigateTo( - startRoute as Routes.Document.CaptureFrontScreen, - popUpTo = true, - popUpToInclusive = true, - ) - } - } - DocumentCaptureFlow.BackDocumentCapture -> - localNavigationState.screensNavigation.navigateTo( - Routes.Document.CaptureBackScreen( - DocumentCaptureParams( - jobId = jobId, - userId = userId, - showInstructions = showInstructions, - showAttribution = showAttribution, - allowGallerySelection = allowGalleryUpload, - showSkipButton = false, - instructionsHeroImage = R.drawable.si_doc_v_back_hero, - instructionsTitleText = R.string.si_doc_v_instruction_back_title, - instructionsSubtitleText = R.string.si_doc_v_instruction_back_subtitle, - captureTitleText = R.string.si_doc_v_capture_instructions_back_title, - knownIdAspectRatio = idAspectRatio, - ), - ), - popUpTo = true, - popUpToInclusive = true, - ) - - DocumentCaptureFlow.SelfieCapture -> - localNavigationState.orchestratedNavigation.navigateTo( - Routes.Orchestrated.SelfieRoute( - OrchestratedSelfieCaptureParams( - SelfieCaptureParams( - userId = userId, - jobId = jobId, - showInstructions = showInstructions, - showAttribution = showAttribution, - allowAgentMode = allowAgentMode, - skipApiSubmission = true, - ), - ), - ), - popUpTo = false, - popUpToInclusive = false, - ) - - is DocumentCaptureFlow.ProcessingScreen -> - localNavigationState.screensNavigation.navigateTo( - Routes.Shared.ProcessingScreen( - ProcessingScreenParams( - processingState = currentStep.processingState, - inProgressTitle = R.string.si_doc_v_processing_title, - inProgressSubtitle = R.string.si_doc_v_processing_subtitle, - inProgressIcon = R.drawable.si_doc_v_processing_hero, - successTitle = R.string.si_doc_v_processing_success_title, - successSubtitle = uiState.errorMessage.resolve().takeIf { it.isNotEmpty() } - ?: stringResource(R.string.si_doc_v_processing_success_subtitle), - successIcon = R.drawable.si_processing_success, - errorTitle = R.string.si_doc_v_processing_error_title, - errorSubtitle = uiState.errorMessage.resolve().takeIf { it.isNotEmpty() } - ?: stringResource(id = R.string.si_processing_error_subtitle), - errorIcon = R.drawable.si_processing_error, - continueButtonText = R.string.si_continue, - retryButtonText = R.string.si_smart_selfie_processing_retry_button, - closeButtonText = R.string.si_smart_selfie_processing_close_button, - ), - ), - popUpTo = true, - popUpToInclusive = true, + if (uiState.currentStep is DocumentCaptureFlow.FrontDocumentCapture) { + resultCallbacks.onInstructionsAcknowledgedTakePhoto = { + navigateToDocumentCaptureScreen( + R.drawable.si_doc_v_front_hero, + R.string.si_doc_v_instruction_title, + R.string.si_verify_identity_instruction_subtitle, + R.string.si_doc_v_capture_instructions_front_title, + userId, + jobId, + showInstructions, + showAttribution, + allowGalleryUpload, + idAspectRatio, ) + } + resultCallbacks.onConfirmCapturedImage = viewModel::onDocumentFrontCaptureSuccess + } else if (uiState.currentStep is DocumentCaptureFlow.BackDocumentCapture) { + resultCallbacks.onInstructionsAcknowledgedTakePhoto = { + acknowledgedBackInstructions = true + } + resultCallbacks.onConfirmCapturedImage = viewModel::onDocumentBackCaptureSuccess } + HandleDocumentCaptureFlow( + currentStep = uiState.currentStep, + uiState = uiState, + showInstructions = showInstructions, + acknowledgedBackInstructions = acknowledgedBackInstructions, + showAttribution = showAttribution, + allowGalleryUpload = allowGalleryUpload, + userId = userId, + jobId = jobId, + idAspectRatio = idAspectRatio, + allowAgentMode = allowAgentMode, + ) + NavigationBackHandler( navController = localNavigationState.screensNavigation.getNavController, - ) { currentDestination -> + ) { _, canGoBack -> + localNavigationState.screensNavigation.getNavController.popBackStack() - if (compareRouteStrings(startRoute, currentDestination)) { + if (!canGoBack) { onResult(SmileIDResult.Error(OperationCanceledException("User cancelled"))) } } } + +@Composable +private fun HandleDocumentCaptureFlow( + currentStep: DocumentCaptureFlow, + uiState: OrchestratedDocumentUiState, + showInstructions: Boolean, + acknowledgedBackInstructions: Boolean, + showAttribution: Boolean, + allowGalleryUpload: Boolean, + userId: String, + jobId: String, + idAspectRatio: Float?, + allowAgentMode: Boolean, +) { + when (currentStep) { + DocumentCaptureFlow.FrontDocumentCapture -> { + HandleFrontDocumentCapture( + showInstructions, + true, + uiState.documentFrontFile, + showAttribution, + allowGalleryUpload, + userId, + jobId, + idAspectRatio, + ) + } + + DocumentCaptureFlow.BackDocumentCapture -> HandleBackDocumentCapture( + showInstructions, + acknowledgedBackInstructions, + uiState.documentBackFile, + showAttribution, + allowGalleryUpload, + userId, + jobId, + idAspectRatio, + ) + + DocumentCaptureFlow.SelfieCapture -> HandleSelfieCapture( + userId, + jobId, + showInstructions, + showAttribution, + allowAgentMode, + ) + + is DocumentCaptureFlow.ProcessingScreen -> HandleProcessingScreen( + currentStep.processingState, + uiState.errorMessage, + ) + } +} + +@Composable +private fun HandleFrontDocumentCapture( + showInstructions: Boolean, + acknowledgedFrontInstructions: Boolean, + documentFrontFile: File?, + showAttribution: Boolean, + allowGalleryUpload: Boolean, + userId: String, + jobId: String, + idAspectRatio: Float?, +) { + when { + documentFrontFile != null -> NavigateToImageConfirmDialog(documentFrontFile) + } +} + +@Composable +private fun HandleBackDocumentCapture( + showInstructions: Boolean, + acknowledgedBackInstructions: Boolean, + documentBackFile: File?, + showAttribution: Boolean, + allowGalleryUpload: Boolean, + userId: String, + jobId: String, + idAspectRatio: Float?, +) { + when { + showInstructions && !acknowledgedBackInstructions -> NavigateToInstructionScreen( + R.drawable.si_doc_v_back_hero, + R.string.si_doc_v_instruction_back_title, + R.string.si_doc_v_instruction_back_subtitle, + showAttribution, + allowGalleryUpload, + ) + + documentBackFile != null -> NavigateToImageConfirmDialog(documentBackFile) + else -> navigateToDocumentCaptureScreen( + R.drawable.si_doc_v_back_hero, + R.string.si_doc_v_instruction_back_title, + R.string.si_doc_v_instruction_back_subtitle, + R.string.si_doc_v_capture_instructions_back_title, + userId, + jobId, + showInstructions, + showAttribution, + allowGalleryUpload, + idAspectRatio, + false, + ) + } +} + +@Composable +private fun HandleSelfieCapture( + userId: String, + jobId: String, + showInstructions: Boolean, + showAttribution: Boolean, + allowAgentMode: Boolean, +) { + localNavigationState.orchestratedNavigation.navigateTo( + Routes.Orchestrated.SelfieRoute( + OrchestratedSelfieCaptureParams( + SelfieCaptureParams( + userId = userId, + jobId = jobId, + showInstructions = showInstructions, + showAttribution = showAttribution, + allowAgentMode = allowAgentMode, + skipApiSubmission = true, + ), + ), + ), + popUpTo = false, + popUpToInclusive = false, + ) +} + +@Composable +private fun HandleProcessingScreen( + processingState: ProcessingState, + errorMessage: StringResource, +) { + localNavigationState.screensNavigation.navigateTo( + Routes.Shared.ProcessingScreen( + ProcessingScreenParams( + processingState = processingState, + inProgressTitle = R.string.si_doc_v_processing_title, + inProgressSubtitle = R.string.si_doc_v_processing_subtitle, + inProgressIcon = R.drawable.si_doc_v_processing_hero, + successTitle = R.string.si_doc_v_processing_success_title, + successSubtitle = errorMessage.resolve().takeIf { it.isNotEmpty() } + ?: stringResource(R.string.si_doc_v_processing_success_subtitle), + successIcon = R.drawable.si_processing_success, + errorTitle = R.string.si_doc_v_processing_error_title, + errorSubtitle = errorMessage.resolve().takeIf { it.isNotEmpty() } + ?: stringResource(id = R.string.si_processing_error_subtitle), + errorIcon = R.drawable.si_processing_error, + continueButtonText = R.string.si_continue, + retryButtonText = R.string.si_smart_selfie_processing_retry_button, + closeButtonText = R.string.si_smart_selfie_processing_close_button, + ), + ), + popUpTo = true, + popUpToInclusive = true, + ) +} + +@Composable +private fun NavigateToInstructionScreen( + heroImage: Int, + titleRes: Int, + subtitleRes: Int, + showAttribution: Boolean, + allowGalleryUpload: Boolean, +) { + localNavigationState.screensNavigation.navigateTo( + Routes.Document.InstructionScreen( + params = DocumentInstructionParams( + heroImage = heroImage, + title = stringResource(titleRes), + subtitle = stringResource(subtitleRes), + showAttribution = showAttribution, + allowPhotoFromGallery = allowGalleryUpload, + showSkipButton = false, + ), + ), + popUpTo = true, + popUpToInclusive = true, + ) +} + +@Composable +private fun NavigateToImageConfirmDialog(documentFile: File) { + localNavigationState.screensNavigation.navigateTo( + Routes.Shared.ImageConfirmDialog( + ImageConfirmParams( + titleText = R.string.si_doc_v_confirmation_dialog_title, + subtitleText = R.string.si_doc_v_confirmation_dialog_subtitle, + imageFilePath = encodeUrl(documentFile.absolutePath), + confirmButtonText = R.string.si_doc_v_confirmation_dialog_confirm_button, + retakeButtonText = R.string.si_doc_v_confirmation_dialog_retake_button, + scaleFactor = 1.0f, + ), + ), + popUpTo = false, + popUpToInclusive = false, + ) +} + +private fun navigateToDocumentCaptureScreen( + heroImage: Int, + titleRes: Int, + subtitleRes: Int, + captureTitleRes: Int, + userId: String, + jobId: String, + showInstructions: Boolean, + showAttribution: Boolean, + allowGalleryUpload: Boolean, + idAspectRatio: Float?, + front: Boolean = true, +) { + val route = if (front) { + Routes.Document.CaptureFrontScreen( + DocumentCaptureParams( + jobId = jobId, + userId = userId, + showInstructions = showInstructions, + showAttribution = showAttribution, + allowGallerySelection = allowGalleryUpload, + showSkipButton = false, + instructionsHeroImage = heroImage, + instructionsTitleText = titleRes, + instructionsSubtitleText = subtitleRes, + captureTitleText = captureTitleRes, + knownIdAspectRatio = idAspectRatio, + ), + ) + } else { + Routes.Document.CaptureBackScreen( + DocumentCaptureParams( + jobId = jobId, + userId = userId, + showInstructions = showInstructions, + showAttribution = showAttribution, + allowGallerySelection = allowGalleryUpload, + showSkipButton = false, + instructionsHeroImage = heroImage, + instructionsTitleText = titleRes, + instructionsSubtitleText = subtitleRes, + captureTitleText = captureTitleRes, + knownIdAspectRatio = idAspectRatio, + ), + ) + } + + localNavigationState.screensNavigation.navigateTo( + route, + popUpTo = true, + popUpToInclusive = true, + ) +} diff --git a/lib/src/main/java/com/smileidentity/compose/nav/NavUtil.kt b/lib/src/main/java/com/smileidentity/compose/nav/NavUtil.kt index b2f79ded..b9ab8f0c 100644 --- a/lib/src/main/java/com/smileidentity/compose/nav/NavUtil.kt +++ b/lib/src/main/java/com/smileidentity/compose/nav/NavUtil.kt @@ -3,8 +3,11 @@ package com.smileidentity.compose.nav import android.os.Build import android.os.Bundle import android.os.Parcelable +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource import androidx.navigation.NavDestination import androidx.navigation.NavType +import com.smileidentity.R import java.io.File import java.net.URLDecoder import java.net.URLEncoder @@ -33,6 +36,7 @@ class CustomNavType( override val name: String = clazz.name } +@Composable internal fun getDocumentCaptureRoute( countryCode: String, params: DocumentCaptureParams, @@ -43,32 +47,48 @@ internal fun getDocumentCaptureRoute( allowGalleryUpload: Boolean, ): Routes { val serializableFile = bypassSelfieCaptureWithFile?.let { SerializableFile.fromFile(it) } - return Routes.Document.CaptureFrontScreen( - DocumentCaptureParams( - userId = params.userId, - jobId = params.jobId, - showInstructions = params.showInstructions, - showAttribution = params.showAttribution, - allowAgentMode = params.allowAgentMode, - allowGallerySelection = allowGalleryUpload, - showSkipButton = params.showSkipButton, - instructionsHeroImage = params.instructionsHeroImage, - instructionsTitleText = params.instructionsTitleText, - instructionsSubtitleText = params.instructionsSubtitleText, - captureTitleText = params.captureTitleText, - knownIdAspectRatio = idAspectRatio, - allowNewEnroll = params.allowNewEnroll, - countryCode = countryCode, - documentType = documentType, - captureBothSides = captureBothSides, - selfieFile = serializableFile, - extraPartnerParams = params.extraPartnerParams, - ), - ) + return if (params.showInstructions) { + Routes.Document.InstructionScreen( + params = DocumentInstructionParams( + R.drawable.si_doc_v_front_hero, + stringResource(R.string.si_doc_v_instruction_title), + stringResource(R.string.si_verify_identity_instruction_subtitle), + params.showAttribution, + allowGalleryUpload, + ), + ) + } else { + Routes.Document.CaptureFrontScreen( + DocumentCaptureParams( + userId = params.userId, + jobId = params.jobId, + showInstructions = true, + showAttribution = params.showAttribution, + allowAgentMode = params.allowAgentMode, + allowGallerySelection = allowGalleryUpload, + showSkipButton = params.showSkipButton, + instructionsHeroImage = params.instructionsHeroImage, + instructionsTitleText = params.instructionsTitleText, + instructionsSubtitleText = params.instructionsSubtitleText, + captureTitleText = params.captureTitleText, + knownIdAspectRatio = idAspectRatio, + allowNewEnroll = params.allowNewEnroll, + countryCode = countryCode, + documentType = documentType, + captureBothSides = captureBothSides, + selfieFile = serializableFile, + extraPartnerParams = params.extraPartnerParams, + ), + ) + } } internal fun getSelfieCaptureRoute(useStrictMode: Boolean, params: SelfieCaptureParams): Routes { - return if (useStrictMode) { + return if (params.showInstructions) { + Routes.Selfie.InstructionsScreen( + InstructionScreenParams(params.showAttribution), + ) + } else if (useStrictMode) { Routes.Selfie.CaptureScreenV2(params) } else { Routes.Selfie.CaptureScreen(params) diff --git a/lib/src/main/java/com/smileidentity/compose/nav/NavigationBackHandler.kt b/lib/src/main/java/com/smileidentity/compose/nav/NavigationBackHandler.kt index e49051e6..d6f1203f 100644 --- a/lib/src/main/java/com/smileidentity/compose/nav/NavigationBackHandler.kt +++ b/lib/src/main/java/com/smileidentity/compose/nav/NavigationBackHandler.kt @@ -2,6 +2,7 @@ package com.smileidentity.compose.nav import androidx.activity.compose.BackHandler import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue import androidx.navigation.NavController import androidx.navigation.NavDestination @@ -11,11 +12,17 @@ import androidx.navigation.compose.currentBackStackEntryAsState fun NavigationBackHandler( navController: NavController, enabled: Boolean = true, - onBack: (currentDestination: NavDestination?) -> Unit, + onBack: (currentDestination: NavDestination?, canGoBack: Boolean) -> Unit, ) { val currentBackStackEntry by navController.currentBackStackEntryAsState() val currentDestination = currentBackStackEntry?.destination + val canGoBack = navController.previousBackStackEntry != null + + DisposableEffect(currentDestination, canGoBack) { + onDispose { } + } + BackHandler(enabled = enabled) { - onBack(currentDestination) + onBack(currentDestination, canGoBack) } } diff --git a/lib/src/main/java/com/smileidentity/compose/nav/SmileIDNav.kt b/lib/src/main/java/com/smileidentity/compose/nav/SmileIDNav.kt index 13bf001a..ed3b8657 100644 --- a/lib/src/main/java/com/smileidentity/compose/nav/SmileIDNav.kt +++ b/lib/src/main/java/com/smileidentity/compose/nav/SmileIDNav.kt @@ -232,13 +232,39 @@ internal fun NavGraphBuilder.screensNavGraph( ) { sharedDestinations(resultCallbacks) selfieDestinations(resultCallbacks) - documentParentDestinations(resultCallbacks) documentsDestinations(resultCallbacks) } -internal fun NavGraphBuilder.documentParentDestinations( +internal fun NavGraphBuilder.documentsDestinations( resultCallbacks: ResultCallbacks = ResultCallbacks(), ) { + composable( + typeMap = mapOf( + typeOf() to CustomNavType( + DocumentInstructionParams::class.java, + DocumentInstructionParams.serializer(), + ), + ), + ) { navBackStackEntry -> + val route = navBackStackEntry.toRoute() + val params = route.params + DocumentCaptureInstructionsScreen( + heroImage = params.heroImage, + title = params.title, + subtitle = params.subtitle, + showAttribution = params.showAttribution, + allowPhotoFromGallery = params.allowPhotoFromGallery, + showSkipButton = params.showSkipButton, + onSkip = { resultCallbacks.onDocumentInstructionSkip?.invoke() }, + onInstructionsAcknowledgedSelectFromGallery = { + resultCallbacks.onDocumentInstructionAcknowledgedSelectFromGallery?.invoke() + }, + onInstructionsAcknowledgedTakePhoto = { + resultCallbacks.onInstructionsAcknowledgedTakePhoto?.invoke() + }, + ) + } + composable( typeMap = mapOf( typeOf() to CustomNavType( @@ -252,18 +278,15 @@ internal fun NavGraphBuilder.documentParentDestinations( DocumentCaptureScreen( resultCallbacks = resultCallbacks, jobId = params.jobId, - showInstructions = params.showInstructions, - showAttribution = params.showAttribution, - allowGallerySelection = params.allowGallerySelection, - showSkipButton = params.showSkipButton, side = DocumentCaptureSide.Front, - instructionsHeroImage = params.instructionsHeroImage, - instructionsTitleText = stringResource(params.instructionsTitleText), knownIdAspectRatio = params.knownIdAspectRatio, - instructionsSubtitleText = stringResource(params.instructionsSubtitleText), captureTitleText = stringResource(params.captureTitleText), - onConfirm = resultCallbacks.onDocumentFrontCaptureSuccess ?: {}, - onError = resultCallbacks.onDocumentCaptureError ?: {}, + onConfirm = { file -> + resultCallbacks.onDocumentFrontCaptureSuccess?.invoke(file) + }, + onError = { error -> + resultCallbacks.onDocumentCaptureError?.invoke(error) + }, ) } @@ -280,49 +303,15 @@ internal fun NavGraphBuilder.documentParentDestinations( DocumentCaptureScreen( resultCallbacks = resultCallbacks, jobId = params.jobId, - showInstructions = params.showInstructions, - showAttribution = params.showAttribution, - allowGallerySelection = params.allowGallerySelection, - showSkipButton = params.showSkipButton, - side = DocumentCaptureSide.Back, - instructionsHeroImage = params.instructionsHeroImage, - instructionsTitleText = stringResource(params.instructionsTitleText), + side = DocumentCaptureSide.Front, knownIdAspectRatio = params.knownIdAspectRatio, - instructionsSubtitleText = stringResource(params.instructionsSubtitleText), captureTitleText = stringResource(params.captureTitleText), - onConfirm = resultCallbacks.onDocumentBackCaptureSuccess ?: {}, - onError = resultCallbacks.onDocumentCaptureError ?: {}, - ) - } -} - -internal fun NavGraphBuilder.documentsDestinations( - resultCallbacks: ResultCallbacks = ResultCallbacks(), -) { - composable( - typeMap = mapOf( - typeOf() to CustomNavType( - DocumentInstructionParams::class.java, - DocumentInstructionParams.serializer(), - ), - ), - ) { navBackStackEntry -> - val route = navBackStackEntry.toRoute() - val params = route.params - DocumentCaptureInstructionsScreen( - heroImage = params.heroImage, - title = params.title, - subtitle = params.subtitle, - showAttribution = params.showAttribution, - allowPhotoFromGallery = params.allowPhotoFromGallery, - showSkipButton = params.showSkipButton, - onSkip = resultCallbacks.onDocumentInstructionSkip ?: {}, - onInstructionsAcknowledgedSelectFromGallery = - resultCallbacks.onDocumentInstructionAcknowledgedSelectFromGallery - ?: {}, - onInstructionsAcknowledgedTakePhoto = - resultCallbacks.onInstructionsAcknowledgedTakePhoto - ?: {}, + onConfirm = { file -> + resultCallbacks.onDocumentBackCaptureSuccess?.invoke(file) + }, + onError = { error -> + resultCallbacks.onDocumentCaptureError?.invoke(error) + }, ) } @@ -387,7 +376,9 @@ internal fun NavGraphBuilder.selfieDestinations( val params = route.params SmartSelfieInstructionsScreen( showAttribution = params.showAttribution, - onInstructionsAcknowledged = resultCallbacks.onSelfieInstructionScreen ?: {}, + onInstructionsAcknowledged = { + resultCallbacks.onSelfieInstructionScreen?.invoke() + }, ) } } diff --git a/lib/src/main/java/com/smileidentity/compose/selfie/OrchestratedSelfieCaptureScreen.kt b/lib/src/main/java/com/smileidentity/compose/selfie/OrchestratedSelfieCaptureScreen.kt index 213c525a..afc73397 100644 --- a/lib/src/main/java/com/smileidentity/compose/selfie/OrchestratedSelfieCaptureScreen.kt +++ b/lib/src/main/java/com/smileidentity/compose/selfie/OrchestratedSelfieCaptureScreen.kt @@ -10,10 +10,7 @@ import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshots.SnapshotStateList import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource @@ -22,13 +19,11 @@ import androidx.lifecycle.viewmodel.compose.viewModel import com.smileidentity.R import com.smileidentity.compose.components.LocalMetadata import com.smileidentity.compose.nav.ImageConfirmParams -import com.smileidentity.compose.nav.InstructionScreenParams import com.smileidentity.compose.nav.NavigationBackHandler import com.smileidentity.compose.nav.ProcessingScreenParams import com.smileidentity.compose.nav.ResultCallbacks import com.smileidentity.compose.nav.Routes import com.smileidentity.compose.nav.SelfieCaptureParams -import com.smileidentity.compose.nav.compareRouteStrings import com.smileidentity.compose.nav.encodeUrl import com.smileidentity.compose.nav.localNavigationState import com.smileidentity.models.v2.Metadatum @@ -55,6 +50,7 @@ internal fun OrchestratedSelfieCaptureScreen( jobId: String = rememberSaveable { randomJobId() }, allowNewEnroll: Boolean = false, isEnroll: Boolean = true, + useStrictMode: Boolean = false, allowAgentMode: Boolean = false, skipApiSubmission: Boolean = false, showAttribution: Boolean = true, @@ -86,9 +82,6 @@ internal fun OrchestratedSelfieCaptureScreen( content() } val uiState = viewModel.uiState.collectAsStateWithLifecycle().value - var acknowledgedInstructions by rememberSaveable { mutableStateOf(false) } - var showingSelfie by rememberSaveable { mutableStateOf(false) } - var startRoute: Routes? by rememberSaveable { mutableStateOf(null) } resultCallbacks.selfieViewModel = viewModel resultCallbacks.onProcessingContinue = { viewModel.onFinished(onResult) @@ -106,22 +99,32 @@ internal fun OrchestratedSelfieCaptureScreen( viewModel.onSelfieRejected() } resultCallbacks.onSelfieInstructionScreen = { - acknowledgedInstructions = true - } - when { - showInstructions && !acknowledgedInstructions -> { - startRoute = Routes.Selfie.InstructionsScreen( - InstructionScreenParams(showAttribution), + val selfieParams = SelfieCaptureParams( + userId = userId, + jobId = jobId, + isEnroll = isEnroll, + allowAgentMode = allowAgentMode, + skipApiSubmission = skipApiSubmission, + showAttribution = showAttribution, + extraPartnerParams = extraPartnerParams, + showInstructions = showInstructions, + ) + val selfieRoute = if (useStrictMode) { + Routes.Selfie.CaptureScreenV2( + selfieParams, + ) + } else { + Routes.Selfie.CaptureScreen( + selfieParams, ) - startRoute?.let { - localNavigationState.screensNavigation.navigateTo( - it, - popUpTo = false, - popUpToInclusive = false, - ) - } } - + localNavigationState.screensNavigation.navigateTo( + selfieRoute, + popUpTo = false, + popUpToInclusive = false, + ) + } + when { uiState.processingState != null -> { localNavigationState.screensNavigation.navigateTo( Routes.Shared.ProcessingScreen( @@ -166,39 +169,13 @@ internal fun OrchestratedSelfieCaptureScreen( popUpToInclusive = false, ) } - - else -> { - if (!showingSelfie) { - showingSelfie = true - val selfieRoute = Routes.Selfie.CaptureScreen( - SelfieCaptureParams( - userId = userId, - jobId = jobId, - isEnroll = isEnroll, - allowAgentMode = allowAgentMode, - skipApiSubmission = skipApiSubmission, - showAttribution = showAttribution, - extraPartnerParams = extraPartnerParams, - showInstructions = showInstructions, - ), - ) - if (!showInstructions) { - startRoute = selfieRoute - } - localNavigationState.screensNavigation.navigateTo( - selfieRoute, - popUpTo = false, - popUpToInclusive = false, - ) - } - } } NavigationBackHandler( navController = localNavigationState.screensNavigation.getNavController, - ) { currentDestination -> + ) { _, canGoBack -> localNavigationState.screensNavigation.getNavController.popBackStack() - if (compareRouteStrings(startRoute, currentDestination)) { + if (!canGoBack) { onResult(SmileIDResult.Error(OperationCanceledException("User cancelled"))) } } diff --git a/lib/src/main/java/com/smileidentity/compose/selfie/SelfieCaptureScreen.kt b/lib/src/main/java/com/smileidentity/compose/selfie/SelfieCaptureScreen.kt index 25cc75aa..d26f00fc 100644 --- a/lib/src/main/java/com/smileidentity/compose/selfie/SelfieCaptureScreen.kt +++ b/lib/src/main/java/com/smileidentity/compose/selfie/SelfieCaptureScreen.kt @@ -81,7 +81,6 @@ fun SelfieCaptureScreen( }, ), ) { - println("Japhet Ndhlovu is here now with this one") val uiState by viewModel.uiState.collectAsStateWithLifecycle() val cameraState = rememberCameraState() var camSelector by rememberCamSelector(CamSelector.Front) diff --git a/lib/src/main/java/com/smileidentity/viewmodel/document/OrchestratedDocumentViewModel.kt b/lib/src/main/java/com/smileidentity/viewmodel/document/OrchestratedDocumentViewModel.kt index b894ede5..20ffb5de 100644 --- a/lib/src/main/java/com/smileidentity/viewmodel/document/OrchestratedDocumentViewModel.kt +++ b/lib/src/main/java/com/smileidentity/viewmodel/document/OrchestratedDocumentViewModel.kt @@ -52,6 +52,10 @@ import timber.log.Timber internal data class OrchestratedDocumentUiState( val currentStep: DocumentCaptureFlow = DocumentCaptureFlow.FrontDocumentCapture, val errorMessage: StringResource = StringResource.ResId(R.string.si_processing_error_subtitle), + val selfieToConfirm: File? = null, + val documentFrontFile: File? = null, + val documentBackFile: File? = null, + val livenessFiles: List? = null, ) /** @@ -70,21 +74,37 @@ internal abstract class OrchestratedDocumentViewModel( private var extraPartnerParams: ImmutableMap = persistentMapOf(), private val metadata: MutableList, ) : ViewModel() { - private val _uiState = MutableStateFlow(OrchestratedDocumentUiState()) + private val _uiState = MutableStateFlow( + OrchestratedDocumentUiState( + selfieToConfirm = selfieFile, + ), + ) val uiState = _uiState.asStateFlow() var result: SmileIDResult = SmileIDResult.Error( IllegalStateException("Document Capture incomplete"), ) - private var documentFrontFile: File? = null - private var documentBackFile: File? = null - private var livenessFiles: List? = null private var stepToRetry: DocumentCaptureFlow? = null - fun onDocumentFrontCaptureSuccess(documentImageFile: File) { - documentFrontFile = documentImageFile + fun onFrontDocCaptured(documentImageFile: File) { + _uiState.update { + it.copy( + documentFrontFile = documentImageFile, + ) + } + } + + fun onBackDocCaptured(documentImageFile: File) { + _uiState.update { + it.copy( + documentBackFile = documentImageFile, + ) + } + } + + fun onDocumentFrontCaptureSuccess() { if (captureBothSides) { _uiState.update { it.copy(currentStep = DocumentCaptureFlow.BackDocumentCapture) } - } else if (selfieFile == null) { + } else if (uiState.value.selfieToConfirm == null) { _uiState.update { it.copy(currentStep = DocumentCaptureFlow.SelfieCapture) } } else { submitJob() @@ -92,25 +112,28 @@ internal abstract class OrchestratedDocumentViewModel( } fun onDocumentBackSkip() { - if (selfieFile == null) { + if (uiState.value.selfieToConfirm == null) { _uiState.update { it.copy(currentStep = DocumentCaptureFlow.SelfieCapture) } } else { submitJob() } } - fun onDocumentBackCaptureSuccess(documentImageFile: File) { - documentBackFile = documentImageFile - if (selfieFile == null) { + fun onDocumentBackCaptureSuccess() { + if (uiState.value.selfieToConfirm == null) { _uiState.update { it.copy(currentStep = DocumentCaptureFlow.SelfieCapture) } } else { submitJob() } } - fun onSelfieCaptureSuccess(it: SmileIDResult.Success) { - selfieFile = it.data.selfieFile - livenessFiles = it.data.livenessFiles + fun onSelfieCaptureSuccess(result: SmileIDResult.Success) { + _uiState.update { + it.copy( + selfieToConfirm = result.data.selfieFile, + livenessFiles = result.data.livenessFiles, + ) + } submitJob() } @@ -123,7 +146,7 @@ internal abstract class OrchestratedDocumentViewModel( ) private fun submitJob() { - val documentFrontFile = documentFrontFile + val documentFrontFile = uiState.value.documentFrontFile ?: throw IllegalStateException("documentFrontFile is null") _uiState.update { it.copy(currentStep = DocumentCaptureFlow.ProcessingScreen(ProcessingState.InProgress)) @@ -137,12 +160,14 @@ internal abstract class OrchestratedDocumentViewModel( jobId = jobId, ) val frontImageInfo = documentFrontFile.asDocumentFrontImage() - val backImageInfo = documentBackFile?.asDocumentBackImage() - val selfieImageInfo = selfieFile?.asSelfieImage() ?: throw IllegalStateException( - "Selfie file is null", - ) + val backImageInfo = uiState.value.documentBackFile?.asDocumentBackImage() + val selfieImageInfo = uiState.value.selfieToConfirm?.asSelfieImage() + ?: throw IllegalStateException( + "Selfie file is null", + ) // Liveness files will be null when the partner bypasses our Selfie capture with a file - val livenessImageInfo = livenessFiles.orEmpty().map { it.asLivenessImage() } + val livenessImageInfo = + uiState.value.livenessFiles.orEmpty().map { it.asLivenessImage() } val uploadRequest = UploadRequest( images = listOfNotNull( frontImageInfo, @@ -207,7 +232,11 @@ internal abstract class OrchestratedDocumentViewModel( SmileID.api.upload(prepUploadResponse.uploadUrl, uploadRequest) Timber.d("Upload finished") - sendResult(documentFrontFile, documentBackFile, livenessFiles) + sendResult( + documentFrontFile, + uiState.value.documentBackFile, + uiState.value.livenessFiles, + ) } } @@ -216,7 +245,7 @@ internal abstract class OrchestratedDocumentViewModel( documentBackFile: File? = null, livenessFiles: List? = null, ) { - var selfieFileResult: File = selfieFile ?: run { + var selfieFileResult: File = uiState.value.selfieToConfirm ?: run { Timber.w("Selfie file not found for job ID: $jobId") throw Exception("Selfie file not found for job ID: $jobId") } @@ -268,8 +297,12 @@ internal abstract class OrchestratedDocumentViewModel( fun onError(throwable: Throwable) { val didMoveToSubmitted = handleOfflineJobFailure(jobId, throwable) if (didMoveToSubmitted) { - this.selfieFile = getFileByType(jobId, FileType.SELFIE) - this.livenessFiles = getFilesByType(jobId, FileType.LIVENESS) + _uiState.update { + it.copy( + selfieToConfirm = getFileByType(jobId, FileType.SELFIE), + livenessFiles = getFilesByType(jobId, FileType.LIVENESS), + ) + } } stepToRetry = uiState.value.currentStep _uiState.update { @@ -286,12 +319,13 @@ internal abstract class OrchestratedDocumentViewModel( ) } saveResult( - selfieImage = selfieFile ?: throw IllegalStateException("Selfie file is null"), - documentFrontFile = documentFrontFile ?: throw IllegalStateException( + selfieImage = uiState.value.selfieToConfirm + ?: throw IllegalStateException("Selfie file is null"), + documentFrontFile = uiState.value.documentFrontFile ?: throw IllegalStateException( "Document front file is null", ), - documentBackFile = documentBackFile, - livenessFiles = livenessFiles, + documentBackFile = uiState.value.documentBackFile, + livenessFiles = uiState.value.livenessFiles, didSubmitJob = false, ) } else {