-
Notifications
You must be signed in to change notification settings - Fork 950
[PM-34126] feat: Add card scan screen #6721
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
dca5681
a58b58c
b9bece6
00d6666
1d7dde7
167de2c
d076c37
82b689a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,39 @@ | ||
| @file:OmitFromCoverage | ||
|
|
||
| package com.x8bit.bitwarden.ui.vault.feature.cardscanner | ||
|
|
||
| import androidx.navigation.NavController | ||
| import androidx.navigation.NavGraphBuilder | ||
| import androidx.navigation.NavOptions | ||
| import com.bitwarden.annotation.OmitFromCoverage | ||
| import com.bitwarden.ui.platform.base.util.composableWithSlideTransitions | ||
| import kotlinx.serialization.Serializable | ||
|
|
||
| /** | ||
| * The type-safe route for the card scan screen. | ||
| */ | ||
| @OmitFromCoverage | ||
| @Serializable | ||
| data object CardScanRoute | ||
|
|
||
| /** | ||
| * Add the card scan screen to the nav graph. | ||
| */ | ||
| fun NavGraphBuilder.cardScanDestination( | ||
| onNavigateBack: () -> Unit, | ||
| ) { | ||
| composableWithSlideTransitions<CardScanRoute> { | ||
| CardScanScreen( | ||
| onNavigateBack = onNavigateBack, | ||
| ) | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Navigate to the card scan screen. | ||
| */ | ||
| fun NavController.navigateToCardScanScreen( | ||
| navOptions: NavOptions? = null, | ||
| ) { | ||
| this.navigate(route = CardScanRoute, navOptions = navOptions) | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,183 @@ | ||
| package com.x8bit.bitwarden.ui.vault.feature.cardscanner | ||
|
|
||
| import androidx.compose.foundation.background | ||
| import androidx.compose.foundation.layout.Arrangement | ||
| import androidx.compose.foundation.layout.Column | ||
| import androidx.compose.foundation.layout.Row | ||
| import androidx.compose.foundation.layout.Spacer | ||
| import androidx.compose.foundation.layout.fillMaxSize | ||
| import androidx.compose.foundation.layout.navigationBarsPadding | ||
| import androidx.compose.foundation.layout.padding | ||
| import androidx.compose.foundation.rememberScrollState | ||
| import androidx.compose.foundation.verticalScroll | ||
| import androidx.compose.material3.ExperimentalMaterial3Api | ||
| import androidx.compose.material3.Text | ||
| import androidx.compose.material3.TopAppBarDefaults | ||
| import androidx.compose.material3.rememberTopAppBarState | ||
| import androidx.compose.runtime.Composable | ||
| import androidx.compose.runtime.CompositionLocalProvider | ||
| import androidx.compose.ui.Alignment | ||
| import androidx.compose.ui.Modifier | ||
| import androidx.compose.ui.res.stringResource | ||
| import androidx.compose.ui.text.style.TextAlign | ||
| import androidx.compose.ui.unit.dp | ||
| import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel | ||
| import com.bitwarden.ui.platform.base.util.EventsEffect | ||
| import com.bitwarden.ui.platform.base.util.StatusBarsAppearanceAffect | ||
| import com.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar | ||
| import com.bitwarden.ui.platform.components.camera.CameraPreview | ||
| import com.bitwarden.ui.platform.components.camera.CardScanOverlay | ||
| import com.bitwarden.ui.platform.components.scaffold.BitwardenScaffold | ||
| import com.bitwarden.ui.platform.components.util.rememberVectorPainter | ||
| import com.bitwarden.ui.platform.composition.LocalCardTextAnalyzer | ||
| import com.bitwarden.ui.platform.feature.cardscanner.util.CardTextAnalyzer | ||
| import com.bitwarden.ui.platform.model.WindowSize | ||
| import com.bitwarden.ui.platform.resource.BitwardenDrawable | ||
| import com.bitwarden.ui.platform.resource.BitwardenString | ||
| import com.bitwarden.ui.platform.theme.BitwardenTheme | ||
| import com.bitwarden.ui.platform.theme.LocalBitwardenColorScheme | ||
| import com.bitwarden.ui.platform.theme.color.darkBitwardenColorScheme | ||
| import com.bitwarden.ui.platform.util.rememberWindowSize | ||
|
|
||
| /** | ||
| * The screen to scan credit cards for the application. | ||
| */ | ||
| @Suppress("LongMethod") | ||
| @OptIn(ExperimentalMaterial3Api::class) | ||
| @Composable | ||
| fun CardScanScreen( | ||
| onNavigateBack: () -> Unit, | ||
| viewModel: CardScanViewModel = hiltViewModel(), | ||
| cardTextAnalyzer: CardTextAnalyzer = LocalCardTextAnalyzer.current, | ||
| ) { | ||
| cardTextAnalyzer.onCardScanned = { cardScanData -> | ||
| viewModel.trySendAction( | ||
| CardScanAction.CardScanReceive(cardScanData = cardScanData), | ||
| ) | ||
| } | ||
|
|
||
| EventsEffect(viewModel = viewModel) { event -> | ||
| when (event) { | ||
| is CardScanEvent.NavigateBack -> onNavigateBack() | ||
| } | ||
| } | ||
|
|
||
| // This screen should always look like it's in dark mode | ||
| CompositionLocalProvider( | ||
| LocalBitwardenColorScheme provides darkBitwardenColorScheme, | ||
| ) { | ||
| StatusBarsAppearanceAffect() | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. π |
||
| BitwardenScaffold( | ||
| modifier = Modifier.fillMaxSize(), | ||
| topBar = { | ||
| BitwardenTopAppBar( | ||
| title = stringResource(id = BitwardenString.scan_card), | ||
| navigationIcon = rememberVectorPainter( | ||
| id = BitwardenDrawable.ic_close, | ||
| ), | ||
| navigationIconContentDescription = stringResource( | ||
| id = BitwardenString.close, | ||
| ), | ||
| onNavigationIconClick = { | ||
| viewModel.trySendAction(CardScanAction.CloseClick) | ||
| }, | ||
| scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior( | ||
| state = rememberTopAppBarState(), | ||
| ), | ||
| ) | ||
| }, | ||
| ) { | ||
| CameraPreview( | ||
| cameraErrorReceive = { | ||
| viewModel.trySendAction( | ||
| CardScanAction.CameraSetupErrorReceive, | ||
| ) | ||
| }, | ||
| analyzer = cardTextAnalyzer, | ||
| modifier = Modifier.fillMaxSize(), | ||
| ) | ||
| when (rememberWindowSize()) { | ||
| WindowSize.Compact -> { | ||
| CardScanContentCompact() | ||
| } | ||
|
|
||
| WindowSize.Medium -> { | ||
| CardScanContentMedium() | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| @Composable | ||
| private fun CardScanContentCompact( | ||
| modifier: Modifier = Modifier, | ||
| ) { | ||
| Column( | ||
| horizontalAlignment = Alignment.CenterHorizontally, | ||
| modifier = modifier, | ||
| ) { | ||
| CardScanOverlay( | ||
| overlayWidth = 300.dp, | ||
| modifier = Modifier.weight(2f), | ||
| ) | ||
|
|
||
| Column( | ||
| horizontalAlignment = Alignment.CenterHorizontally, | ||
| verticalArrangement = Arrangement.SpaceAround, | ||
| modifier = Modifier | ||
| .weight(1f) | ||
| .fillMaxSize() | ||
| .background(color = BitwardenTheme.colorScheme.background.scrim) | ||
| .padding(horizontal = 16.dp) | ||
| .verticalScroll(rememberScrollState()), | ||
| ) { | ||
| Text( | ||
| text = stringResource( | ||
| id = BitwardenString.scan_card_instruction, | ||
| ), | ||
| textAlign = TextAlign.Center, | ||
| color = BitwardenTheme.colorScheme.text.primary, | ||
| style = BitwardenTheme.typography.bodyMedium, | ||
| modifier = Modifier.padding(horizontal = 16.dp), | ||
| ) | ||
| Spacer(modifier = Modifier.navigationBarsPadding()) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| @Composable | ||
| private fun CardScanContentMedium( | ||
| modifier: Modifier = Modifier, | ||
| ) { | ||
| Row( | ||
| verticalAlignment = Alignment.CenterVertically, | ||
| modifier = modifier, | ||
| ) { | ||
| CardScanOverlay( | ||
| overlayWidth = 250.dp, | ||
| modifier = Modifier.weight(2f), | ||
| ) | ||
|
|
||
| Column( | ||
| horizontalAlignment = Alignment.CenterHorizontally, | ||
| verticalArrangement = Arrangement.SpaceAround, | ||
| modifier = Modifier | ||
| .weight(1f) | ||
| .fillMaxSize() | ||
| .background(color = BitwardenTheme.colorScheme.background.scrim) | ||
| .padding(horizontal = 16.dp) | ||
| .navigationBarsPadding() | ||
| .verticalScroll(rememberScrollState()), | ||
| ) { | ||
| Text( | ||
| text = stringResource( | ||
| id = BitwardenString.scan_card_instruction, | ||
| ), | ||
| textAlign = TextAlign.Center, | ||
| color = BitwardenTheme.colorScheme.text.primary, | ||
| style = BitwardenTheme.typography.bodySmall, | ||
| ) | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,97 @@ | ||
| package com.x8bit.bitwarden.ui.vault.feature.cardscanner | ||
|
|
||
| import android.os.Parcelable | ||
| import androidx.lifecycle.SavedStateHandle | ||
| import com.bitwarden.ui.platform.base.BaseViewModel | ||
| import com.bitwarden.ui.platform.base.DeferredBackgroundEvent | ||
| import com.bitwarden.ui.platform.feature.cardscanner.manager.CardScanManager | ||
| import com.bitwarden.ui.platform.feature.cardscanner.util.CardScanData | ||
| import com.bitwarden.ui.platform.feature.cardscanner.util.CardScanResult | ||
| import dagger.hilt.android.lifecycle.HiltViewModel | ||
| import kotlinx.coroutines.flow.update | ||
| import kotlinx.parcelize.Parcelize | ||
| import javax.inject.Inject | ||
|
|
||
| private const val KEY_STATE = "state" | ||
|
|
||
| /** | ||
| * Handles [CardScanAction] and launches [CardScanEvent] for the [CardScanScreen]. | ||
| */ | ||
| @HiltViewModel | ||
| class CardScanViewModel @Inject constructor( | ||
| savedStateHandle: SavedStateHandle, | ||
| private val cardScanManager: CardScanManager, | ||
| ) : BaseViewModel<CardScanState, CardScanEvent, CardScanAction>( | ||
| initialState = savedStateHandle[KEY_STATE] | ||
| ?: CardScanState(hasHandledScan = false), | ||
| ) { | ||
|
|
||
| override fun handleAction(action: CardScanAction) { | ||
| when (action) { | ||
| is CardScanAction.CloseClick -> handleCloseClick() | ||
| is CardScanAction.CameraSetupErrorReceive -> handleCameraErrorReceive() | ||
| is CardScanAction.CardScanReceive -> handleCardScanReceive(action) | ||
| } | ||
| } | ||
|
|
||
| private fun handleCloseClick() { | ||
| sendEvent(CardScanEvent.NavigateBack) | ||
| } | ||
|
|
||
| private fun handleCameraErrorReceive() { | ||
| cardScanManager.emitCardScanResult(CardScanResult.ScanError()) | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Do we need this too? mutableStateFlow.update { it.copy(hasHandledScan = true) }
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. No. This is only fired on camera setup failure, so it will only be called once, unlike |
||
| sendEvent(CardScanEvent.NavigateBack) | ||
| } | ||
|
|
||
| private fun handleCardScanReceive(action: CardScanAction.CardScanReceive) { | ||
| if (state.hasHandledScan) return | ||
| mutableStateFlow.update { it.copy(hasHandledScan = true) } | ||
| cardScanManager.emitCardScanResult( | ||
| CardScanResult.Success(cardScanData = action.cardScanData), | ||
| ) | ||
| sendEvent(CardScanEvent.NavigateBack) | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Models events for the [CardScanScreen]. | ||
| */ | ||
| sealed class CardScanEvent { | ||
|
|
||
| /** | ||
| * Navigate back. Added [DeferredBackgroundEvent] as scan might fire before | ||
| * events are consumed. | ||
| */ | ||
| data object NavigateBack : CardScanEvent(), DeferredBackgroundEvent | ||
| } | ||
|
|
||
| /** | ||
| * Models actions for the [CardScanScreen]. | ||
| */ | ||
| sealed class CardScanAction { | ||
|
|
||
| /** | ||
| * User clicked close. | ||
| */ | ||
| data object CloseClick : CardScanAction() | ||
|
|
||
| /** | ||
| * A card has been scanned with the detected fields. | ||
| */ | ||
| data class CardScanReceive( | ||
| val cardScanData: CardScanData, | ||
| ) : CardScanAction() | ||
|
|
||
| /** | ||
| * The camera is unable to be set up. | ||
| */ | ||
| data object CameraSetupErrorReceive : CardScanAction() | ||
| } | ||
|
|
||
| /** | ||
| * Represents the state of the card scan screen. | ||
| */ | ||
| @Parcelize | ||
| data class CardScanState( | ||
| val hasHandledScan: Boolean, | ||
| ) : Parcelable | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
π