[#1284] Rework Scan screen

- Closes #1284
- Closes #423
- Closes #437
- Changelog update
This commit is contained in:
Honza Rychnovský 2024-04-23 10:05:15 +02:00 committed by GitHub
parent 992c1dd197
commit 1ffbaf986f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 633 additions and 291 deletions

View File

@ -9,6 +9,9 @@ directly impact users rather than highlighting other key architectural updates.*
## [Unreleased] ## [Unreleased]
### Changed
- The Scan QR code screen has been reworked to align with the rest of the screens
### Fixed ### Fixed
- Sending zero funds is allowed only for shielded recipient address type - Sending zero funds is allowed only for shielded recipient address type
- The Balances widget loader has been improved to better handle cases, like a wallet with only transparent funds - The Balances widget loader has been improved to better handle cases, like a wallet with only transparent funds

View File

@ -183,7 +183,7 @@ fun SecondaryButton(
horizontal = ZcashTheme.dimens.spacingNone, horizontal = ZcashTheme.dimens.spacingNone,
vertical = ZcashTheme.dimens.spacingSmall vertical = ZcashTheme.dimens.spacingSmall
), ),
contentPaddingValues: PaddingValues = PaddingValues(all = 16.dp) contentPaddingValues: PaddingValues = PaddingValues(all = 16.5.dp)
) { ) {
Button( Button(
shape = RectangleShape, shape = RectangleShape,
@ -195,7 +195,8 @@ fun SecondaryButton(
.padding(outerPaddingValues) .padding(outerPaddingValues)
.shadow( .shadow(
contentColor = textColor, contentColor = textColor,
strokeColor = textColor, strokeColor = buttonColor,
strokeWidth = 1.dp,
offsetX = ZcashTheme.dimens.buttonShadowOffsetX, offsetX = ZcashTheme.dimens.buttonShadowOffsetX,
offsetY = ZcashTheme.dimens.buttonShadowOffsetY, offsetY = ZcashTheme.dimens.buttonShadowOffsetY,
spread = ZcashTheme.dimens.buttonShadowSpread, spread = ZcashTheme.dimens.buttonShadowSpread,

View File

@ -2,7 +2,6 @@
package co.electriccoin.zcash.ui.design.component package co.electriccoin.zcash.ui.design.component
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@ -44,6 +43,7 @@ import androidx.compose.ui.unit.dp
import co.electriccoin.zcash.ui.design.R import co.electriccoin.zcash.ui.design.R
import co.electriccoin.zcash.ui.design.theme.ZcashTheme import co.electriccoin.zcash.ui.design.theme.ZcashTheme
import co.electriccoin.zcash.ui.design.theme.internal.SecondaryTypography import co.electriccoin.zcash.ui.design.theme.internal.SecondaryTypography
import co.electriccoin.zcash.ui.design.theme.internal.TopAppBarColors
@Preview @Preview
@Composable @Composable
@ -265,14 +265,15 @@ private fun TopBarOneVisibleActionMenuExample(
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
fun SmallTopAppBar( fun SmallTopAppBar(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
restoringLabel: String? = null,
titleText: String? = null,
showTitleLogo: Boolean = false,
backText: String? = null,
backContentDescriptionText: String? = null, backContentDescriptionText: String? = null,
onBack: (() -> Unit)? = null, backText: String? = null,
colors: TopAppBarColors = ZcashTheme.colors.topAppBarColors,
hamburgerMenuActions: (@Composable RowScope.() -> Unit)? = null, hamburgerMenuActions: (@Composable RowScope.() -> Unit)? = null,
onBack: (() -> Unit)? = null,
regularActions: (@Composable RowScope.() -> Unit)? = null, regularActions: (@Composable RowScope.() -> Unit)? = null,
restoringLabel: String? = null,
showTitleLogo: Boolean = false,
titleText: String? = null,
) { ) {
CenterAlignedTopAppBar( CenterAlignedTopAppBar(
title = { title = {
@ -284,13 +285,15 @@ fun SmallTopAppBar(
if (titleText != null) { if (titleText != null) {
Text( Text(
text = titleText.uppercase(), text = titleText.uppercase(),
style = SecondaryTypography.headlineSmall style = SecondaryTypography.headlineSmall,
color = colors.titleColor,
) )
restoringSpacerHeight = ZcashTheme.dimens.spacingTiny restoringSpacerHeight = ZcashTheme.dimens.spacingTiny
} else if (showTitleLogo) { } else if (showTitleLogo) {
Icon( Icon(
painter = painterResource(id = R.drawable.zashi_text_logo), painter = painterResource(id = R.drawable.zashi_text_logo),
contentDescription = null, contentDescription = null,
tint = colors.titleColor,
modifier = Modifier.height(ZcashTheme.dimens.topAppBarZcashLogoHeight) modifier = Modifier.height(ZcashTheme.dimens.topAppBarZcashLogoHeight)
) )
restoringSpacerHeight = ZcashTheme.dimens.spacingSmall restoringSpacerHeight = ZcashTheme.dimens.spacingSmall
@ -303,7 +306,7 @@ fun SmallTopAppBar(
Text( Text(
text = restoringLabel.uppercase(), text = restoringLabel.uppercase(),
style = ZcashTheme.extendedTypography.restoringTopAppBarStyle, style = ZcashTheme.extendedTypography.restoringTopAppBarStyle,
color = ZcashTheme.colors.restoringTopAppBarColor, color = colors.subTitleColor,
modifier = Modifier.fillMaxWidth(0.75f), modifier = Modifier.fillMaxWidth(0.75f),
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
maxLines = 1, maxLines = 1,
@ -325,9 +328,10 @@ fun SmallTopAppBar(
modifier = Modifier.padding(all = ZcashTheme.dimens.spacingDefault), modifier = Modifier.padding(all = ZcashTheme.dimens.spacingDefault),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Image( Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack, imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = backContentDescriptionText contentDescription = backContentDescriptionText,
tint = colors.navigationColor,
) )
Spacer(modifier = Modifier.size(size = ZcashTheme.dimens.spacingSmall)) Spacer(modifier = Modifier.size(size = ZcashTheme.dimens.spacingSmall))
Text(text = backText.uppercase()) Text(text = backText.uppercase())
@ -339,6 +343,7 @@ fun SmallTopAppBar(
regularActions?.invoke(this) regularActions?.invoke(this)
hamburgerMenuActions?.invoke(this) hamburgerMenuActions?.invoke(this)
}, },
colors = colors.toMaterialTopAppBarColors(),
modifier = modifier =
Modifier Modifier
.testTag(CommonTag.TOP_APP_BAR) .testTag(CommonTag.TOP_APP_BAR)

View File

@ -55,6 +55,7 @@ data class Dimens(
val inScreenZcashTextLogoHeight: Dp, val inScreenZcashTextLogoHeight: Dp,
val screenHorizontalSpacingBig: Dp, val screenHorizontalSpacingBig: Dp,
val screenHorizontalSpacingRegular: Dp, val screenHorizontalSpacingRegular: Dp,
val cameraTorchButton: Dp,
) )
private val defaultDimens = private val defaultDimens =
@ -96,6 +97,7 @@ private val defaultDimens =
inScreenZcashTextLogoHeight = 30.dp, inScreenZcashTextLogoHeight = 30.dp,
screenHorizontalSpacingBig = 64.dp, screenHorizontalSpacingBig = 64.dp,
screenHorizontalSpacingRegular = 32.dp, screenHorizontalSpacingRegular = 32.dp,
cameraTorchButton = 20.dp,
) )
private val normalDimens = defaultDimens private val normalDimens = defaultDimens

View File

@ -5,6 +5,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import co.electriccoin.zcash.ui.design.theme.internal.TopAppBarColors
@Immutable @Immutable
data class ExtendedColors( data class ExtendedColors(
@ -19,7 +20,6 @@ data class ExtendedColors(
val circularProgressBarScreen: Color, val circularProgressBarScreen: Color,
val linearProgressBarTrack: Color, val linearProgressBarTrack: Color,
val linearProgressBarBackground: Color, val linearProgressBarBackground: Color,
val restoringTopAppBarColor: Color,
val chipIndex: Color, val chipIndex: Color,
val textCommon: Color, val textCommon: Color,
val textMedium: Color, val textMedium: Color,
@ -45,11 +45,15 @@ data class ExtendedColors(
val darkDividerColor: Color, val darkDividerColor: Color,
val tabTextColor: Color, val tabTextColor: Color,
val panelBackgroundColor: Color, val panelBackgroundColor: Color,
val cameraDisabledBackgroundColor: Color,
val cameraDisabledFrameColor: Color,
val radioButtonColor: Color, val radioButtonColor: Color,
val radioButtonTextColor: Color, val radioButtonTextColor: Color,
val historyBackgroundColor: Color, val historyBackgroundColor: Color,
val historyRedColor: Color, val historyRedColor: Color,
val historySyncingColor: Color, val historySyncingColor: Color,
val topAppBarColors: TopAppBarColors,
val transparentTopAppBarColors: TopAppBarColors
) { ) {
@Composable @Composable
fun surfaceGradient() = fun surfaceGradient() =

View File

@ -43,6 +43,8 @@ internal object Dark {
val tabTextColor = Color(0xFF040404) val tabTextColor = Color(0xFF040404)
val layoutStroke = Color(0xFFFFFFFF) val layoutStroke = Color(0xFFFFFFFF)
val panelBackgroundColor = Color(0xFFEAEAEA) val panelBackgroundColor = Color(0xFFEAEAEA)
val cameraDisabledBackgroundColor = Color(0xFF5E5C5C)
val cameraDisabledFrameColor = Color(0xFFFFFFFF)
val primaryButton = Color(0xFFFFFFFF) val primaryButton = Color(0xFFFFFFFF)
val secondaryButton = Color(0xFFFFFFFF) val secondaryButton = Color(0xFFFFFFFF)
@ -56,7 +58,6 @@ internal object Dark {
val circularProgressBarScreen = Color(0xFFFFFFFF) val circularProgressBarScreen = Color(0xFFFFFFFF)
val linearProgressBarTrack = Color(0xFFD9D9D9) val linearProgressBarTrack = Color(0xFFD9D9D9)
val linearProgressBarBackground = complementaryColor val linearProgressBarBackground = complementaryColor
val restoringTopAppBarColor = Color(0xFF8A8888)
val callout = Color(0xFFFFFFFF) val callout = Color(0xFFFFFFFF)
val onCallout = Color(0xFFFFFFFF) val onCallout = Color(0xFFFFFFFF)
@ -74,6 +75,9 @@ internal object Dark {
val historyBackgroundColor = Color(0xFFF6F6F6) val historyBackgroundColor = Color(0xFFF6F6F6)
val historyRedColor = textFieldWarning val historyRedColor = textFieldWarning
val historySyncingColor = panelBackgroundColor val historySyncingColor = panelBackgroundColor
val topAppBarColors = DarkTopAppBarColors()
val transparentTopAppBarColors = TransparentTopAppBarColors()
} }
internal object Light { internal object Light {
@ -105,6 +109,8 @@ internal object Light {
val tabTextColor = Color(0xFF040404) val tabTextColor = Color(0xFF040404)
val layoutStroke = Color(0xFF000000) val layoutStroke = Color(0xFF000000)
val panelBackgroundColor = Color(0xFFEAEAEA) val panelBackgroundColor = Color(0xFFEAEAEA)
val cameraDisabledBackgroundColor = Color(0xFF5E5C5C)
val cameraDisabledFrameColor = Color(0xFFFFFFFF)
val primaryButton = Color(0xFF000000) val primaryButton = Color(0xFF000000)
val secondaryButton = Color(0xFFFFFFFF) val secondaryButton = Color(0xFFFFFFFF)
@ -118,7 +124,6 @@ internal object Light {
val circularProgressBarSmallDark = textBodyOnBackground val circularProgressBarSmallDark = textBodyOnBackground
val linearProgressBarTrack = Color(0xFFD9D9D9) val linearProgressBarTrack = Color(0xFFD9D9D9)
val linearProgressBarBackground = complementaryColor val linearProgressBarBackground = complementaryColor
val restoringTopAppBarColor = Color(0xFF8A8888)
val callout = Color(0xFFFFFFFF) val callout = Color(0xFFFFFFFF)
val onCallout = Color(0xFFFFFFFF) val onCallout = Color(0xFFFFFFFF)
@ -135,6 +140,9 @@ internal object Light {
val historyBackgroundColor = Color(0xFFF6F6F6) val historyBackgroundColor = Color(0xFFF6F6F6)
val historyRedColor = textFieldWarning val historyRedColor = textFieldWarning
val historySyncingColor = Dark.panelBackgroundColor val historySyncingColor = Dark.panelBackgroundColor
val topAppBarColors = LightTopAppBarColors()
val transparentTopAppBarColors = TransparentTopAppBarColors()
} }
internal val DarkColorPalette = internal val DarkColorPalette =
@ -174,7 +182,6 @@ internal val DarkExtendedColorPalette =
circularProgressBarScreen = Dark.circularProgressBarScreen, circularProgressBarScreen = Dark.circularProgressBarScreen,
linearProgressBarTrack = Dark.linearProgressBarTrack, linearProgressBarTrack = Dark.linearProgressBarTrack,
linearProgressBarBackground = Dark.linearProgressBarBackground, linearProgressBarBackground = Dark.linearProgressBarBackground,
restoringTopAppBarColor = Dark.restoringTopAppBarColor,
chipIndex = Dark.textChipIndex, chipIndex = Dark.textChipIndex,
textCommon = Dark.textCommon, textCommon = Dark.textCommon,
textMedium = Dark.textMedium, textMedium = Dark.textMedium,
@ -200,11 +207,15 @@ internal val DarkExtendedColorPalette =
darkDividerColor = Dark.darkDividerColor, darkDividerColor = Dark.darkDividerColor,
tabTextColor = Dark.tabTextColor, tabTextColor = Dark.tabTextColor,
panelBackgroundColor = Dark.panelBackgroundColor, panelBackgroundColor = Dark.panelBackgroundColor,
cameraDisabledBackgroundColor = Dark.cameraDisabledBackgroundColor,
cameraDisabledFrameColor = Dark.cameraDisabledFrameColor,
radioButtonColor = Dark.radioButtonColor, radioButtonColor = Dark.radioButtonColor,
radioButtonTextColor = Dark.radioButtonTextColor, radioButtonTextColor = Dark.radioButtonTextColor,
historyBackgroundColor = Dark.historyBackgroundColor, historyBackgroundColor = Dark.historyBackgroundColor,
historyRedColor = Dark.historyRedColor, historyRedColor = Dark.historyRedColor,
historySyncingColor = Dark.historySyncingColor, historySyncingColor = Dark.historySyncingColor,
topAppBarColors = Dark.topAppBarColors,
transparentTopAppBarColors = Dark.transparentTopAppBarColors
) )
internal val LightExtendedColorPalette = internal val LightExtendedColorPalette =
@ -220,7 +231,6 @@ internal val LightExtendedColorPalette =
circularProgressBarSmallDark = Light.circularProgressBarSmallDark, circularProgressBarSmallDark = Light.circularProgressBarSmallDark,
linearProgressBarTrack = Light.linearProgressBarTrack, linearProgressBarTrack = Light.linearProgressBarTrack,
linearProgressBarBackground = Light.linearProgressBarBackground, linearProgressBarBackground = Light.linearProgressBarBackground,
restoringTopAppBarColor = Light.restoringTopAppBarColor,
chipIndex = Light.textChipIndex, chipIndex = Light.textChipIndex,
textCommon = Light.textCommon, textCommon = Light.textCommon,
textMedium = Light.textMedium, textMedium = Light.textMedium,
@ -246,11 +256,15 @@ internal val LightExtendedColorPalette =
darkDividerColor = Light.darkDividerColor, darkDividerColor = Light.darkDividerColor,
tabTextColor = Light.tabTextColor, tabTextColor = Light.tabTextColor,
panelBackgroundColor = Light.panelBackgroundColor, panelBackgroundColor = Light.panelBackgroundColor,
cameraDisabledBackgroundColor = Light.cameraDisabledBackgroundColor,
cameraDisabledFrameColor = Light.cameraDisabledFrameColor,
radioButtonColor = Light.radioButtonColor, radioButtonColor = Light.radioButtonColor,
radioButtonTextColor = Light.radioButtonTextColor, radioButtonTextColor = Light.radioButtonTextColor,
historyBackgroundColor = Light.historyBackgroundColor, historyBackgroundColor = Light.historyBackgroundColor,
historyRedColor = Light.historyRedColor, historyRedColor = Light.historyRedColor,
historySyncingColor = Light.historySyncingColor, historySyncingColor = Light.historySyncingColor,
topAppBarColors = Light.topAppBarColors,
transparentTopAppBarColors = Light.transparentTopAppBarColors
) )
@Suppress("CompositionLocalAllowlist") @Suppress("CompositionLocalAllowlist")
@ -268,7 +282,6 @@ internal val LocalExtendedColors =
circularProgressBarSmallDark = Color.Unspecified, circularProgressBarSmallDark = Color.Unspecified,
linearProgressBarTrack = Color.Unspecified, linearProgressBarTrack = Color.Unspecified,
linearProgressBarBackground = Color.Unspecified, linearProgressBarBackground = Color.Unspecified,
restoringTopAppBarColor = Color.Unspecified,
chipIndex = Color.Unspecified, chipIndex = Color.Unspecified,
textCommon = Color.Unspecified, textCommon = Color.Unspecified,
textMedium = Color.Unspecified, textMedium = Color.Unspecified,
@ -294,10 +307,14 @@ internal val LocalExtendedColors =
darkDividerColor = Color.Unspecified, darkDividerColor = Color.Unspecified,
tabTextColor = Color.Unspecified, tabTextColor = Color.Unspecified,
panelBackgroundColor = Color.Unspecified, panelBackgroundColor = Color.Unspecified,
cameraDisabledBackgroundColor = Color.Unspecified,
cameraDisabledFrameColor = Color.Unspecified,
radioButtonColor = Color.Unspecified, radioButtonColor = Color.Unspecified,
radioButtonTextColor = Color.Unspecified, radioButtonTextColor = Color.Unspecified,
historyBackgroundColor = Color.Unspecified, historyBackgroundColor = Color.Unspecified,
historyRedColor = Color.Unspecified, historyRedColor = Color.Unspecified,
historySyncingColor = Color.Unspecified, historySyncingColor = Color.Unspecified,
topAppBarColors = DefaultTopAppBarColors(),
transparentTopAppBarColors = DefaultTopAppBarColors(),
) )
} }

View File

@ -0,0 +1,62 @@
@file:Suppress("MagicNumber")
package co.electriccoin.zcash.ui.design.theme.internal
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Immutable
import androidx.compose.ui.graphics.Color
@Immutable
interface TopAppBarColors {
val containerColor: Color
val navigationColor: Color
val titleColor: Color
val subTitleColor: Color
val actionColor: Color
@OptIn(ExperimentalMaterial3Api::class)
fun toMaterialTopAppBarColors() =
androidx.compose.material3.TopAppBarColors(
containerColor = containerColor,
scrolledContainerColor = containerColor,
navigationIconContentColor = navigationColor,
titleContentColor = titleColor,
actionIconContentColor = actionColor
)
}
@Immutable
internal data class DefaultTopAppBarColors(
override val containerColor: Color = Color.Unspecified,
override val navigationColor: Color = Color.Unspecified,
override val titleColor: Color = Color.Unspecified,
override val subTitleColor: Color = Color.Unspecified,
override val actionColor: Color = Color.Unspecified,
) : TopAppBarColors
@Immutable
internal data class LightTopAppBarColors(
override val containerColor: Color = Color(0xFFFFFFFF),
override val navigationColor: Color = Color(0xFF000000),
override val titleColor: Color = Color(0xFF000000),
override val subTitleColor: Color = Color(0xFF8A8888),
override val actionColor: Color = Color(0xFF000000),
) : TopAppBarColors
@Immutable
internal data class DarkTopAppBarColors(
override val containerColor: Color = Color(0xFF000000),
override val navigationColor: Color = Color(0xFFFFFFFF),
override val titleColor: Color = Color(0xFFFFFFFF),
override val subTitleColor: Color = Color(0xFF8A8888),
override val actionColor: Color = Color(0xFFFFFFFF),
) : TopAppBarColors
@Immutable
internal data class TransparentTopAppBarColors(
override val containerColor: Color = Color(0x00000000),
override val navigationColor: Color = Color(0xFFFFFFFF),
override val titleColor: Color = Color(0xFFFFFFFF),
override val subTitleColor: Color = Color(0xFFFFFFFF),
override val actionColor: Color = Color(0xFFFFFFFF),
) : TopAppBarColors

View File

@ -57,6 +57,8 @@ android {
} }
dependencies { dependencies {
implementation(libs.zcash.sdk)
implementation(projects.uiLib) implementation(projects.uiLib)
implementation(projects.uiDesignLib) implementation(projects.uiDesignLib)
implementation(projects.testLib) implementation(projects.testLib)

View File

@ -2,9 +2,7 @@ package co.electriccoin.zcash.ui.integration.test.screen.scan.view
import androidx.compose.ui.test.assertHasClickAction import androidx.compose.ui.test.assertHasClickAction
import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertTextEquals
import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performClick
@ -14,6 +12,7 @@ import co.electriccoin.zcash.ui.R
import co.electriccoin.zcash.ui.integration.test.common.IntegrationTestingActivity import co.electriccoin.zcash.ui.integration.test.common.IntegrationTestingActivity
import co.electriccoin.zcash.ui.integration.test.common.getPermissionPositiveButtonUiObject import co.electriccoin.zcash.ui.integration.test.common.getPermissionPositiveButtonUiObject
import co.electriccoin.zcash.ui.integration.test.common.getStringResource import co.electriccoin.zcash.ui.integration.test.common.getStringResource
import co.electriccoin.zcash.ui.integration.test.common.getStringResourceWithArgs
import co.electriccoin.zcash.ui.integration.test.common.waitForDeviceIdle import co.electriccoin.zcash.ui.integration.test.common.waitForDeviceIdle
import co.electriccoin.zcash.ui.screen.scan.ScanTag import co.electriccoin.zcash.ui.screen.scan.ScanTag
import co.electriccoin.zcash.ui.screen.scan.model.ScanState import co.electriccoin.zcash.ui.screen.scan.model.ScanState
@ -71,21 +70,10 @@ class ScanViewTest : UiTestPrerequisites() {
testSetup.grantPermission() testSetup.grantPermission()
composeTestRule.onNodeWithContentDescription( composeTestRule.onNodeWithText(getStringResource(R.string.scan_cancel_button).uppercase()).also {
getStringResource(R.string.scan_back_content_description)
).also {
it.assertIsDisplayed() it.assertIsDisplayed()
} }
composeTestRule.onNodeWithText(getStringResource(R.string.scan_hint)).also {
it.assertIsDisplayed()
}
composeTestRule.onNodeWithTag(ScanTag.TEXT_STATE).also {
it.assertIsDisplayed()
it.assertTextEquals(getStringResource(R.string.scan_state_scanning))
}
composeTestRule.onNodeWithTag(ScanTag.QR_FRAME).also { composeTestRule.onNodeWithTag(ScanTag.QR_FRAME).also {
it.assertIsDisplayed() it.assertIsDisplayed()
} }
@ -109,23 +97,19 @@ class ScanViewTest : UiTestPrerequisites() {
assertEquals(ScanState.Permission, testSetup.getScanState()) assertEquals(ScanState.Permission, testSetup.getScanState())
composeTestRule.onNodeWithTag(ScanTag.QR_FRAME).also {
it.assertDoesNotExist()
}
composeTestRule.onNodeWithTag(ScanTag.CAMERA_VIEW).also { composeTestRule.onNodeWithTag(ScanTag.CAMERA_VIEW).also {
it.assertDoesNotExist() it.assertDoesNotExist()
} }
composeTestRule.onNodeWithText(getStringResource(R.string.scan_hint)).also { composeTestRule.onNodeWithText(
getStringResourceWithArgs(
resId = R.string.scan_state_permission,
getStringResource(R.string.app_name)
)
).also {
it.assertIsDisplayed() it.assertIsDisplayed()
} }
composeTestRule.onNodeWithTag(ScanTag.TEXT_STATE).also {
it.assertIsDisplayed()
it.assertTextEquals(getStringResource(R.string.scan_state_permission))
}
composeTestRule.onNodeWithText(getStringResource(R.string.scan_settings_button), ignoreCase = true).also { composeTestRule.onNodeWithText(getStringResource(R.string.scan_settings_button), ignoreCase = true).also {
it.assertIsDisplayed() it.assertIsDisplayed()
it.assertHasClickAction() it.assertHasClickAction()

View File

@ -3,6 +3,8 @@ package co.electriccoin.zcash.ui.integration.test.screen.scan.view
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.test.junit4.ComposeContentTestRule import androidx.compose.ui.test.junit4.ComposeContentTestRule
import cash.z.ecc.android.sdk.type.AddressType
import co.electriccoin.zcash.ui.common.model.WalletRestoringState
import co.electriccoin.zcash.ui.design.theme.ZcashTheme import co.electriccoin.zcash.ui.design.theme.ZcashTheme
import co.electriccoin.zcash.ui.integration.test.common.getPermissionNegativeButtonUiObject import co.electriccoin.zcash.ui.integration.test.common.getPermissionNegativeButtonUiObject
import co.electriccoin.zcash.ui.integration.test.common.getPermissionPositiveButtonUiObject import co.electriccoin.zcash.ui.integration.test.common.getPermissionPositiveButtonUiObject
@ -56,7 +58,9 @@ class ScanViewTestSetup(
}, },
onScanStateChanged = { onScanStateChanged = {
scanState.set(it) scanState.set(it)
} },
walletRestoringState = WalletRestoringState.NONE,
addressValidationResult = AddressType.Unified
) )
} }

View File

@ -2,7 +2,6 @@ package co.electriccoin.zcash.ui.screen.scan.view
import android.Manifest import android.Manifest
import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertTextEquals
import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithTag
@ -33,12 +32,12 @@ class ScanViewBasicTest : UiTestPrerequisites() {
@Test @Test
@MediumTest @MediumTest
fun back() { fun cancel() {
val testSetup = newTestSetup() val testSetup = newTestSetup()
assertEquals(0, testSetup.getOnBackCount()) assertEquals(0, testSetup.getOnBackCount())
composeTestRule.onNodeWithContentDescription(getStringResource(R.string.scan_back_content_description)).also { composeTestRule.onNodeWithText(getStringResource(R.string.scan_cancel_button).uppercase()).also {
it.performClick() it.performClick()
} }
@ -52,7 +51,7 @@ class ScanViewBasicTest : UiTestPrerequisites() {
// Permission granted ui items (visible): // Permission granted ui items (visible):
composeTestRule.onNodeWithText(getStringResource(R.string.scan_header)).also { composeTestRule.onNodeWithText(getStringResource(R.string.scan_cancel_button).uppercase()).also {
it.assertIsDisplayed() it.assertIsDisplayed()
} }
@ -62,12 +61,7 @@ class ScanViewBasicTest : UiTestPrerequisites() {
it.assertIsDisplayed() it.assertIsDisplayed()
} }
composeTestRule.onNodeWithTag(ScanTag.TEXT_STATE).also { composeTestRule.onNodeWithContentDescription(getStringResource(R.string.scan_torch_content_description)).also {
it.assertIsDisplayed()
it.assertTextEquals(getStringResource(R.string.scan_state_scanning))
}
composeTestRule.onNodeWithText(getStringResource(R.string.scan_hint)).also {
it.assertIsDisplayed() it.assertIsDisplayed()
} }

View File

@ -3,6 +3,8 @@ package co.electriccoin.zcash.ui.screen.scan.view
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.test.junit4.ComposeContentTestRule import androidx.compose.ui.test.junit4.ComposeContentTestRule
import cash.z.ecc.android.sdk.type.AddressType
import co.electriccoin.zcash.ui.common.model.WalletRestoringState
import co.electriccoin.zcash.ui.design.theme.ZcashTheme import co.electriccoin.zcash.ui.design.theme.ZcashTheme
import co.electriccoin.zcash.ui.screen.scan.model.ScanState import co.electriccoin.zcash.ui.screen.scan.model.ScanState
import java.util.concurrent.atomic.AtomicInteger import java.util.concurrent.atomic.AtomicInteger
@ -36,7 +38,9 @@ class ScanViewBasicTestSetup(
onOpenSettings = {}, onOpenSettings = {},
onScanStateChanged = { onScanStateChanged = {
scanState.set(it) scanState.set(it)
} },
walletRestoringState = WalletRestoringState.NONE,
addressValidationResult = AddressType.Shielded
) )
} }

View File

@ -1,15 +1,21 @@
package co.electriccoin.zcash.ui.screen.scan package co.electriccoin.zcash.ui.screen.scan
import androidx.activity.ComponentActivity import android.content.Context
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import cash.z.ecc.android.sdk.Synchronizer
import cash.z.ecc.android.sdk.type.AddressType
import co.electriccoin.zcash.ui.MainActivity import co.electriccoin.zcash.ui.MainActivity
import co.electriccoin.zcash.ui.R import co.electriccoin.zcash.ui.R
import co.electriccoin.zcash.ui.common.model.SerializableAddress import co.electriccoin.zcash.ui.common.model.SerializableAddress
import co.electriccoin.zcash.ui.common.model.WalletRestoringState
import co.electriccoin.zcash.ui.common.viewmodel.WalletViewModel import co.electriccoin.zcash.ui.common.viewmodel.WalletViewModel
import co.electriccoin.zcash.ui.design.component.CircularScreenProgressIndicator import co.electriccoin.zcash.ui.design.component.CircularScreenProgressIndicator
import co.electriccoin.zcash.ui.screen.scan.util.SettingsUtil import co.electriccoin.zcash.ui.screen.scan.util.SettingsUtil
@ -21,25 +27,34 @@ internal fun MainActivity.WrapScanValidator(
onScanValid: (address: SerializableAddress) -> Unit, onScanValid: (address: SerializableAddress) -> Unit,
goBack: () -> Unit goBack: () -> Unit
) { ) {
val walletViewModel by viewModels<WalletViewModel>()
val synchronizer = walletViewModel.synchronizer.collectAsStateWithLifecycle().value
val walletRestoringState = walletViewModel.walletRestoringState.collectAsStateWithLifecycle().value
WrapScan( WrapScan(
this, context = this,
onScanValid = onScanValid, onScanValid = onScanValid,
goBack = goBack goBack = goBack,
synchronizer = synchronizer,
walletRestoringState = walletRestoringState
) )
} }
@Composable @Composable
fun WrapScan( fun WrapScan(
activity: ComponentActivity, context: Context,
goBack: () -> Unit,
onScanValid: (address: SerializableAddress) -> Unit, onScanValid: (address: SerializableAddress) -> Unit,
goBack: () -> Unit synchronizer: Synchronizer?,
walletRestoringState: WalletRestoringState,
) { ) {
val walletViewModel by activity.viewModels<WalletViewModel>() val scope = rememberCoroutineScope()
val synchronizer = walletViewModel.synchronizer.collectAsStateWithLifecycle().value
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope()
var addressValidationResult by remember { mutableStateOf<AddressType?>(null) }
if (synchronizer == null) { if (synchronizer == null) {
// TODO [#1146]: Consider moving CircularScreenProgressIndicator from Android layer to View layer // TODO [#1146]: Consider moving CircularScreenProgressIndicator from Android layer to View layer
@ -49,34 +64,32 @@ fun WrapScan(
} else { } else {
Scan( Scan(
snackbarHostState = snackbarHostState, snackbarHostState = snackbarHostState,
addressValidationResult = addressValidationResult,
onBack = goBack, onBack = goBack,
onScanned = { result -> onScanned = { result ->
scope.launch { scope.launch {
val addressType = synchronizer.validateAddress(result) addressValidationResult = synchronizer.validateAddress(result)
val isAddressValid = !addressType.isNotValid val isAddressValid = addressValidationResult?.let { !it.isNotValid } ?: false
if (isAddressValid) { if (isAddressValid) {
onScanValid(SerializableAddress(result, addressType)) onScanValid(SerializableAddress(result, addressValidationResult!!))
} else {
snackbarHostState.showSnackbar(
message = activity.getString(R.string.scan_validation_invalid_address)
)
} }
} }
}, },
onOpenSettings = { onOpenSettings = {
runCatching { runCatching {
activity.startActivity(SettingsUtil.newSettingsIntent(activity.packageName)) context.startActivity(SettingsUtil.newSettingsIntent(context.packageName))
}.onFailure { }.onFailure {
// This case should not really happen, as the Settings app should be available on every // This case should not really happen, as the Settings app should be available on every
// Android device, but we need to handle it somehow. // Android device, but we need to handle it somehow.
scope.launch { scope.launch {
snackbarHostState.showSnackbar( snackbarHostState.showSnackbar(
message = activity.getString(R.string.scan_settings_open_failed) message = context.getString(R.string.scan_settings_open_failed)
) )
} }
} }
}, },
onScanStateChanged = {} onScanStateChanged = {},
walletRestoringState = walletRestoringState,
) )
} }
} }

View File

@ -4,7 +4,7 @@ package co.electriccoin.zcash.ui.screen.scan
* These are only used for automated testing. * These are only used for automated testing.
*/ */
object ScanTag { object ScanTag {
const val TEXT_STATE = "text_state" const val FAILED_TEXT_STATE = "failed_text_state"
const val CAMERA_VIEW = "camera_view" const val CAMERA_VIEW = "camera_view"
const val QR_FRAME = "frame" const val QR_FRAME = "frame"
} }

View File

@ -3,6 +3,8 @@ package co.electriccoin.zcash.ui.screen.scan.util
import android.graphics.ImageFormat import android.graphics.ImageFormat
import androidx.camera.core.ImageAnalysis import androidx.camera.core.ImageAnalysis
import androidx.camera.core.ImageProxy import androidx.camera.core.ImageProxy
import co.electriccoin.zcash.spackle.Twig
import co.electriccoin.zcash.ui.screen.scan.view.FramePosition
import com.google.zxing.BarcodeFormat import com.google.zxing.BarcodeFormat
import com.google.zxing.BinaryBitmap import com.google.zxing.BinaryBitmap
import com.google.zxing.DecodeHintType import com.google.zxing.DecodeHintType
@ -11,9 +13,9 @@ import com.google.zxing.PlanarYUVLuminanceSource
import com.google.zxing.common.HybridBinarizer import com.google.zxing.common.HybridBinarizer
import java.nio.ByteBuffer import java.nio.ByteBuffer
// TODO [#437]: https://github.com/Electric-Coin-Company/zashi-android/issues/437
class QrCodeAnalyzer( class QrCodeAnalyzer(
private val onQrCodeScanned: (String) -> Unit private val framePosition: FramePosition,
private val onQrCodeScanned: (String) -> Unit,
) : ImageAnalysis.Analyzer { ) : ImageAnalysis.Analyzer {
private val supportedImageFormats = private val supportedImageFormats =
listOf( listOf(
@ -26,6 +28,15 @@ class QrCodeAnalyzer(
image.use { image.use {
if (image.format in supportedImageFormats) { if (image.format in supportedImageFormats) {
val bytes = image.planes.first().buffer.toByteArray() val bytes = image.planes.first().buffer.toByteArray()
Twig.debug {
"Scan result: " +
"Frame: $framePosition, " +
"Info: ${image.imageInfo}, " +
"Image width: ${image.width}, " +
"Image height: ${image.height}"
}
val source = val source =
PlanarYUVLuminanceSource( PlanarYUVLuminanceSource(
bytes, bytes,
@ -40,6 +51,23 @@ class QrCodeAnalyzer(
val binaryBmp = BinaryBitmap(HybridBinarizer(source)) val binaryBmp = BinaryBitmap(HybridBinarizer(source))
// TODO [#1380]: Leverage FramePosition in QrCodeAnalyzer
// TODO [#1380]: https://github.com/Electric-Coin-Company/zashi-android/issues/1380
@Suppress("MagicNumber")
val binaryBitmapCropped =
binaryBmp.crop(
(binaryBmp.width * 0.25).toInt(),
(binaryBmp.height * 0.30).toInt(),
(binaryBmp.width * 0.30).toInt(),
(binaryBmp.height * 0.4).toInt()
)
Twig.debug {
"Scan result cropped: " +
"Image width: ${binaryBitmapCropped.width}, " +
"Image height: ${binaryBitmapCropped.height}"
}
runCatching { runCatching {
val result = val result =
MultiFormatReader().apply { MultiFormatReader().apply {
@ -51,7 +79,7 @@ class QrCodeAnalyzer(
) )
) )
) )
}.decode(binaryBmp) }.decode(binaryBitmapCropped)
onQrCodeScanned(result.text) onQrCodeScanned(result.text)
}.onFailure { }.onFailure {

View File

@ -2,45 +2,44 @@ package co.electriccoin.zcash.ui.screen.scan.view
import android.Manifest import android.Manifest
import android.content.Context import android.content.Context
import android.content.res.Configuration
import android.view.ViewGroup import android.view.ViewGroup
import androidx.camera.core.CameraControl
import androidx.camera.core.CameraSelector import androidx.camera.core.CameraSelector
import androidx.camera.core.ImageAnalysis import androidx.camera.core.ImageAnalysis
import androidx.camera.lifecycle.ProcessCameraProvider import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.camera.view.PreviewView import androidx.camera.view.PreviewView
import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.Canvas
import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.ClipOp
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.clipRect
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
@ -48,6 +47,8 @@ import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@ -55,12 +56,14 @@ import androidx.compose.ui.viewinterop.AndroidView
import androidx.constraintlayout.compose.ConstraintLayout import androidx.constraintlayout.compose.ConstraintLayout
import androidx.constraintlayout.compose.Dimension import androidx.constraintlayout.compose.Dimension
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import cash.z.ecc.android.sdk.type.AddressType
import co.electriccoin.zcash.spackle.Twig import co.electriccoin.zcash.spackle.Twig
import co.electriccoin.zcash.ui.R import co.electriccoin.zcash.ui.R
import co.electriccoin.zcash.ui.design.component.Body import co.electriccoin.zcash.ui.common.model.WalletRestoringState
import co.electriccoin.zcash.ui.design.component.GradientSurface import co.electriccoin.zcash.ui.design.component.GradientSurface
import co.electriccoin.zcash.ui.design.component.SecondaryButton import co.electriccoin.zcash.ui.design.component.SecondaryButton
import co.electriccoin.zcash.ui.design.component.Small import co.electriccoin.zcash.ui.design.component.Small
import co.electriccoin.zcash.ui.design.component.SmallTopAppBar
import co.electriccoin.zcash.ui.design.theme.ZcashTheme import co.electriccoin.zcash.ui.design.theme.ZcashTheme
import co.electriccoin.zcash.ui.screen.scan.ScanTag import co.electriccoin.zcash.ui.screen.scan.ScanTag
import co.electriccoin.zcash.ui.screen.scan.model.ScanState import co.electriccoin.zcash.ui.screen.scan.model.ScanState
@ -77,8 +80,6 @@ import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.guava.await import kotlinx.coroutines.guava.await
import kotlin.math.roundToInt import kotlin.math.roundToInt
// TODO [#423]: QR scan screen elements transparency
// TODO [#423]: https://github.com/Electric-Coin-Company/zashi-android/issues/423
@Preview("Scan") @Preview("Scan")
@Composable @Composable
private fun PreviewScan() { private fun PreviewScan() {
@ -89,111 +90,26 @@ private fun PreviewScan() {
onBack = {}, onBack = {},
onScanned = {}, onScanned = {},
onOpenSettings = {}, onOpenSettings = {},
onScanStateChanged = {} onScanStateChanged = {},
walletRestoringState = WalletRestoringState.NONE,
addressValidationResult = AddressType.Transparent,
) )
} }
} }
} }
@OptIn(ExperimentalPermissionsApi::class)
@Composable @Composable
@Suppress("LongParameterList", "UnusedMaterial3ScaffoldPaddingParameter")
fun Scan( fun Scan(
snackbarHostState: SnackbarHostState, snackbarHostState: SnackbarHostState,
onBack: () -> Unit, onBack: () -> Unit,
onScanned: (String) -> Unit, onScanned: (String) -> Unit,
onOpenSettings: () -> Unit, onOpenSettings: () -> Unit,
onScanStateChanged: (ScanState) -> Unit
) {
Scaffold(
topBar = { ScanTopAppBar(onBack = onBack) },
snackbarHost = { SnackbarHost(snackbarHostState) }
) { paddingValues ->
ScanMainContent(
onScanned,
onOpenSettings,
onBack,
onScanStateChanged,
snackbarHostState,
modifier =
Modifier
.fillMaxSize()
.background(Color.Black)
.padding(
top = paddingValues.calculateTopPadding(),
bottom = paddingValues.calculateBottomPadding(),
start = ZcashTheme.dimens.spacingNone,
end = ZcashTheme.dimens.spacingNone
)
)
}
}
@Composable
fun ScanBottomItems(
scanState: ScanState,
onOpenSettings: () -> Unit,
modifier: Modifier = Modifier
) {
Column(modifier) {
Body(
text = stringResource(id = R.string.scan_hint),
color = Color.White
)
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingSmall))
Small(
text =
when (scanState) {
ScanState.Permission -> stringResource(id = R.string.scan_state_permission)
ScanState.Scanning -> stringResource(id = R.string.scan_state_scanning)
ScanState.Failed -> stringResource(id = R.string.scan_state_failed)
},
color = Color.White,
modifier = Modifier.testTag(ScanTag.TEXT_STATE)
)
if (scanState == ScanState.Permission) {
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingDefault))
SecondaryButton(
onClick = onOpenSettings,
text = stringResource(id = R.string.scan_settings_button)
)
}
}
}
@Composable
@OptIn(ExperimentalMaterial3Api::class)
private fun ScanTopAppBar(onBack: () -> Unit) {
TopAppBar(
title = { Text(text = stringResource(id = R.string.scan_header)) },
navigationIcon = {
IconButton(
onClick = onBack
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.scan_back_content_description)
)
}
}
)
}
@OptIn(ExperimentalPermissionsApi::class)
@Suppress("LongMethod", "LongParameterList")
@Composable
private fun ScanMainContent(
onScanned: (String) -> Unit,
onOpenSettings: () -> Unit,
onBack: () -> Unit,
onScanStateChanged: (ScanState) -> Unit, onScanStateChanged: (ScanState) -> Unit,
snackbarHostState: SnackbarHostState, walletRestoringState: WalletRestoringState,
modifier: Modifier = Modifier addressValidationResult: AddressType?
) { ) {
val context = LocalContext.current
val permissionState = val permissionState =
rememberPermissionState( rememberPermissionState(
Manifest.permission.CAMERA Manifest.permission.CAMERA
@ -210,102 +126,297 @@ private fun ScanMainContent(
) )
} }
if (!permissionState.status.isGranted) { Scaffold(
setScanState(ScanState.Permission) snackbarHost = { SnackbarHost(snackbarHostState) }
if (permissionState.status.shouldShowRationale) { ) { _ ->
// keep blank screen with a link to the app settings Box {
// user denied the permission previously ScanMainContent(
} else { addressValidationResult = addressValidationResult,
LaunchedEffect(key1 = true) { onScanned = onScanned,
permissionState.launchPermissionRequest() onOpenSettings = onOpenSettings,
onBack = onBack,
onScanStateChanged = onScanStateChanged,
permissionState = permissionState,
scanState = scanState,
setScanState = setScanState,
modifier =
Modifier
.fillMaxSize()
.background(
if (scanState != ScanState.Scanning) {
ZcashTheme.colors.cameraDisabledBackgroundColor
} else {
Color.Black
}
)
// Intentionally omitting paddingValues to have edge to edge design
)
ScanTopAppBar(
onBack = onBack,
showBack = scanState != ScanState.Scanning,
showRestoring = walletRestoringState == WalletRestoringState.RESTORING,
)
}
}
}
@Composable
fun ScanBottomItems(
addressValidationResult: AddressType?,
scanState: ScanState,
onOpenSettings: () -> Unit,
onBack: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(modifier) {
var failureText: String? = null
// Check validation result, if any
if (addressValidationResult is AddressType.Invalid) {
failureText = stringResource(id = R.string.scan_address_validation_failed)
}
// Check permission request result, if any
failureText =
when (scanState) {
ScanState.Permission ->
stringResource(
id = R.string.scan_state_permission,
stringResource(id = R.string.app_name)
)
ScanState.Failed -> stringResource(id = R.string.scan_state_failed)
ScanState.Scanning -> failureText
}
if (failureText != null) {
Small(
text = failureText,
color = Color.White,
textAlign = TextAlign.Center,
modifier =
Modifier
.fillMaxWidth()
.testTag(ScanTag.FAILED_TEXT_STATE)
)
}
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingLarge))
when (scanState) {
ScanState.Scanning, ScanState.Failed -> {
SecondaryButton(
onClick = onBack,
text = stringResource(id = R.string.scan_cancel_button)
)
}
ScanState.Permission -> {
SecondaryButton(
onClick = onOpenSettings,
text = stringResource(id = R.string.scan_settings_button)
)
} }
} }
} else if (scanState == ScanState.Failed) {
// keep current state Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingDefault))
} else if (permissionState.status.isGranted) { }
if (scanState != ScanState.Scanning) { }
setScanState(ScanState.Scanning)
@Composable
private fun ScanTopAppBar(
onBack: () -> Unit,
showBack: Boolean,
showRestoring: Boolean,
) {
SmallTopAppBar(
restoringLabel =
if (showRestoring) {
stringResource(id = R.string.restoring_wallet_label)
} else {
null
},
backText =
if (showBack) {
stringResource(id = R.string.scan_back)
} else {
null
},
backContentDescriptionText = stringResource(id = R.string.scan_back_content_description),
colors = ZcashTheme.colors.transparentTopAppBarColors,
onBack = onBack,
)
}
const val CAMERA_TRANSLUCENT_BORDER = 0.5f
const val FRAME_SIZE_RATIO = 0.6f
data class FramePosition(
val left: Float,
val top: Float,
val right: Float,
val bottom: Float,
val screenHeight: Int,
val screenWidth: Int
) {
val width: Float = right - left
val height: Float = bottom - top
}
@OptIn(ExperimentalPermissionsApi::class)
@Suppress("LongMethod", "LongParameterList")
@Composable
private fun ScanMainContent(
addressValidationResult: AddressType?,
onScanned: (String) -> Unit,
onOpenSettings: () -> Unit,
onBack: () -> Unit,
onScanStateChanged: (ScanState) -> Unit,
permissionState: PermissionState,
scanState: ScanState,
setScanState: (ScanState) -> Unit,
modifier: Modifier = Modifier,
) {
when {
(!permissionState.status.isGranted) -> {
setScanState(ScanState.Permission)
if (permissionState.status.shouldShowRationale) {
// Keep dark screen with a link to the app settings - user denied the permission previously
} else {
LaunchedEffect(key1 = true) {
permissionState.launchPermissionRequest()
}
}
}
(scanState == ScanState.Failed) -> {
// Keep current state
}
(permissionState.status.isGranted) -> {
if (scanState != ScanState.Scanning) {
setScanState(ScanState.Scanning)
}
} }
} }
// we calculate the best frame size for the current device screen // Calculate the best frame size for the current device screen
val framePossibleSize = remember { mutableStateOf(IntSize.Zero) } val framePossibleSize = remember { mutableStateOf(IntSize.Zero) }
val frameActualSize = (framePossibleSize.value.width * FRAME_SIZE_RATIO).roundToInt()
val density = LocalDensity.current
val configuration = LocalConfiguration.current val configuration = LocalConfiguration.current
@Suppress("MagicNumber") val framePosition =
val frameActualSize = FramePosition(
if (configuration.orientation == Configuration.ORIENTATION_LANDSCAPE) { left = (framePossibleSize.value.width - frameActualSize) / 2f,
(framePossibleSize.value.height * 0.85).roundToInt() top = (framePossibleSize.value.height - frameActualSize) / 2f,
} else { right = (framePossibleSize.value.width - frameActualSize) / 2f + frameActualSize,
(framePossibleSize.value.width * 0.7).roundToInt() bottom = (framePossibleSize.value.height - frameActualSize) / 2f + frameActualSize,
} screenHeight = with(density) { configuration.screenHeightDp.dp.roundToPx() },
screenWidth = with(density) { configuration.screenWidthDp.dp.roundToPx() }
)
ConstraintLayout(modifier) { val (isTorchOn, setIsTorchOn) = rememberSaveable { mutableStateOf(false) }
ConstraintLayout(modifier = modifier) {
val (frame, bottomItems) = createRefs() val (frame, bottomItems) = createRefs()
when (scanState) { when (scanState) {
ScanState.Permission -> { ScanState.Permission -> {
// keep initial ui state // Keep initial ui state
onScanStateChanged(ScanState.Permission) onScanStateChanged(ScanState.Permission)
} }
ScanState.Scanning -> { ScanState.Scanning -> {
// TODO [#437]: Scan QR Screen Frame Analysing
// TODO [#437]: https://github.com/Electric-Coin-Company/zashi-android/issues/437
onScanStateChanged(ScanState.Scanning) onScanStateChanged(ScanState.Scanning)
ScanCameraView( ScanCameraView(
framePosition = framePosition,
isTorchOn = isTorchOn,
onScanned = onScanned, onScanned = onScanned,
permissionState = permissionState,
setScanState = setScanState, setScanState = setScanState,
permissionState = permissionState
) )
Box( Canvas(modifier = Modifier.fillMaxSize()) {
clipRect(
clipOp = ClipOp.Difference,
left = framePosition.left,
top = framePosition.top,
right = framePosition.right,
bottom = framePosition.bottom,
) {
drawRect(Color.Black.copy(alpha = CAMERA_TRANSLUCENT_BORDER))
}
}
Image(
imageVector =
if (isTorchOn) {
ImageVector.vectorResource(R.drawable.ic_torch_off)
} else {
ImageVector.vectorResource(R.drawable.ic_torch_on)
},
contentDescription = stringResource(id = R.string.scan_torch_content_description),
modifier = modifier =
Modifier Modifier
.constrainAs(frame) { .offset(
top.linkTo(parent.top) x =
bottom.linkTo(bottomItems.top) with(density) {
start.linkTo(parent.start) (
end.linkTo(parent.end) framePosition.right.toDp() -
width = Dimension.fillToConstraints ZcashTheme.dimens.cameraTorchButton -
height = Dimension.fillToConstraints ZcashTheme.dimens.spacingDefault
} )
.onSizeChanged { coordinates -> },
framePossibleSize.value = coordinates y = with(density) { framePosition.bottom.toDp() }
}, )
contentAlignment = Alignment.Center .clip(RoundedCornerShape(ZcashTheme.dimens.regularRippleEffectCorner))
) { .clickable { setIsTorchOn(!isTorchOn) }
ScanFrame(frameActualSize) .padding(ZcashTheme.dimens.spacingDefault)
} .size(
width = ZcashTheme.dimens.cameraTorchButton,
height = ZcashTheme.dimens.cameraTorchButton
)
)
} }
ScanState.Failed -> { ScanState.Failed -> {
onScanStateChanged(ScanState.Failed) onScanStateChanged(ScanState.Failed)
// Using [rememberUpdatedState] to ensure that always the latest lambda is captured
// And to avoid Detekt warning: Lambda parameters in a @Composable that are referenced directly inside
// of restarting effects can cause issues or unpredictable behavior.
val currentOnScanStateChanged = rememberUpdatedState(newValue = onScanStateChanged)
val currentOnBack = rememberUpdatedState(newValue = onBack)
LaunchedEffect(currentOnScanStateChanged, currentOnBack) {
setScanState(ScanState.Failed)
currentOnScanStateChanged.value(ScanState.Failed)
val snackbarResult =
snackbarHostState.showSnackbar(
message = context.getString(R.string.scan_setup_failed),
actionLabel = context.getString(R.string.scan_setup_back)
)
if (snackbarResult == SnackbarResult.ActionPerformed) {
currentOnBack.value()
}
}
} }
} }
Box(modifier = Modifier.constrainAs(bottomItems) { bottom.linkTo(parent.bottom) }) { Box(
modifier =
Modifier
.constrainAs(frame) {
top.linkTo(parent.top)
bottom.linkTo(bottomItems.top)
start.linkTo(parent.start)
end.linkTo(parent.end)
width = Dimension.fillToConstraints
height = Dimension.fillToConstraints
}
.onSizeChanged { coordinates ->
framePossibleSize.value = coordinates
},
contentAlignment = Alignment.Center
) {
ScanFrame(
frameSize = frameActualSize,
isScanning = scanState == ScanState.Scanning
)
}
Box(
modifier =
Modifier
.constrainAs(bottomItems) { bottom.linkTo(parent.bottom) }
) {
ScanBottomItems( ScanBottomItems(
scanState = scanState, addressValidationResult = addressValidationResult,
onBack = onBack,
onOpenSettings = onOpenSettings, onOpenSettings = onOpenSettings,
scanState = scanState,
modifier = modifier =
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
@ -318,31 +429,82 @@ private fun ScanMainContent(
} }
} }
@Suppress("MagicNumber")
@Composable @Composable
fun ScanFrame(frameSize: Int) { fun ScanFrame(
frameSize: Int,
isScanning: Boolean,
modifier: Modifier = Modifier,
) {
@Suppress("MagicNumber")
Box( Box(
modifier = modifier =
Modifier modifier
.size(with(LocalDensity.current) { frameSize.toDp() }) .then(
.background(Color.Transparent) Modifier
.border(BorderStroke(10.dp, Color.White), RoundedCornerShape(10)) .size(with(LocalDensity.current) { frameSize.toDp() })
.testTag(ScanTag.QR_FRAME) .background(
) if (isScanning) {
Color.Transparent
} else {
ZcashTheme.colors.cameraDisabledFrameColor
}
)
.testTag(ScanTag.QR_FRAME)
)
) {
Icon(
imageVector = ImageVector.vectorResource(R.drawable.ic_scan_corner),
tint = Color.White,
contentDescription = null,
modifier =
Modifier
.rotate(0f)
.align(Alignment.TopStart),
)
Icon(
imageVector = ImageVector.vectorResource(R.drawable.ic_scan_corner),
tint = Color.White,
contentDescription = null,
modifier =
Modifier
.rotate(90f)
.align(Alignment.TopEnd),
)
Icon(
imageVector = ImageVector.vectorResource(R.drawable.ic_scan_corner),
tint = Color.White,
contentDescription = null,
modifier =
Modifier
.rotate(-90f)
.align(Alignment.BottomStart),
)
Icon(
imageVector = ImageVector.vectorResource(R.drawable.ic_scan_corner),
tint = Color.White,
contentDescription = null,
modifier =
Modifier
.rotate(180f)
.align(Alignment.BottomEnd),
)
}
} }
@OptIn(ExperimentalPermissionsApi::class) @OptIn(ExperimentalPermissionsApi::class)
@SuppressWarnings("LongMethod") @Suppress("LongMethod")
@Composable @Composable
fun ScanCameraView( fun ScanCameraView(
framePosition: FramePosition,
isTorchOn: Boolean,
onScanned: (result: String) -> Unit, onScanned: (result: String) -> Unit,
permissionState: PermissionState,
setScanState: (ScanState) -> Unit, setScanState: (ScanState) -> Unit,
permissionState: PermissionState
) { ) {
val context = LocalContext.current val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current val lifecycleOwner = LocalLifecycleOwner.current
// we check the permission first, as the ProcessCameraProvider's emit won't be called again after // We check the permission first, as the ProcessCameraProvider's emit won't be called again after
// recomposition with the permission granted // recomposition with the permission granted
val cameraProviderFlow = val cameraProviderFlow =
if (permissionState.status.isGranted) { if (permissionState.status.isGranted) {
@ -355,6 +517,9 @@ fun ScanCameraView(
val collectedCameraProvider = cameraProviderFlow?.collectAsState(initial = null)?.value val collectedCameraProvider = cameraProviderFlow?.collectAsState(initial = null)?.value
val cameraController = remember { mutableStateOf<CameraControl?>(null) }
cameraController.value?.enableTorch(isTorchOn)
if (null == collectedCameraProvider) { if (null == collectedCameraProvider) {
// Show loading indicator // Show loading indicator
} else { } else {
@ -387,14 +552,15 @@ fun ScanCameraView(
} }
runCatching { runCatching {
// we must unbind the use-cases before rebinding them // We must unbind the use-cases before rebinding them
collectedCameraProvider.unbindAll() collectedCameraProvider.unbindAll()
collectedCameraProvider.bindToLifecycle( cameraController.value =
lifecycleOwner, collectedCameraProvider.bindToLifecycle(
selector, lifecycleOwner,
preview, selector,
imageAnalysis preview,
) imageAnalysis
).cameraControl
}.onFailure { }.onFailure {
Twig.error { "Scan QR failed in bind phase with: ${it.message}" } Twig.error { "Scan QR failed in bind phase with: ${it.message}" }
setScanState(ScanState.Failed) setScanState(ScanState.Failed)
@ -402,12 +568,16 @@ fun ScanCameraView(
previewView previewView
}, },
Modifier modifier =
.fillMaxSize() Modifier
.testTag(ScanTag.CAMERA_VIEW) .fillMaxSize()
.testTag(ScanTag.CAMERA_VIEW)
) )
imageAnalysis.qrCodeFlow(context).collectAsState(initial = null).value?.let { imageAnalysis.qrCodeFlow(
context = context,
framePosition = framePosition,
).collectAsState(initial = null).value?.let {
onScanned(it) onScanned(it)
} }
} }
@ -416,17 +586,23 @@ fun ScanCameraView(
// Using callbackFlow because QrCodeAnalyzer has a non-suspending callback which makes // Using callbackFlow because QrCodeAnalyzer has a non-suspending callback which makes
// a basic flow builder not work here. // a basic flow builder not work here.
@Composable @Composable
fun ImageAnalysis.qrCodeFlow(context: Context): Flow<String> = fun ImageAnalysis.qrCodeFlow(
context: Context,
framePosition: FramePosition,
): Flow<String> =
remember { remember {
callbackFlow { callbackFlow {
setAnalyzer( setAnalyzer(
ContextCompat.getMainExecutor(context), ContextCompat.getMainExecutor(context),
QrCodeAnalyzer { result -> QrCodeAnalyzer(
// Note that these callbacks aren't tied to the Compose lifecycle, so they could occur framePosition = framePosition,
// after the view goes away. Collection needs to occur within the Compose lifecycle onQrCodeScanned = { result ->
// to make this not be a problem. // Note that these callbacks aren't tied to the Compose lifecycle, so they could occur
trySend(result) // after the view goes away. Collection needs to occur within the Compose lifecycle
} // to make this not be a problem.
trySend(result)
}
)
) )
awaitClose { awaitClose {

View File

@ -1,5 +1,3 @@
@file:Suppress("ktlint:standard:filename")
package co.electriccoin.zcash.ui.screen.settings package co.electriccoin.zcash.ui.screen.settings
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity

View File

@ -5,7 +5,7 @@
<string name="about_version_format" formatted="true">Version <xliff:g example="1.0" id="version_name">%1$s <string name="about_version_format" formatted="true">Version <xliff:g example="1.0" id="version_name">%1$s
</xliff:g></string> </xliff:g></string>
<string name="about_debug_menu_app_name">App name: <string name="about_debug_menu_app_name">App name:
<xliff:g example="Zashi (D)" id="app_name">%1$s</xliff:g></string> <xliff:g example="Zashi" id="app_name">%1$s</xliff:g></string>
<string name="about_debug_menu_build">Build: <xliff:g example="635dac0eb9ddc2bc6da5177f0dd495d8b76af4dc" <string name="about_debug_menu_build">Build: <xliff:g example="635dac0eb9ddc2bc6da5177f0dd495d8b76af4dc"
id="git_commit_hash">%1$s</xliff:g></string> id="git_commit_hash">%1$s</xliff:g></string>
<string name="about_description">Send and receive ZEC on Zashi! Zashi is a minimal-design, self-custody, ZEC-only <string name="about_description">Send and receive ZEC on Zashi! Zashi is a minimal-design, self-custody, ZEC-only

View File

@ -0,0 +1,16 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="36dp"
android:height="37dp"
android:viewportWidth="36"
android:viewportHeight="37">
<path
android:pathData="M0,1.75L36,1.75"
android:strokeWidth="2"
android:fillColor="#00000000"
android:strokeColor="#ffffff"/>
<path
android:pathData="M1,37L1,1"
android:strokeWidth="2"
android:fillColor="#00000000"
android:strokeColor="#ffffff"/>
</vector>

View File

@ -0,0 +1,18 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="18dp"
android:height="20dp"
android:viewportWidth="18"
android:viewportHeight="20">
<group>
<clip-path
android:pathData="M3.167,0h11v20h-11z"/>
<path
android:pathData="M11.323,0C11.49,0.1 11.537,0.249 11.509,0.448C11.334,1.777 11.164,3.108 10.993,4.439C10.832,5.682 10.672,6.924 10.513,8.167C10.506,8.22 10.506,8.275 10.502,8.354C10.574,8.354 10.634,8.354 10.695,8.354C11.727,8.354 12.759,8.354 13.789,8.354C13.913,8.354 14.03,8.362 14.11,8.481C14.198,8.612 14.185,8.747 14.06,8.921C13.406,9.821 12.752,10.721 12.098,11.62C10.136,14.32 8.174,17.019 6.212,19.719C6.174,19.771 6.137,19.826 6.096,19.877C5.994,20.001 5.857,20.033 5.732,19.965C5.6,19.894 5.54,19.749 5.585,19.584C5.709,19.137 5.838,18.692 5.965,18.246C6.54,16.219 7.116,14.191 7.691,12.164C7.706,12.112 7.715,12.059 7.732,11.984C7.656,11.984 7.596,11.984 7.535,11.984C6.232,11.984 4.93,11.984 3.628,11.984C3.582,11.984 3.534,11.984 3.488,11.984C3.357,11.984 3.254,11.933 3.195,11.803C3.14,11.677 3.174,11.566 3.246,11.461C3.605,10.936 3.964,10.411 4.322,9.886C6.523,6.663 8.725,3.442 10.929,0.22C10.986,0.138 11.066,0.072 11.136,0C11.198,0 11.261,0 11.323,0Z"
android:fillColor="#ffffff"/>
</group>
<path
android:strokeWidth="1"
android:pathData="M0.396,2.397L17.646,19.647"
android:fillColor="#00000000"
android:strokeColor="#ffffff"/>
</vector>

View File

@ -0,0 +1,13 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="12dp"
android:height="20dp"
android:viewportWidth="12"
android:viewportHeight="20">
<group>
<clip-path
android:pathData="M0.667,0h11v20h-11z"/>
<path
android:pathData="M8.823,0C8.99,0.1 9.037,0.249 9.009,0.448C8.834,1.777 8.664,3.108 8.493,4.439C8.332,5.682 8.172,6.924 8.013,8.167C8.006,8.22 8.006,8.275 8.002,8.354C8.074,8.354 8.134,8.354 8.195,8.354C9.227,8.354 10.259,8.354 11.289,8.354C11.413,8.354 11.53,8.362 11.61,8.481C11.698,8.612 11.685,8.747 11.56,8.921C10.906,9.821 10.252,10.721 9.598,11.62C7.636,14.32 5.674,17.019 3.712,19.719C3.674,19.771 3.637,19.826 3.596,19.877C3.494,20.001 3.357,20.033 3.232,19.965C3.1,19.894 3.04,19.749 3.085,19.584C3.209,19.137 3.338,18.692 3.465,18.246C4.04,16.219 4.616,14.191 5.191,12.164C5.206,12.112 5.215,12.059 5.232,11.984C5.156,11.984 5.096,11.984 5.035,11.984C3.732,11.984 2.43,11.984 1.128,11.984C1.082,11.984 1.034,11.984 0.988,11.984C0.857,11.984 0.754,11.933 0.695,11.803C0.64,11.677 0.674,11.566 0.746,11.461C1.105,10.936 1.464,10.411 1.822,9.886C4.023,6.663 6.225,3.442 8.429,0.22C8.486,0.138 8.566,0.072 8.636,0C8.698,0 8.761,0 8.823,0Z"
android:fillColor="#ffffff"/>
</group>
</vector>

View File

@ -1,20 +1,18 @@
<?xml version="1.0" encoding="utf-8"?> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<resources> <string name="scan_back">Back</string>
<string name="scan_header">Scan a Zcash QR</string>
<string name="scan_back_content_description">Back</string> <string name="scan_back_content_description">Back</string>
<string name="scan_preview_content_description">Camera</string> <string name="scan_preview_content_description">Camera</string>
<string name="scan_hint">You can scan any Zcash QR code.</string> <string name="scan_cancel_button">Cancel</string>
<string name="scan_settings_button">Open Settings</string>
<string name="scan_settings_button">Enable camera permission</string>
<string name="scan_settings_open_failed">Unable to launch Settings app.</string> <string name="scan_settings_open_failed">Unable to launch Settings app.</string>
<string name="scan_setup_failed">Unable to run QR scanner.</string> <string name="scan_state_permission">
<string name="scan_setup_back">Back</string> <xliff:g id="app_name" example="Zashi">%1$s</xliff:g> does not have access to your camera. Please go to your
system settings and authorize <xliff:g id="app_name" example="Zashi">%1$s</xliff:g>.
</string>
<string name="scan_state_failed">Camera initialization failed. Try it again, please.</string>
<string name="scan_address_validation_failed">This QR code is not a valid Zcash Address.</string>
<string name="scan_state_permission">Permission for camera is necessary.</string> <string name="scan_torch_content_description">Camera torch toggle</string>
<string name="scan_state_scanning">Scanning…</string>
<string name="scan_state_failed">Scanning failed.</string>
<string name="scan_validation_invalid_address">Invalid Zcash address scanned.</string>
</resources> </resources>