[#1284] Rework Scan screen
- Closes #1284 - Closes #423 - Closes #437 - Changelog update
This commit is contained in:
parent
992c1dd197
commit
1ffbaf986f
|
@ -9,6 +9,9 @@ directly impact users rather than highlighting other key architectural updates.*
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
### Changed
|
||||
- The Scan QR code screen has been reworked to align with the rest of the screens
|
||||
|
||||
### Fixed
|
||||
- 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
|
||||
|
|
|
@ -183,7 +183,7 @@ fun SecondaryButton(
|
|||
horizontal = ZcashTheme.dimens.spacingNone,
|
||||
vertical = ZcashTheme.dimens.spacingSmall
|
||||
),
|
||||
contentPaddingValues: PaddingValues = PaddingValues(all = 16.dp)
|
||||
contentPaddingValues: PaddingValues = PaddingValues(all = 16.5.dp)
|
||||
) {
|
||||
Button(
|
||||
shape = RectangleShape,
|
||||
|
@ -195,7 +195,8 @@ fun SecondaryButton(
|
|||
.padding(outerPaddingValues)
|
||||
.shadow(
|
||||
contentColor = textColor,
|
||||
strokeColor = textColor,
|
||||
strokeColor = buttonColor,
|
||||
strokeWidth = 1.dp,
|
||||
offsetX = ZcashTheme.dimens.buttonShadowOffsetX,
|
||||
offsetY = ZcashTheme.dimens.buttonShadowOffsetY,
|
||||
spread = ZcashTheme.dimens.buttonShadowSpread,
|
||||
|
|
|
@ -2,7 +2,6 @@
|
|||
|
||||
package co.electriccoin.zcash.ui.design.component
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
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.theme.ZcashTheme
|
||||
import co.electriccoin.zcash.ui.design.theme.internal.SecondaryTypography
|
||||
import co.electriccoin.zcash.ui.design.theme.internal.TopAppBarColors
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
|
@ -265,14 +265,15 @@ private fun TopBarOneVisibleActionMenuExample(
|
|||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
fun SmallTopAppBar(
|
||||
modifier: Modifier = Modifier,
|
||||
restoringLabel: String? = null,
|
||||
titleText: String? = null,
|
||||
showTitleLogo: Boolean = false,
|
||||
backText: String? = null,
|
||||
backContentDescriptionText: String? = null,
|
||||
onBack: (() -> Unit)? = null,
|
||||
backText: String? = null,
|
||||
colors: TopAppBarColors = ZcashTheme.colors.topAppBarColors,
|
||||
hamburgerMenuActions: (@Composable RowScope.() -> Unit)? = null,
|
||||
onBack: (() -> Unit)? = null,
|
||||
regularActions: (@Composable RowScope.() -> Unit)? = null,
|
||||
restoringLabel: String? = null,
|
||||
showTitleLogo: Boolean = false,
|
||||
titleText: String? = null,
|
||||
) {
|
||||
CenterAlignedTopAppBar(
|
||||
title = {
|
||||
|
@ -284,13 +285,15 @@ fun SmallTopAppBar(
|
|||
if (titleText != null) {
|
||||
Text(
|
||||
text = titleText.uppercase(),
|
||||
style = SecondaryTypography.headlineSmall
|
||||
style = SecondaryTypography.headlineSmall,
|
||||
color = colors.titleColor,
|
||||
)
|
||||
restoringSpacerHeight = ZcashTheme.dimens.spacingTiny
|
||||
} else if (showTitleLogo) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.zashi_text_logo),
|
||||
contentDescription = null,
|
||||
tint = colors.titleColor,
|
||||
modifier = Modifier.height(ZcashTheme.dimens.topAppBarZcashLogoHeight)
|
||||
)
|
||||
restoringSpacerHeight = ZcashTheme.dimens.spacingSmall
|
||||
|
@ -303,7 +306,7 @@ fun SmallTopAppBar(
|
|||
Text(
|
||||
text = restoringLabel.uppercase(),
|
||||
style = ZcashTheme.extendedTypography.restoringTopAppBarStyle,
|
||||
color = ZcashTheme.colors.restoringTopAppBarColor,
|
||||
color = colors.subTitleColor,
|
||||
modifier = Modifier.fillMaxWidth(0.75f),
|
||||
textAlign = TextAlign.Center,
|
||||
maxLines = 1,
|
||||
|
@ -325,9 +328,10 @@ fun SmallTopAppBar(
|
|||
modifier = Modifier.padding(all = ZcashTheme.dimens.spacingDefault),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Image(
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = backContentDescriptionText
|
||||
contentDescription = backContentDescriptionText,
|
||||
tint = colors.navigationColor,
|
||||
)
|
||||
Spacer(modifier = Modifier.size(size = ZcashTheme.dimens.spacingSmall))
|
||||
Text(text = backText.uppercase())
|
||||
|
@ -339,6 +343,7 @@ fun SmallTopAppBar(
|
|||
regularActions?.invoke(this)
|
||||
hamburgerMenuActions?.invoke(this)
|
||||
},
|
||||
colors = colors.toMaterialTopAppBarColors(),
|
||||
modifier =
|
||||
Modifier
|
||||
.testTag(CommonTag.TOP_APP_BAR)
|
||||
|
|
|
@ -55,6 +55,7 @@ data class Dimens(
|
|||
val inScreenZcashTextLogoHeight: Dp,
|
||||
val screenHorizontalSpacingBig: Dp,
|
||||
val screenHorizontalSpacingRegular: Dp,
|
||||
val cameraTorchButton: Dp,
|
||||
)
|
||||
|
||||
private val defaultDimens =
|
||||
|
@ -96,6 +97,7 @@ private val defaultDimens =
|
|||
inScreenZcashTextLogoHeight = 30.dp,
|
||||
screenHorizontalSpacingBig = 64.dp,
|
||||
screenHorizontalSpacingRegular = 32.dp,
|
||||
cameraTorchButton = 20.dp,
|
||||
)
|
||||
|
||||
private val normalDimens = defaultDimens
|
||||
|
|
|
@ -5,6 +5,7 @@ import androidx.compose.runtime.Composable
|
|||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import co.electriccoin.zcash.ui.design.theme.internal.TopAppBarColors
|
||||
|
||||
@Immutable
|
||||
data class ExtendedColors(
|
||||
|
@ -19,7 +20,6 @@ data class ExtendedColors(
|
|||
val circularProgressBarScreen: Color,
|
||||
val linearProgressBarTrack: Color,
|
||||
val linearProgressBarBackground: Color,
|
||||
val restoringTopAppBarColor: Color,
|
||||
val chipIndex: Color,
|
||||
val textCommon: Color,
|
||||
val textMedium: Color,
|
||||
|
@ -45,11 +45,15 @@ data class ExtendedColors(
|
|||
val darkDividerColor: Color,
|
||||
val tabTextColor: Color,
|
||||
val panelBackgroundColor: Color,
|
||||
val cameraDisabledBackgroundColor: Color,
|
||||
val cameraDisabledFrameColor: Color,
|
||||
val radioButtonColor: Color,
|
||||
val radioButtonTextColor: Color,
|
||||
val historyBackgroundColor: Color,
|
||||
val historyRedColor: Color,
|
||||
val historySyncingColor: Color,
|
||||
val topAppBarColors: TopAppBarColors,
|
||||
val transparentTopAppBarColors: TopAppBarColors
|
||||
) {
|
||||
@Composable
|
||||
fun surfaceGradient() =
|
||||
|
|
|
@ -43,6 +43,8 @@ internal object Dark {
|
|||
val tabTextColor = Color(0xFF040404)
|
||||
val layoutStroke = Color(0xFFFFFFFF)
|
||||
val panelBackgroundColor = Color(0xFFEAEAEA)
|
||||
val cameraDisabledBackgroundColor = Color(0xFF5E5C5C)
|
||||
val cameraDisabledFrameColor = Color(0xFFFFFFFF)
|
||||
|
||||
val primaryButton = Color(0xFFFFFFFF)
|
||||
val secondaryButton = Color(0xFFFFFFFF)
|
||||
|
@ -56,7 +58,6 @@ internal object Dark {
|
|||
val circularProgressBarScreen = Color(0xFFFFFFFF)
|
||||
val linearProgressBarTrack = Color(0xFFD9D9D9)
|
||||
val linearProgressBarBackground = complementaryColor
|
||||
val restoringTopAppBarColor = Color(0xFF8A8888)
|
||||
|
||||
val callout = Color(0xFFFFFFFF)
|
||||
val onCallout = Color(0xFFFFFFFF)
|
||||
|
@ -74,6 +75,9 @@ internal object Dark {
|
|||
val historyBackgroundColor = Color(0xFFF6F6F6)
|
||||
val historyRedColor = textFieldWarning
|
||||
val historySyncingColor = panelBackgroundColor
|
||||
|
||||
val topAppBarColors = DarkTopAppBarColors()
|
||||
val transparentTopAppBarColors = TransparentTopAppBarColors()
|
||||
}
|
||||
|
||||
internal object Light {
|
||||
|
@ -105,6 +109,8 @@ internal object Light {
|
|||
val tabTextColor = Color(0xFF040404)
|
||||
val layoutStroke = Color(0xFF000000)
|
||||
val panelBackgroundColor = Color(0xFFEAEAEA)
|
||||
val cameraDisabledBackgroundColor = Color(0xFF5E5C5C)
|
||||
val cameraDisabledFrameColor = Color(0xFFFFFFFF)
|
||||
|
||||
val primaryButton = Color(0xFF000000)
|
||||
val secondaryButton = Color(0xFFFFFFFF)
|
||||
|
@ -118,7 +124,6 @@ internal object Light {
|
|||
val circularProgressBarSmallDark = textBodyOnBackground
|
||||
val linearProgressBarTrack = Color(0xFFD9D9D9)
|
||||
val linearProgressBarBackground = complementaryColor
|
||||
val restoringTopAppBarColor = Color(0xFF8A8888)
|
||||
|
||||
val callout = Color(0xFFFFFFFF)
|
||||
val onCallout = Color(0xFFFFFFFF)
|
||||
|
@ -135,6 +140,9 @@ internal object Light {
|
|||
val historyBackgroundColor = Color(0xFFF6F6F6)
|
||||
val historyRedColor = textFieldWarning
|
||||
val historySyncingColor = Dark.panelBackgroundColor
|
||||
|
||||
val topAppBarColors = LightTopAppBarColors()
|
||||
val transparentTopAppBarColors = TransparentTopAppBarColors()
|
||||
}
|
||||
|
||||
internal val DarkColorPalette =
|
||||
|
@ -174,7 +182,6 @@ internal val DarkExtendedColorPalette =
|
|||
circularProgressBarScreen = Dark.circularProgressBarScreen,
|
||||
linearProgressBarTrack = Dark.linearProgressBarTrack,
|
||||
linearProgressBarBackground = Dark.linearProgressBarBackground,
|
||||
restoringTopAppBarColor = Dark.restoringTopAppBarColor,
|
||||
chipIndex = Dark.textChipIndex,
|
||||
textCommon = Dark.textCommon,
|
||||
textMedium = Dark.textMedium,
|
||||
|
@ -200,11 +207,15 @@ internal val DarkExtendedColorPalette =
|
|||
darkDividerColor = Dark.darkDividerColor,
|
||||
tabTextColor = Dark.tabTextColor,
|
||||
panelBackgroundColor = Dark.panelBackgroundColor,
|
||||
cameraDisabledBackgroundColor = Dark.cameraDisabledBackgroundColor,
|
||||
cameraDisabledFrameColor = Dark.cameraDisabledFrameColor,
|
||||
radioButtonColor = Dark.radioButtonColor,
|
||||
radioButtonTextColor = Dark.radioButtonTextColor,
|
||||
historyBackgroundColor = Dark.historyBackgroundColor,
|
||||
historyRedColor = Dark.historyRedColor,
|
||||
historySyncingColor = Dark.historySyncingColor,
|
||||
topAppBarColors = Dark.topAppBarColors,
|
||||
transparentTopAppBarColors = Dark.transparentTopAppBarColors
|
||||
)
|
||||
|
||||
internal val LightExtendedColorPalette =
|
||||
|
@ -220,7 +231,6 @@ internal val LightExtendedColorPalette =
|
|||
circularProgressBarSmallDark = Light.circularProgressBarSmallDark,
|
||||
linearProgressBarTrack = Light.linearProgressBarTrack,
|
||||
linearProgressBarBackground = Light.linearProgressBarBackground,
|
||||
restoringTopAppBarColor = Light.restoringTopAppBarColor,
|
||||
chipIndex = Light.textChipIndex,
|
||||
textCommon = Light.textCommon,
|
||||
textMedium = Light.textMedium,
|
||||
|
@ -246,11 +256,15 @@ internal val LightExtendedColorPalette =
|
|||
darkDividerColor = Light.darkDividerColor,
|
||||
tabTextColor = Light.tabTextColor,
|
||||
panelBackgroundColor = Light.panelBackgroundColor,
|
||||
cameraDisabledBackgroundColor = Light.cameraDisabledBackgroundColor,
|
||||
cameraDisabledFrameColor = Light.cameraDisabledFrameColor,
|
||||
radioButtonColor = Light.radioButtonColor,
|
||||
radioButtonTextColor = Light.radioButtonTextColor,
|
||||
historyBackgroundColor = Light.historyBackgroundColor,
|
||||
historyRedColor = Light.historyRedColor,
|
||||
historySyncingColor = Light.historySyncingColor,
|
||||
topAppBarColors = Light.topAppBarColors,
|
||||
transparentTopAppBarColors = Light.transparentTopAppBarColors
|
||||
)
|
||||
|
||||
@Suppress("CompositionLocalAllowlist")
|
||||
|
@ -268,7 +282,6 @@ internal val LocalExtendedColors =
|
|||
circularProgressBarSmallDark = Color.Unspecified,
|
||||
linearProgressBarTrack = Color.Unspecified,
|
||||
linearProgressBarBackground = Color.Unspecified,
|
||||
restoringTopAppBarColor = Color.Unspecified,
|
||||
chipIndex = Color.Unspecified,
|
||||
textCommon = Color.Unspecified,
|
||||
textMedium = Color.Unspecified,
|
||||
|
@ -294,10 +307,14 @@ internal val LocalExtendedColors =
|
|||
darkDividerColor = Color.Unspecified,
|
||||
tabTextColor = Color.Unspecified,
|
||||
panelBackgroundColor = Color.Unspecified,
|
||||
cameraDisabledBackgroundColor = Color.Unspecified,
|
||||
cameraDisabledFrameColor = Color.Unspecified,
|
||||
radioButtonColor = Color.Unspecified,
|
||||
radioButtonTextColor = Color.Unspecified,
|
||||
historyBackgroundColor = Color.Unspecified,
|
||||
historyRedColor = Color.Unspecified,
|
||||
historySyncingColor = Color.Unspecified,
|
||||
topAppBarColors = DefaultTopAppBarColors(),
|
||||
transparentTopAppBarColors = DefaultTopAppBarColors(),
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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
|
|
@ -57,6 +57,8 @@ android {
|
|||
}
|
||||
|
||||
dependencies {
|
||||
implementation(libs.zcash.sdk)
|
||||
|
||||
implementation(projects.uiLib)
|
||||
implementation(projects.uiDesignLib)
|
||||
implementation(projects.testLib)
|
||||
|
|
|
@ -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.assertIsDisplayed
|
||||
import androidx.compose.ui.test.assertTextEquals
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
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.getPermissionPositiveButtonUiObject
|
||||
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.screen.scan.ScanTag
|
||||
import co.electriccoin.zcash.ui.screen.scan.model.ScanState
|
||||
|
@ -71,21 +70,10 @@ class ScanViewTest : UiTestPrerequisites() {
|
|||
|
||||
testSetup.grantPermission()
|
||||
|
||||
composeTestRule.onNodeWithContentDescription(
|
||||
getStringResource(R.string.scan_back_content_description)
|
||||
).also {
|
||||
composeTestRule.onNodeWithText(getStringResource(R.string.scan_cancel_button).uppercase()).also {
|
||||
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 {
|
||||
it.assertIsDisplayed()
|
||||
}
|
||||
|
@ -109,23 +97,19 @@ class ScanViewTest : UiTestPrerequisites() {
|
|||
|
||||
assertEquals(ScanState.Permission, testSetup.getScanState())
|
||||
|
||||
composeTestRule.onNodeWithTag(ScanTag.QR_FRAME).also {
|
||||
it.assertDoesNotExist()
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithTag(ScanTag.CAMERA_VIEW).also {
|
||||
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()
|
||||
}
|
||||
|
||||
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 {
|
||||
it.assertIsDisplayed()
|
||||
it.assertHasClickAction()
|
||||
|
|
|
@ -3,6 +3,8 @@ package co.electriccoin.zcash.ui.integration.test.screen.scan.view
|
|||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.runtime.Composable
|
||||
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.integration.test.common.getPermissionNegativeButtonUiObject
|
||||
import co.electriccoin.zcash.ui.integration.test.common.getPermissionPositiveButtonUiObject
|
||||
|
@ -56,7 +58,9 @@ class ScanViewTestSetup(
|
|||
},
|
||||
onScanStateChanged = {
|
||||
scanState.set(it)
|
||||
}
|
||||
},
|
||||
walletRestoringState = WalletRestoringState.NONE,
|
||||
addressValidationResult = AddressType.Unified
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -2,7 +2,6 @@ package co.electriccoin.zcash.ui.screen.scan.view
|
|||
|
||||
import android.Manifest
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.assertTextEquals
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
|
@ -33,12 +32,12 @@ class ScanViewBasicTest : UiTestPrerequisites() {
|
|||
|
||||
@Test
|
||||
@MediumTest
|
||||
fun back() {
|
||||
fun cancel() {
|
||||
val testSetup = newTestSetup()
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
|
@ -52,7 +51,7 @@ class ScanViewBasicTest : UiTestPrerequisites() {
|
|||
|
||||
// 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()
|
||||
}
|
||||
|
||||
|
@ -62,12 +61,7 @@ class ScanViewBasicTest : UiTestPrerequisites() {
|
|||
it.assertIsDisplayed()
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithTag(ScanTag.TEXT_STATE).also {
|
||||
it.assertIsDisplayed()
|
||||
it.assertTextEquals(getStringResource(R.string.scan_state_scanning))
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithText(getStringResource(R.string.scan_hint)).also {
|
||||
composeTestRule.onNodeWithContentDescription(getStringResource(R.string.scan_torch_content_description)).also {
|
||||
it.assertIsDisplayed()
|
||||
}
|
||||
|
||||
|
|
|
@ -3,6 +3,8 @@ package co.electriccoin.zcash.ui.screen.scan.view
|
|||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.runtime.Composable
|
||||
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.screen.scan.model.ScanState
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
@ -36,7 +38,9 @@ class ScanViewBasicTestSetup(
|
|||
onOpenSettings = {},
|
||||
onScanStateChanged = {
|
||||
scanState.set(it)
|
||||
}
|
||||
},
|
||||
walletRestoringState = WalletRestoringState.NONE,
|
||||
addressValidationResult = AddressType.Shielded
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -1,15 +1,21 @@
|
|||
package co.electriccoin.zcash.ui.screen.scan
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import android.content.Context
|
||||
import androidx.activity.viewModels
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
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.R
|
||||
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.design.component.CircularScreenProgressIndicator
|
||||
import co.electriccoin.zcash.ui.screen.scan.util.SettingsUtil
|
||||
|
@ -21,25 +27,34 @@ internal fun MainActivity.WrapScanValidator(
|
|||
onScanValid: (address: SerializableAddress) -> Unit,
|
||||
goBack: () -> Unit
|
||||
) {
|
||||
val walletViewModel by viewModels<WalletViewModel>()
|
||||
|
||||
val synchronizer = walletViewModel.synchronizer.collectAsStateWithLifecycle().value
|
||||
|
||||
val walletRestoringState = walletViewModel.walletRestoringState.collectAsStateWithLifecycle().value
|
||||
|
||||
WrapScan(
|
||||
this,
|
||||
context = this,
|
||||
onScanValid = onScanValid,
|
||||
goBack = goBack
|
||||
goBack = goBack,
|
||||
synchronizer = synchronizer,
|
||||
walletRestoringState = walletRestoringState
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun WrapScan(
|
||||
activity: ComponentActivity,
|
||||
context: Context,
|
||||
goBack: () -> Unit,
|
||||
onScanValid: (address: SerializableAddress) -> Unit,
|
||||
goBack: () -> Unit
|
||||
synchronizer: Synchronizer?,
|
||||
walletRestoringState: WalletRestoringState,
|
||||
) {
|
||||
val walletViewModel by activity.viewModels<WalletViewModel>()
|
||||
|
||||
val synchronizer = walletViewModel.synchronizer.collectAsStateWithLifecycle().value
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
var addressValidationResult by remember { mutableStateOf<AddressType?>(null) }
|
||||
|
||||
if (synchronizer == null) {
|
||||
// TODO [#1146]: Consider moving CircularScreenProgressIndicator from Android layer to View layer
|
||||
|
@ -49,34 +64,32 @@ fun WrapScan(
|
|||
} else {
|
||||
Scan(
|
||||
snackbarHostState = snackbarHostState,
|
||||
addressValidationResult = addressValidationResult,
|
||||
onBack = goBack,
|
||||
onScanned = { result ->
|
||||
scope.launch {
|
||||
val addressType = synchronizer.validateAddress(result)
|
||||
val isAddressValid = !addressType.isNotValid
|
||||
addressValidationResult = synchronizer.validateAddress(result)
|
||||
val isAddressValid = addressValidationResult?.let { !it.isNotValid } ?: false
|
||||
if (isAddressValid) {
|
||||
onScanValid(SerializableAddress(result, addressType))
|
||||
} else {
|
||||
snackbarHostState.showSnackbar(
|
||||
message = activity.getString(R.string.scan_validation_invalid_address)
|
||||
)
|
||||
onScanValid(SerializableAddress(result, addressValidationResult!!))
|
||||
}
|
||||
}
|
||||
},
|
||||
onOpenSettings = {
|
||||
runCatching {
|
||||
activity.startActivity(SettingsUtil.newSettingsIntent(activity.packageName))
|
||||
context.startActivity(SettingsUtil.newSettingsIntent(context.packageName))
|
||||
}.onFailure {
|
||||
// This case should not really happen, as the Settings app should be available on every
|
||||
// Android device, but we need to handle it somehow.
|
||||
scope.launch {
|
||||
snackbarHostState.showSnackbar(
|
||||
message = activity.getString(R.string.scan_settings_open_failed)
|
||||
message = context.getString(R.string.scan_settings_open_failed)
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
onScanStateChanged = {}
|
||||
onScanStateChanged = {},
|
||||
walletRestoringState = walletRestoringState,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,7 +4,7 @@ package co.electriccoin.zcash.ui.screen.scan
|
|||
* These are only used for automated testing.
|
||||
*/
|
||||
object ScanTag {
|
||||
const val TEXT_STATE = "text_state"
|
||||
const val FAILED_TEXT_STATE = "failed_text_state"
|
||||
const val CAMERA_VIEW = "camera_view"
|
||||
const val QR_FRAME = "frame"
|
||||
}
|
||||
|
|
|
@ -3,6 +3,8 @@ package co.electriccoin.zcash.ui.screen.scan.util
|
|||
import android.graphics.ImageFormat
|
||||
import androidx.camera.core.ImageAnalysis
|
||||
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.BinaryBitmap
|
||||
import com.google.zxing.DecodeHintType
|
||||
|
@ -11,9 +13,9 @@ import com.google.zxing.PlanarYUVLuminanceSource
|
|||
import com.google.zxing.common.HybridBinarizer
|
||||
import java.nio.ByteBuffer
|
||||
|
||||
// TODO [#437]: https://github.com/Electric-Coin-Company/zashi-android/issues/437
|
||||
class QrCodeAnalyzer(
|
||||
private val onQrCodeScanned: (String) -> Unit
|
||||
private val framePosition: FramePosition,
|
||||
private val onQrCodeScanned: (String) -> Unit,
|
||||
) : ImageAnalysis.Analyzer {
|
||||
private val supportedImageFormats =
|
||||
listOf(
|
||||
|
@ -26,6 +28,15 @@ class QrCodeAnalyzer(
|
|||
image.use {
|
||||
if (image.format in supportedImageFormats) {
|
||||
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 =
|
||||
PlanarYUVLuminanceSource(
|
||||
bytes,
|
||||
|
@ -40,6 +51,23 @@ class QrCodeAnalyzer(
|
|||
|
||||
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 {
|
||||
val result =
|
||||
MultiFormatReader().apply {
|
||||
|
@ -51,7 +79,7 @@ class QrCodeAnalyzer(
|
|||
)
|
||||
)
|
||||
)
|
||||
}.decode(binaryBmp)
|
||||
}.decode(binaryBitmapCropped)
|
||||
|
||||
onQrCodeScanned(result.text)
|
||||
}.onFailure {
|
||||
|
|
|
@ -2,45 +2,44 @@ package co.electriccoin.zcash.ui.screen.scan.view
|
|||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.res.Configuration
|
||||
import android.view.ViewGroup
|
||||
import androidx.camera.core.CameraControl
|
||||
import androidx.camera.core.CameraSelector
|
||||
import androidx.camera.core.ImageAnalysis
|
||||
import androidx.camera.lifecycle.ProcessCameraProvider
|
||||
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.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
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.IconButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
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.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Alignment
|
||||
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.drawscope.clipRect
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
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.testTag
|
||||
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.unit.IntSize
|
||||
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.Dimension
|
||||
import androidx.core.content.ContextCompat
|
||||
import cash.z.ecc.android.sdk.type.AddressType
|
||||
import co.electriccoin.zcash.spackle.Twig
|
||||
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.SecondaryButton
|
||||
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.screen.scan.ScanTag
|
||||
import co.electriccoin.zcash.ui.screen.scan.model.ScanState
|
||||
|
@ -77,8 +80,6 @@ import kotlinx.coroutines.flow.flow
|
|||
import kotlinx.coroutines.guava.await
|
||||
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")
|
||||
@Composable
|
||||
private fun PreviewScan() {
|
||||
|
@ -89,111 +90,26 @@ private fun PreviewScan() {
|
|||
onBack = {},
|
||||
onScanned = {},
|
||||
onOpenSettings = {},
|
||||
onScanStateChanged = {}
|
||||
onScanStateChanged = {},
|
||||
walletRestoringState = WalletRestoringState.NONE,
|
||||
addressValidationResult = AddressType.Transparent,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalPermissionsApi::class)
|
||||
@Composable
|
||||
@Suppress("LongParameterList", "UnusedMaterial3ScaffoldPaddingParameter")
|
||||
fun Scan(
|
||||
snackbarHostState: SnackbarHostState,
|
||||
onBack: () -> Unit,
|
||||
onScanned: (String) -> 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,
|
||||
snackbarHostState: SnackbarHostState,
|
||||
modifier: Modifier = Modifier
|
||||
walletRestoringState: WalletRestoringState,
|
||||
addressValidationResult: AddressType?
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
val permissionState =
|
||||
rememberPermissionState(
|
||||
Manifest.permission.CAMERA
|
||||
|
@ -210,102 +126,297 @@ private fun ScanMainContent(
|
|||
)
|
||||
}
|
||||
|
||||
if (!permissionState.status.isGranted) {
|
||||
setScanState(ScanState.Permission)
|
||||
if (permissionState.status.shouldShowRationale) {
|
||||
// keep blank screen with a link to the app settings
|
||||
// user denied the permission previously
|
||||
} else {
|
||||
LaunchedEffect(key1 = true) {
|
||||
permissionState.launchPermissionRequest()
|
||||
Scaffold(
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) }
|
||||
) { _ ->
|
||||
Box {
|
||||
ScanMainContent(
|
||||
addressValidationResult = addressValidationResult,
|
||||
onScanned = onScanned,
|
||||
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
|
||||
} else if (permissionState.status.isGranted) {
|
||||
if (scanState != ScanState.Scanning) {
|
||||
setScanState(ScanState.Scanning)
|
||||
|
||||
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingDefault))
|
||||
}
|
||||
}
|
||||
|
||||
@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 frameActualSize = (framePossibleSize.value.width * FRAME_SIZE_RATIO).roundToInt()
|
||||
|
||||
val density = LocalDensity.current
|
||||
|
||||
val configuration = LocalConfiguration.current
|
||||
|
||||
@Suppress("MagicNumber")
|
||||
val frameActualSize =
|
||||
if (configuration.orientation == Configuration.ORIENTATION_LANDSCAPE) {
|
||||
(framePossibleSize.value.height * 0.85).roundToInt()
|
||||
} else {
|
||||
(framePossibleSize.value.width * 0.7).roundToInt()
|
||||
}
|
||||
val framePosition =
|
||||
FramePosition(
|
||||
left = (framePossibleSize.value.width - frameActualSize) / 2f,
|
||||
top = (framePossibleSize.value.height - frameActualSize) / 2f,
|
||||
right = (framePossibleSize.value.width - frameActualSize) / 2f + frameActualSize,
|
||||
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()
|
||||
|
||||
when (scanState) {
|
||||
ScanState.Permission -> {
|
||||
// keep initial ui state
|
||||
// Keep initial ui state
|
||||
onScanStateChanged(ScanState.Permission)
|
||||
}
|
||||
ScanState.Scanning -> {
|
||||
// TODO [#437]: Scan QR Screen Frame Analysing
|
||||
// TODO [#437]: https://github.com/Electric-Coin-Company/zashi-android/issues/437
|
||||
onScanStateChanged(ScanState.Scanning)
|
||||
|
||||
ScanCameraView(
|
||||
framePosition = framePosition,
|
||||
isTorchOn = isTorchOn,
|
||||
onScanned = onScanned,
|
||||
permissionState = permissionState,
|
||||
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
|
||||
.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(frameActualSize)
|
||||
}
|
||||
.offset(
|
||||
x =
|
||||
with(density) {
|
||||
(
|
||||
framePosition.right.toDp() -
|
||||
ZcashTheme.dimens.cameraTorchButton -
|
||||
ZcashTheme.dimens.spacingDefault
|
||||
)
|
||||
},
|
||||
y = with(density) { framePosition.bottom.toDp() }
|
||||
)
|
||||
.clip(RoundedCornerShape(ZcashTheme.dimens.regularRippleEffectCorner))
|
||||
.clickable { setIsTorchOn(!isTorchOn) }
|
||||
.padding(ZcashTheme.dimens.spacingDefault)
|
||||
.size(
|
||||
width = ZcashTheme.dimens.cameraTorchButton,
|
||||
height = ZcashTheme.dimens.cameraTorchButton
|
||||
)
|
||||
)
|
||||
}
|
||||
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(
|
||||
scanState = scanState,
|
||||
addressValidationResult = addressValidationResult,
|
||||
onBack = onBack,
|
||||
onOpenSettings = onOpenSettings,
|
||||
scanState = scanState,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
|
@ -318,31 +429,82 @@ private fun ScanMainContent(
|
|||
}
|
||||
}
|
||||
|
||||
@Suppress("MagicNumber")
|
||||
@Composable
|
||||
fun ScanFrame(frameSize: Int) {
|
||||
fun ScanFrame(
|
||||
frameSize: Int,
|
||||
isScanning: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
@Suppress("MagicNumber")
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.size(with(LocalDensity.current) { frameSize.toDp() })
|
||||
.background(Color.Transparent)
|
||||
.border(BorderStroke(10.dp, Color.White), RoundedCornerShape(10))
|
||||
.testTag(ScanTag.QR_FRAME)
|
||||
)
|
||||
modifier
|
||||
.then(
|
||||
Modifier
|
||||
.size(with(LocalDensity.current) { frameSize.toDp() })
|
||||
.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)
|
||||
@SuppressWarnings("LongMethod")
|
||||
@Suppress("LongMethod")
|
||||
@Composable
|
||||
fun ScanCameraView(
|
||||
framePosition: FramePosition,
|
||||
isTorchOn: Boolean,
|
||||
onScanned: (result: String) -> Unit,
|
||||
permissionState: PermissionState,
|
||||
setScanState: (ScanState) -> Unit,
|
||||
permissionState: PermissionState
|
||||
) {
|
||||
val context = LocalContext.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
|
||||
val cameraProviderFlow =
|
||||
if (permissionState.status.isGranted) {
|
||||
|
@ -355,6 +517,9 @@ fun ScanCameraView(
|
|||
|
||||
val collectedCameraProvider = cameraProviderFlow?.collectAsState(initial = null)?.value
|
||||
|
||||
val cameraController = remember { mutableStateOf<CameraControl?>(null) }
|
||||
cameraController.value?.enableTorch(isTorchOn)
|
||||
|
||||
if (null == collectedCameraProvider) {
|
||||
// Show loading indicator
|
||||
} else {
|
||||
|
@ -387,14 +552,15 @@ fun ScanCameraView(
|
|||
}
|
||||
|
||||
runCatching {
|
||||
// we must unbind the use-cases before rebinding them
|
||||
// We must unbind the use-cases before rebinding them
|
||||
collectedCameraProvider.unbindAll()
|
||||
collectedCameraProvider.bindToLifecycle(
|
||||
lifecycleOwner,
|
||||
selector,
|
||||
preview,
|
||||
imageAnalysis
|
||||
)
|
||||
cameraController.value =
|
||||
collectedCameraProvider.bindToLifecycle(
|
||||
lifecycleOwner,
|
||||
selector,
|
||||
preview,
|
||||
imageAnalysis
|
||||
).cameraControl
|
||||
}.onFailure {
|
||||
Twig.error { "Scan QR failed in bind phase with: ${it.message}" }
|
||||
setScanState(ScanState.Failed)
|
||||
|
@ -402,12 +568,16 @@ fun ScanCameraView(
|
|||
|
||||
previewView
|
||||
},
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.testTag(ScanTag.CAMERA_VIEW)
|
||||
modifier =
|
||||
Modifier
|
||||
.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)
|
||||
}
|
||||
}
|
||||
|
@ -416,17 +586,23 @@ fun ScanCameraView(
|
|||
// Using callbackFlow because QrCodeAnalyzer has a non-suspending callback which makes
|
||||
// a basic flow builder not work here.
|
||||
@Composable
|
||||
fun ImageAnalysis.qrCodeFlow(context: Context): Flow<String> =
|
||||
fun ImageAnalysis.qrCodeFlow(
|
||||
context: Context,
|
||||
framePosition: FramePosition,
|
||||
): Flow<String> =
|
||||
remember {
|
||||
callbackFlow {
|
||||
setAnalyzer(
|
||||
ContextCompat.getMainExecutor(context),
|
||||
QrCodeAnalyzer { result ->
|
||||
// Note that these callbacks aren't tied to the Compose lifecycle, so they could occur
|
||||
// after the view goes away. Collection needs to occur within the Compose lifecycle
|
||||
// to make this not be a problem.
|
||||
trySend(result)
|
||||
}
|
||||
QrCodeAnalyzer(
|
||||
framePosition = framePosition,
|
||||
onQrCodeScanned = { result ->
|
||||
// Note that these callbacks aren't tied to the Compose lifecycle, so they could occur
|
||||
// after the view goes away. Collection needs to occur within the Compose lifecycle
|
||||
// to make this not be a problem.
|
||||
trySend(result)
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
awaitClose {
|
||||
|
|
|
@ -1,5 +1,3 @@
|
|||
@file:Suppress("ktlint:standard:filename")
|
||||
|
||||
package co.electriccoin.zcash.ui.screen.settings
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
<string name="about_version_format" formatted="true">Version <xliff:g example="1.0" id="version_name">%1$s
|
||||
</xliff:g></string>
|
||||
<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"
|
||||
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
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -1,20 +1,18 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="scan_header">Scan a Zcash QR</string>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<string name="scan_back">Back</string>
|
||||
<string name="scan_back_content_description">Back</string>
|
||||
<string name="scan_preview_content_description">Camera</string>
|
||||
|
||||
<string name="scan_hint">You can scan any Zcash QR code.</string>
|
||||
|
||||
<string name="scan_settings_button">Enable camera permission</string>
|
||||
<string name="scan_cancel_button">Cancel</string>
|
||||
<string name="scan_settings_button">Open Settings</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_setup_back">Back</string>
|
||||
<string name="scan_state_permission">
|
||||
<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_state_scanning">Scanning…</string>
|
||||
<string name="scan_state_failed">Scanning failed.</string>
|
||||
|
||||
<string name="scan_validation_invalid_address">Invalid Zcash address scanned.</string>
|
||||
<string name="scan_torch_content_description">Camera torch toggle</string>
|
||||
</resources>
|
||||
|
|
Loading…
Reference in New Issue