[#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]
### 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

View File

@ -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,

View File

@ -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)

View File

@ -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

View File

@ -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() =

View File

@ -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(),
)
}

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 {
implementation(libs.zcash.sdk)
implementation(projects.uiLib)
implementation(projects.uiDesignLib)
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.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()

View File

@ -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
)
}

View File

@ -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()
}

View File

@ -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
)
}

View File

@ -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,
)
}
}

View File

@ -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"
}

View File

@ -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 {

View File

@ -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 {

View File

@ -1,5 +1,3 @@
@file:Suppress("ktlint:standard:filename")
package co.electriccoin.zcash.ui.screen.settings
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
</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

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>
<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>