[#1595] Request ZEC flow (QR generation)

This commit is contained in:
Honza Rychnovský 2024-10-17 12:13:19 +02:00 committed by GitHub
parent 624bee88ef
commit 0e67d826d3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
40 changed files with 2127 additions and 24 deletions

View File

@ -17,6 +17,8 @@ and this application adheres to [Semantic Versioning](https://semver.org/spec/v2
### Added
- Address book local and remote storage support
- New QR Code detail screen has been added
- The new Request ZEC screens have been added. They provide a way to build ZIP 321 Uri consisting of the amount,
message, and receiver address and then creates a QR code image of it.
## [1.2 (739)] - 2024-09-27

View File

@ -20,6 +20,7 @@ directly impact users rather than highlighting other key architectural updates.*
### Added
- Address book local and remote storage support
- New QR Code detail screen has been added
- The new Request ZEC screens have been added. They provide a way to build ZIP 321 Uri consisting of the amount, message, and receiver address and then creates a QR code image of it.
## [1.2 (739)] - 2024-09-27

View File

@ -191,7 +191,10 @@ ANDROIDX_WORK_MANAGER_VERSION=2.9.0
ANDROIDX_BROWSER_VERSION=1.8.0
CORE_LIBRARY_DESUGARING_VERSION=2.0.4
FIREBASE_BOM_VERSION_MATCHER=33.1.1
GOOGLE_API_CLIENT_ANDROID_VERSION=1.26.0
GOOGLE_API_SERVICES_DRIVE_VERSION=v3-rev136-1.25.0
GOOGLE_AUTH_LIB_JAVA_VERSION=1.18.0
GOOGLE_HTTP_CLIENT_GSON_VERSION=1.45.0
JACOCO_VERSION=0.8.12
KOTLIN_VERSION=1.9.23
KOTLINX_COROUTINES_VERSION=1.8.0
@ -204,13 +207,10 @@ MARKDOWN_VERSION=0.7.3
PLAY_APP_UPDATE_VERSION=2.1.0
PLAY_APP_UPDATE_KTX_VERSION=2.1.0
PLAY_PUBLISHER_API_VERSION=v3-rev20231030-2.0.0
PLAY_SERVICES_AUTH_VERSION=21.2.0
ZCASH_ANDROID_WALLET_PLUGINS_VERSION=1.0.0
ZXING_VERSION=3.5.3
GOOGLE_HTTP_CLIENT_GSON_VERSION=1.45.0
GOOGLE_API_CLIENT_ANDROID_VERSION=1.26.0
GOOGLE_API_SERVICES_DRIVE_VERSION=v3-rev136-1.25.0
PLAY_SERVICES_AUTH_VERSION=21.2.0
ZIP_321_VERSION = 0.0.3
ZCASH_BIP39_VERSION=1.0.8
# WARNING: Ensure a non-snapshot version is used before releasing to production

View File

@ -19,6 +19,7 @@ dependencies {
api(libs.zcash.sdk)
api(libs.zcash.sdk.incubator)
api(libs.zcash.bip39)
api(libs.zip321)
androidTestImplementation(libs.bundles.androidx.test)
androidTestImplementation(libs.kotlinx.coroutines.test)

View File

@ -183,6 +183,7 @@ dependencyResolutionManagement {
val playAppUpdateKtxVersion = extra["PLAY_APP_UPDATE_KTX_VERSION"].toString()
val zcashBip39Version = extra["ZCASH_BIP39_VERSION"].toString()
val zcashSdkVersion = extra["ZCASH_SDK_VERSION"].toString()
val zip321Version = extra["ZIP_321_VERSION"].toString()
val zxingVersion = extra["ZXING_VERSION"].toString()
val koinVersion = extra["KOIN_VERSION"].toString()
val googleHttpClientGsonVersion = extra["GOOGLE_HTTP_CLIENT_GSON_VERSION"].toString()
@ -254,6 +255,7 @@ dependencyResolutionManagement {
library("zcash-sdk", "cash.z.ecc.android:zcash-android-sdk:$zcashSdkVersion")
library("zcash-sdk-incubator", "cash.z.ecc.android:zcash-android-sdk-incubator:$zcashSdkVersion")
library("zcash-bip39", "cash.z.ecc.android:kotlin-bip39:$zcashBip39Version")
library("zip321", "org.zecdev:zip321:$zip321Version")
library("zxing", "com.google.zxing:core:$zxingVersion")
library("koin", "io.insert-koin:koin-android:$koinVersion")
library("koin-compose", "io.insert-koin:koin-androidx-compose:$koinVersion")

View File

@ -47,6 +47,7 @@ android {
"src/main/res/ui/new_wallet_recovery",
"src/main/res/ui/onboarding",
"src/main/res/ui/qr_code",
"src/main/res/ui/request",
"src/main/res/ui/receive",
"src/main/res/ui/restore",
"src/main/res/ui/restore_success",

View File

@ -4,7 +4,6 @@ import androidx.compose.runtime.saveable.SaverScope
import androidx.test.filters.SmallTest
import cash.z.ecc.android.sdk.model.ZecSend
import cash.z.ecc.sdk.fixture.ZecSendFixture
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
@ -12,7 +11,6 @@ import kotlin.test.assertEquals
class ZecSendExtTest {
@Test
@SmallTest
@OptIn(ExperimentalCoroutinesApi::class)
fun round_trip() =
runTest {
val original = ZecSendFixture.new()

View File

@ -5,6 +5,7 @@ import co.electriccoin.zcash.ui.common.provider.AddressBookProviderImpl
import co.electriccoin.zcash.ui.common.provider.AddressBookStorageProvider
import co.electriccoin.zcash.ui.common.provider.AddressBookStorageProviderImpl
import co.electriccoin.zcash.ui.common.provider.GetDefaultServersProvider
import co.electriccoin.zcash.ui.common.provider.GetMonetarySeparatorProvider
import co.electriccoin.zcash.ui.common.provider.GetVersionInfoProvider
import co.electriccoin.zcash.ui.common.provider.GetZcashCurrencyProvider
import org.koin.core.module.dsl.factoryOf
@ -16,6 +17,7 @@ val providerModule =
factoryOf(::GetDefaultServersProvider)
factoryOf(::GetVersionInfoProvider)
factoryOf(::GetZcashCurrencyProvider)
factoryOf(::GetMonetarySeparatorProvider)
factoryOf(::AddressBookStorageProviderImpl) bind AddressBookStorageProvider::class
factoryOf(::AddressBookProviderImpl) bind AddressBookProvider::class
}

View File

@ -19,10 +19,12 @@ import co.electriccoin.zcash.ui.common.usecase.PersistEndpointUseCase
import co.electriccoin.zcash.ui.common.usecase.RefreshFastestServersUseCase
import co.electriccoin.zcash.ui.common.usecase.RescanBlockchainUseCase
import co.electriccoin.zcash.ui.common.usecase.SaveContactUseCase
import co.electriccoin.zcash.ui.common.usecase.ShareImageUseCase
import co.electriccoin.zcash.ui.common.usecase.UpdateContactUseCase
import co.electriccoin.zcash.ui.common.usecase.ValidateContactAddressUseCase
import co.electriccoin.zcash.ui.common.usecase.ValidateContactNameUseCase
import co.electriccoin.zcash.ui.common.usecase.ValidateEndpointUseCase
import co.electriccoin.zcash.ui.common.usecase.Zip321BuildUriUseCase
import org.koin.core.module.dsl.singleOf
import org.koin.dsl.module
@ -51,4 +53,6 @@ val useCaseModule =
singleOf(::ObserveContactPickedUseCase)
singleOf(::GetAddressesUseCase)
singleOf(::CopyToClipboardUseCase)
singleOf(::ShareImageUseCase)
singleOf(::Zip321BuildUriUseCase)
}

View File

@ -14,6 +14,7 @@ import co.electriccoin.zcash.ui.screen.contact.viewmodel.UpdateContactViewModel
import co.electriccoin.zcash.ui.screen.onboarding.viewmodel.OnboardingViewModel
import co.electriccoin.zcash.ui.screen.qrcode.viewmodel.QrCodeViewModel
import co.electriccoin.zcash.ui.screen.receive.viewmodel.ReceiveViewModel
import co.electriccoin.zcash.ui.screen.request.viewmodel.RequestViewModel
import co.electriccoin.zcash.ui.screen.restore.viewmodel.RestoreViewModel
import co.electriccoin.zcash.ui.screen.restoresuccess.viewmodel.RestoreSuccessViewModel
import co.electriccoin.zcash.ui.screen.sendconfirmation.viewmodel.CreateTransactionsViewModel
@ -65,4 +66,5 @@ val viewModelModule =
viewModelOf(::UpdateContactViewModel)
viewModelOf(::ReceiveViewModel)
viewModelOf(::QrCodeViewModel)
viewModelOf(::RequestViewModel)
}

View File

@ -37,6 +37,7 @@ import co.electriccoin.zcash.ui.NavigationTargets.EXPORT_PRIVATE_DATA
import co.electriccoin.zcash.ui.NavigationTargets.HOME
import co.electriccoin.zcash.ui.NavigationTargets.NOT_ENOUGH_SPACE
import co.electriccoin.zcash.ui.NavigationTargets.QR_CODE
import co.electriccoin.zcash.ui.NavigationTargets.REQUEST
import co.electriccoin.zcash.ui.NavigationTargets.SEED_RECOVERY
import co.electriccoin.zcash.ui.NavigationTargets.SEND_CONFIRMATION
import co.electriccoin.zcash.ui.NavigationTargets.SETTINGS
@ -70,6 +71,7 @@ import co.electriccoin.zcash.ui.screen.exportdata.WrapExportPrivateData
import co.electriccoin.zcash.ui.screen.home.WrapHome
import co.electriccoin.zcash.ui.screen.qrcode.WrapQrCode
import co.electriccoin.zcash.ui.screen.receive.model.ReceiveAddressType
import co.electriccoin.zcash.ui.screen.request.WrapRequest
import co.electriccoin.zcash.ui.screen.scan.ScanNavigationArgs
import co.electriccoin.zcash.ui.screen.scan.WrapScanValidator
import co.electriccoin.zcash.ui.screen.seedrecovery.WrapSeedRecovery
@ -343,6 +345,13 @@ internal fun MainActivity.Navigation() {
val addressType = backStackEntry.arguments?.getInt(ADDRESS_TYPE) ?: ReceiveAddressType.Unified.ordinal
WrapQrCode(addressType)
}
composable(
route = "$REQUEST/{$ADDRESS_TYPE}",
arguments = listOf(navArgument(ADDRESS_TYPE) { type = NavType.IntType })
) { backStackEntry ->
val addressType = backStackEntry.arguments?.getInt(ADDRESS_TYPE) ?: ReceiveAddressType.Unified.ordinal
WrapRequest(addressType)
}
}
}
@ -523,6 +532,7 @@ object NavigationTargets {
const val CHOOSE_SERVER = "choose_server"
const val NOT_ENOUGH_SPACE = "not_enough_space"
const val QR_CODE = "qr_code"
const val REQUEST = "request"
const val SEED_RECOVERY = "seed_recovery"
const val SEND_CONFIRMATION = "send_confirmation"
const val SETTINGS = "settings"

View File

@ -0,0 +1,8 @@
package co.electriccoin.zcash.ui.common.provider
import cash.z.ecc.android.sdk.model.MonetarySeparators
import java.util.Locale
class GetMonetarySeparatorProvider {
operator fun invoke() = MonetarySeparators.current(Locale.getDefault())
}

View File

@ -0,0 +1,95 @@
package co.electriccoin.zcash.ui.common.usecase
import android.content.Context
import android.graphics.Bitmap
import co.electriccoin.zcash.spackle.getInternalCacheDirSuspend
import co.electriccoin.zcash.ui.common.model.VersionInfo
import co.electriccoin.zcash.ui.common.provider.GetVersionInfoProvider
import co.electriccoin.zcash.ui.util.FileShareUtil
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.withContext
import java.io.File
private const val CACHE_SUBDIR = "zashi_qr_images" // NON-NLS
class ShareImageUseCase(
private val context: Context,
private val versionInfoProvider: GetVersionInfoProvider
) {
operator fun invoke(
shareImageBitmap: Bitmap,
shareText: String? = null,
sharePickerText: String,
filePrefix: String = "",
fileSuffix: String = ""
) = shareData(
context = context,
shareImageBitmap = shareImageBitmap,
versionInfo = versionInfoProvider(),
filePrefix = filePrefix,
fileSuffix = fileSuffix,
shareText = shareText,
sharePickerText = sharePickerText,
)
private fun shareData(
context: Context,
shareImageBitmap: Bitmap,
shareText: String?,
sharePickerText: String,
versionInfo: VersionInfo,
filePrefix: String,
fileSuffix: String,
): Flow<Boolean> =
callbackFlow {
// Initialize cache directory
val cacheDir = context.getInternalCacheDirSuspend(CACHE_SUBDIR)
// Save the bitmap to a temporary file in the cache directory
val bitmapFile =
withContext(Dispatchers.IO) {
File.createTempFile(
filePrefix,
fileSuffix,
cacheDir,
).also {
it.storeBitmap(shareImageBitmap)
}
}
// Example of the expected temporary file path:
// /data/user/0/co.electriccoin.zcash.debug/cache/zashi_qr_images/
// zcash_address_qr_6455164324646067652.png
val shareIntent =
FileShareUtil.newShareContentIntent(
context = context,
dataFilePath = bitmapFile.absolutePath,
fileType = FileShareUtil.ZASHI_QR_CODE_MIME_TYPE,
shareText = shareText,
sharePickerText = sharePickerText,
versionInfo = versionInfo,
)
runCatching {
context.startActivity(shareIntent)
trySend(true)
}.onFailure {
trySend(false)
}
awaitClose {
// No resources to release
}
}
private suspend fun File.storeBitmap(bitmap: Bitmap) =
withContext(Dispatchers.IO) {
outputStream().use { fOut ->
@Suppress("MagicNumber")
bitmap.compress(Bitmap.CompressFormat.PNG, 100, fOut)
fOut.flush()
}
}
}

View File

@ -0,0 +1,57 @@
package co.electriccoin.zcash.ui.common.usecase
import MemoBytes
import NonNegativeAmount
import Payment
import PaymentRequest
import RecipientAddress
import co.electriccoin.zcash.spackle.Twig
import org.zecdev.zip321.ZIP321
import java.math.BigDecimal
class Zip321BuildUriUseCase {
operator fun invoke(
address: String,
amount: BigDecimal,
memo: String,
) = buildUri(
address = address,
amount = amount,
memo = memo,
)
private fun buildUri(
address: String,
amount: BigDecimal,
memo: String,
): String {
val payment =
Payment(
recipientAddress = RecipientAddress(address),
nonNegativeAmount = NonNegativeAmount(amount),
memo =
if (memo.isBlank()) {
null
} else {
MemoBytes(memo)
},
otherParams = null,
label = null,
message = null
)
val paymentRequest = PaymentRequest(payments = listOf(payment))
// TODO [#1636]: Use fixed ZIP321 library version
// TODO [#1636]: https://github.com/Electric-Coin-Company/zashi-android/issues/1636
val zip321Uri =
ZIP321.uriString(
paymentRequest,
ZIP321.FormattingOptions.UseEmptyParamIndex(omitAddressLabel = true)
)
Twig.info { "Request Zip321 uri: $zip321Uri" }
return zip321Uri
}
}

View File

@ -12,7 +12,6 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.rememberScrollState
@ -38,7 +37,6 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import cash.z.ecc.android.sdk.fixture.WalletAddressesFixture
import cash.z.ecc.android.sdk.model.WalletAddress
import cash.z.ecc.android.sdk.model.WalletAddresses
@ -144,18 +142,15 @@ private fun ReceiveTopAppBar(
onClick = onSettings,
modifier =
Modifier
.padding(horizontal = ZcashTheme.dimens.spacingDefault)
// Making the size bigger by 3.dp so the rounded image corners are not stripped out
.size(43.dp)
.padding(end = ZcashTheme.dimens.spacingDefault)
.testTag(CommonTag.SETTINGS_TOP_BAR_BUTTON)
) {
Image(
painter =
painterResource(
id = co.electriccoin.zcash.ui.design.R.drawable.ic_hamburger_menu_with_bg
id = co.electriccoin.zcash.ui.design.R.drawable.ic_hamburger_menu
),
contentDescription = stringResource(id = R.string.settings_menu_content_description),
modifier = Modifier.padding(all = 3.dp)
)
}
},

View File

@ -1,7 +1,6 @@
package co.electriccoin.zcash.ui.screen.receive.viewmodel
import android.app.Application
import android.widget.Toast
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import cash.z.ecc.sdk.ANDROID_STATE_FLOW_TIMEOUT
@ -51,9 +50,10 @@ class ReceiveViewModel(
val navigationCommand = MutableSharedFlow<String>()
@Suppress("UNUSED_PARAMETER")
private fun onRequestClick(addressType: ReceiveAddressType) =
Toast.makeText(application.applicationContext, "Not implemented yet", Toast.LENGTH_SHORT).show()
viewModelScope.launch {
navigationCommand.emit("${NavigationTargets.REQUEST}/${addressType.ordinal}")
}
private fun onQrCodeClick(addressType: ReceiveAddressType) =
viewModelScope.launch {

View File

@ -0,0 +1,63 @@
@file:Suppress("ktlint:standard:filename")
package co.electriccoin.zcash.ui.screen.request
import androidx.activity.compose.BackHandler
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import co.electriccoin.zcash.di.koinActivityViewModel
import co.electriccoin.zcash.ui.R
import co.electriccoin.zcash.ui.common.compose.LocalNavController
import co.electriccoin.zcash.ui.common.viewmodel.WalletViewModel
import co.electriccoin.zcash.ui.screen.request.model.RequestState
import co.electriccoin.zcash.ui.screen.request.view.RequestView
import co.electriccoin.zcash.ui.screen.request.viewmodel.RequestViewModel
import org.koin.androidx.compose.koinViewModel
import org.koin.core.parameter.parametersOf
@Composable
internal fun WrapRequest(addressType: Int) {
val context = LocalContext.current
val navController = LocalNavController.current
val walletViewModel = koinActivityViewModel<WalletViewModel>()
val walletState by walletViewModel.walletStateInformation.collectAsStateWithLifecycle()
val requestViewModel = koinViewModel<RequestViewModel> { parametersOf(addressType) }
val requestState by requestViewModel.state.collectAsStateWithLifecycle()
val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(Unit) {
requestViewModel.backNavigationCommand.collect {
navController.popBackStack()
}
}
LaunchedEffect(Unit) {
requestViewModel.shareResultCommand.collect { sharedSuccessfully ->
if (!sharedSuccessfully) {
snackbarHostState.showSnackbar(
message = context.getString(R.string.request_qr_code_data_unable_to_share)
)
}
}
}
BackHandler {
when (requestState) {
RequestState.Loading -> {}
else -> requestViewModel.onBack()
}
}
RequestView(
state = requestState,
topAppBarSubTitleState = walletState,
snackbarHostState = snackbarHostState
)
}

View File

@ -0,0 +1,24 @@
package co.electriccoin.zcash.ui.screen.request.ext
import android.icu.text.DecimalFormat
import android.icu.text.NumberFormat
import cash.z.ecc.android.sdk.model.FRACTION_DIGITS
import cash.z.ecc.android.sdk.model.Locale
import cash.z.ecc.android.sdk.model.toJavaLocale
import java.math.BigDecimal
import java.text.ParseException
internal fun String.convertToDouble(): Double? {
val decimalFormat =
DecimalFormat.getInstance(Locale.getDefault().toJavaLocale(), NumberFormat.NUMBERSTYLE).apply {
roundingMode = android.icu.math.BigDecimal.ROUND_HALF_EVEN // aka Bankers rounding
maximumFractionDigits = FRACTION_DIGITS
minimumFractionDigits = FRACTION_DIGITS
}
return try {
decimalFormat.parse(this).toDouble()
} catch (e: ParseException) {
null
}
}

View File

@ -0,0 +1,9 @@
package co.electriccoin.zcash.ui.screen.request.model
internal sealed class OnAmount {
data class Number(val number: Int) : OnAmount()
data class Separator(val separator: String) : OnAmount()
data object Delete : OnAmount()
}

View File

@ -0,0 +1,121 @@
package co.electriccoin.zcash.ui.screen.request.model
import android.content.Context
import androidx.compose.ui.graphics.ImageBitmap
import cash.z.ecc.android.sdk.ext.convertUsdToZec
import cash.z.ecc.android.sdk.ext.toZecString
import cash.z.ecc.android.sdk.model.FiatCurrencyConversion
import cash.z.ecc.android.sdk.model.Locale
import cash.z.ecc.android.sdk.model.Memo
import cash.z.ecc.android.sdk.model.Zatoshi
import cash.z.ecc.android.sdk.model.fromZecString
import cash.z.ecc.android.sdk.model.toFiatString
import co.electriccoin.zcash.ui.screen.request.ext.convertToDouble
import co.electriccoin.zcash.ui.screen.request.model.MemoState.Valid
data class Request(
val amountState: AmountState,
val memoState: MemoState,
val qrCodeState: QrCodeState,
)
sealed class AmountState(
open val amount: String,
open val currency: RequestCurrency
) {
fun isValid(): Boolean = this is Valid
abstract fun copyState(
newValue: String = amount,
newCurrency: RequestCurrency = currency
): AmountState
fun toZecString(conversion: FiatCurrencyConversion): String =
runCatching {
amount.convertToDouble().convertUsdToZec(conversion.priceOfZec).toZecString()
}.getOrElse { "" }
fun toFiatString(
context: Context,
conversion: FiatCurrencyConversion
): String =
kotlin.runCatching {
Zatoshi.fromZecString(context, amount, Locale.getDefault())?.toFiatString(
currencyConversion = conversion,
locale = Locale.getDefault()
) ?: ""
}.getOrElse { "" }
data class Valid(
override val amount: String,
override val currency: RequestCurrency
) : AmountState(amount, currency) {
override fun copyState(
newValue: String,
newCurrency: RequestCurrency
) = copy(amount = newValue, currency = newCurrency)
}
data class Default(
override val amount: String,
override val currency: RequestCurrency
) : AmountState(amount, currency) {
override fun copyState(
newValue: String,
newCurrency: RequestCurrency
) = copy(amount = newValue, currency = newCurrency)
}
data class InValid(
override val amount: String,
override val currency: RequestCurrency
) : AmountState(amount, currency) {
override fun copyState(
newValue: String,
newCurrency: RequestCurrency
) = copy(amount = newValue, currency = newCurrency)
}
}
sealed class MemoState(
open val text: String,
open val byteSize: Int,
open val zecAmount: String
) {
fun isValid(): Boolean = this is Valid
data class Valid(
override val text: String,
override val byteSize: Int,
override val zecAmount: String
) : MemoState(text, byteSize, zecAmount)
data class InValid(
override val text: String,
override val byteSize: Int,
override val zecAmount: String
) : MemoState(text, byteSize, zecAmount)
companion object {
fun new(
memo: String,
amount: String
): MemoState {
val bytesCount = Memo.countLength(memo)
return if (bytesCount > Memo.MAX_MEMO_LENGTH_BYTES) {
InValid(memo, bytesCount, amount)
} else {
Valid(memo, bytesCount, amount)
}
}
}
}
data class QrCodeState(
val requestUri: String,
val zecAmount: String,
val memo: String,
val bitmap: ImageBitmap?
) {
fun isValid(): Boolean = bitmap != null
}

View File

@ -0,0 +1,7 @@
package co.electriccoin.zcash.ui.screen.request.model
sealed class RequestCurrency {
data object Zec : RequestCurrency()
data object Fiat : RequestCurrency()
}

View File

@ -0,0 +1,7 @@
package co.electriccoin.zcash.ui.screen.request.model
enum class RequestStage {
AMOUNT,
MEMO,
QR_CODE
}

View File

@ -0,0 +1,43 @@
package co.electriccoin.zcash.ui.screen.request.model
import androidx.compose.ui.graphics.ImageBitmap
import cash.z.ecc.android.sdk.model.MonetarySeparators
import cash.z.ecc.android.sdk.model.WalletAddress
import cash.z.ecc.sdk.type.ZcashCurrency
import co.electriccoin.zcash.ui.common.wallet.ExchangeRateState
internal sealed class RequestState {
data object Loading : RequestState()
sealed class Prepared(open val onBack: () -> Unit) : RequestState()
data class Amount(
val request: Request,
val exchangeRateState: ExchangeRateState,
val zcashCurrency: ZcashCurrency,
val monetarySeparators: MonetarySeparators,
val onAmount: (OnAmount) -> Unit,
val onSwitch: (RequestCurrency) -> Unit,
override val onBack: () -> Unit,
val onDone: () -> Unit,
) : Prepared(onBack)
data class Memo(
val request: Request,
val walletAddress: WalletAddress,
val zcashCurrency: ZcashCurrency,
val onMemo: (MemoState) -> Unit,
val onDone: () -> Unit,
override val onBack: () -> Unit,
) : Prepared(onBack)
data class QrCode(
val request: Request,
val walletAddress: WalletAddress,
val onQrCodeShare: (ImageBitmap) -> Unit,
val onQrCodeGenerate: (pixels: Int) -> Unit,
override val onBack: () -> Unit,
val onClose: () -> Unit,
val zcashCurrency: ZcashCurrency,
) : Prepared(onBack)
}

View File

@ -0,0 +1,438 @@
package co.electriccoin.zcash.ui.screen.request.view
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.requiredHeight
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import co.electriccoin.zcash.ui.R
import co.electriccoin.zcash.ui.common.wallet.ExchangeRateState
import co.electriccoin.zcash.ui.design.theme.ZcashTheme
import co.electriccoin.zcash.ui.design.theme.colors.ZashiColors
import co.electriccoin.zcash.ui.design.theme.typography.ZashiTypography
import co.electriccoin.zcash.ui.screen.request.model.AmountState
import co.electriccoin.zcash.ui.screen.request.model.OnAmount
import co.electriccoin.zcash.ui.screen.request.model.RequestCurrency
import co.electriccoin.zcash.ui.screen.request.model.RequestState
@Composable
internal fun RequestAmountView(
state: RequestState.Amount,
modifier: Modifier = Modifier
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier =
modifier
.fillMaxSize()
.verticalScroll(rememberScrollState()),
) {
Spacer(Modifier.height(ZcashTheme.dimens.spacingDefault))
InvalidAmountView(state.request.amountState)
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingDefault))
when (state.exchangeRateState) {
is ExchangeRateState.Data -> {
if (state.request.amountState.currency == RequestCurrency.Zec) {
RequestAmountWithMainZecView(
state = state,
onFiatPreferenceSwitch = { state.onSwitch(RequestCurrency.Fiat) },
modifier = Modifier.padding(horizontal = ZcashTheme.dimens.screenHorizontalSpacingRegular)
)
} else {
RequestAmountWithMainFiatView(
state = state,
onFiatPreferenceSwitch = { state.onSwitch(RequestCurrency.Zec) },
modifier = Modifier.padding(horizontal = ZcashTheme.dimens.screenHorizontalSpacingRegular)
)
}
}
else -> {
RequestAmountNoFiatView(
state = state,
modifier = Modifier.padding(horizontal = ZcashTheme.dimens.screenHorizontalSpacingRegular)
)
}
}
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingDefault))
Spacer(modifier = Modifier.weight(1f))
RequestAmountKeyboardView(state = state)
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingLarge))
}
}
@Composable
private fun RequestAmountWithMainFiatView(
state: RequestState.Amount,
onFiatPreferenceSwitch: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = modifier
) {
state.exchangeRateState as ExchangeRateState.Data
val fiatText =
buildAnnotatedString {
withStyle(style = SpanStyle(color = ZashiColors.Text.textQuaternary)) {
append(state.exchangeRateState.fiatCurrency.symbol)
}
append("\u2009") // Add an extra thin space between the texts
withStyle(style = SpanStyle(color = ZashiColors.Text.textPrimary)) {
append(
if (state.exchangeRateState.currencyConversion != null) {
state.request.amountState.amount
} else {
stringResource(id = R.string.request_amount_empty)
}
)
}
}
AutoSizingText(
text = fiatText,
style =
ZashiTypography.header1.copy(
fontWeight = FontWeight.SemiBold
)
)
Spacer(modifier = Modifier.height(12.dp))
Row(
verticalAlignment = Alignment.CenterVertically
) {
val zecText =
buildAnnotatedString {
withStyle(style = SpanStyle(color = ZashiColors.Text.textPrimary)) {
append(
if (state.exchangeRateState.currencyConversion != null) {
state.request.amountState.toZecString(
state.exchangeRateState.currencyConversion
)
} else {
stringResource(id = R.string.request_amount_empty)
}
)
}
append(" ") // Add an extra space between the texts
withStyle(style = SpanStyle(color = ZashiColors.Text.textQuaternary)) {
append(state.zcashCurrency.localizedName(LocalContext.current))
}
}
Text(
text = zecText,
style = ZashiTypography.textLg,
fontWeight = FontWeight.Medium,
maxLines = 1
)
Spacer(modifier = Modifier.width(10.dp))
Image(
painter = painterResource(id = R.drawable.ic_switch),
contentDescription = null,
modifier = Modifier.clickable { onFiatPreferenceSwitch() }
)
}
}
}
@Composable
private fun RequestAmountWithMainZecView(
state: RequestState.Amount,
onFiatPreferenceSwitch: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = modifier
) {
state.exchangeRateState as ExchangeRateState.Data
val zecText =
buildAnnotatedString {
withStyle(style = SpanStyle(color = ZashiColors.Text.textPrimary)) {
append(state.request.amountState.amount)
}
append("\u2009") // Add an extra thin space between the texts
withStyle(style = SpanStyle(color = ZashiColors.Text.textQuaternary)) {
append(state.zcashCurrency.localizedName(LocalContext.current))
}
}
AutoSizingText(
text = zecText,
style =
ZashiTypography.header1.copy(
fontWeight = FontWeight.SemiBold
)
)
Spacer(modifier = Modifier.height(12.dp))
Row(
verticalAlignment = Alignment.CenterVertically
) {
val fiatText =
buildAnnotatedString {
withStyle(style = SpanStyle(color = ZashiColors.Text.textQuaternary)) {
append(state.exchangeRateState.fiatCurrency.symbol)
}
append(" ") // Add an extra space between the texts
withStyle(style = SpanStyle(color = ZashiColors.Text.textPrimary)) {
append(
if (state.exchangeRateState.currencyConversion != null) {
state.request.amountState.toFiatString(
LocalContext.current,
state.exchangeRateState.currencyConversion
)
} else {
stringResource(id = R.string.request_amount_empty)
}
)
}
}
Text(
text = fiatText,
style = ZashiTypography.textLg,
fontWeight = FontWeight.Medium,
maxLines = 1,
)
Spacer(modifier = Modifier.width(10.dp))
Image(
painter = painterResource(id = R.drawable.ic_switch),
contentDescription = null,
modifier = Modifier.clickable { onFiatPreferenceSwitch() }
)
}
}
}
@Composable
private fun RequestAmountNoFiatView(
state: RequestState.Amount,
modifier: Modifier = Modifier,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = modifier
) {
val fiatText =
buildAnnotatedString {
withStyle(style = SpanStyle(color = ZashiColors.Text.textPrimary)) {
append(state.request.amountState.amount)
}
append("\u2009") // Add an extra thin space between the texts
withStyle(style = SpanStyle(color = ZashiColors.Text.textQuaternary)) {
append(state.zcashCurrency.localizedName(LocalContext.current))
}
}
AutoSizingText(
text = fiatText,
style =
ZashiTypography.header1.copy(
fontWeight = FontWeight.SemiBold
)
)
}
}
private const val KEYBOARD_ZERO = 0
private const val KEYBOARD_ONE = 1
private const val KEYBOARD_TWO = 2
private const val KEYBOARD_THREE = 3
private const val KEYBOARD_FOUR = 4
private const val KEYBOARD_FIVE = 5
private const val KEYBOARD_SIX = 6
private const val KEYBOARD_SEVEN = 7
private const val KEYBOARD_EIGHT = 8
private const val KEYBOARD_NINE = 9
@Composable
private fun RequestAmountKeyboardView(
state: RequestState.Amount,
modifier: Modifier = Modifier
) {
Row(
horizontalArrangement = Arrangement.SpaceEvenly,
modifier =
modifier
.wrapContentHeight()
.fillMaxWidth()
.padding(horizontal = 8.dp)
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally
) {
RequestAmountKeyboardTextButton(
text = stringResource(id = R.string.request_amount_keyboard_one),
onClick = { state.onAmount(OnAmount.Number(KEYBOARD_ONE)) }
)
RequestAmountKeyboardTextButton(
text = stringResource(id = R.string.request_amount_keyboard_four),
onClick = { state.onAmount(OnAmount.Number(KEYBOARD_FOUR)) }
)
RequestAmountKeyboardTextButton(
text = stringResource(id = R.string.request_amount_keyboard_seven),
onClick = { state.onAmount(OnAmount.Number(KEYBOARD_SEVEN)) }
)
RequestAmountKeyboardTextButton(
text = state.monetarySeparators.decimal.toString(),
onClick = {
state.onAmount(OnAmount.Separator(state.monetarySeparators.decimal.toString()))
}
)
}
Column(
horizontalAlignment = Alignment.CenterHorizontally
) {
RequestAmountKeyboardTextButton(
text = stringResource(id = R.string.request_amount_keyboard_two),
onClick = { state.onAmount(OnAmount.Number(KEYBOARD_TWO)) }
)
RequestAmountKeyboardTextButton(
text = stringResource(id = R.string.request_amount_keyboard_five),
onClick = { state.onAmount(OnAmount.Number(KEYBOARD_FIVE)) }
)
RequestAmountKeyboardTextButton(
text = stringResource(id = R.string.request_amount_keyboard_eight),
onClick = { state.onAmount(OnAmount.Number(KEYBOARD_EIGHT)) }
)
RequestAmountKeyboardTextButton(
text = stringResource(id = R.string.request_amount_keyboard_zero),
onClick = { state.onAmount(OnAmount.Number(KEYBOARD_ZERO)) }
)
}
Column(
horizontalAlignment = Alignment.CenterHorizontally
) {
RequestAmountKeyboardTextButton(
text = stringResource(id = R.string.request_amount_keyboard_three),
onClick = { state.onAmount(OnAmount.Number(KEYBOARD_THREE)) }
)
RequestAmountKeyboardTextButton(
text = stringResource(id = R.string.request_amount_keyboard_six),
onClick = { state.onAmount(OnAmount.Number(KEYBOARD_SIX)) }
)
RequestAmountKeyboardTextButton(
text = stringResource(id = R.string.request_amount_keyboard_nine),
onClick = { state.onAmount(OnAmount.Number(KEYBOARD_NINE)) }
)
RequestAmountKeyboardIconButton(
painter = painterResource(id = R.drawable.ic_delete),
contentDescription = stringResource(id = R.string.request_amount_keyboard_delete),
onClick = { state.onAmount(OnAmount.Delete) }
)
}
}
}
@Composable
private fun RequestAmountKeyboardTextButton(
text: String,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
Text(
text = text,
style = ZashiTypography.header4,
fontWeight = FontWeight.SemiBold,
color = ZashiColors.Text.textPrimary,
modifier =
modifier
.wrapContentHeight()
.clip(RoundedCornerShape(ZcashTheme.dimens.regularRippleEffectCorner))
.clickable { onClick() }
.padding(horizontal = 42.dp, vertical = 10.dp)
)
}
@Composable
private fun RequestAmountKeyboardIconButton(
painter: Painter,
contentDescription: String,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
Image(
painter = painter,
contentDescription = contentDescription,
colorFilter = ColorFilter.tint(ZashiColors.Btns.Ghost.btnGhostFg),
modifier =
modifier
.clip(RoundedCornerShape(ZcashTheme.dimens.regularRippleEffectCorner))
.clickable { onClick() }
.padding(horizontal = 32.dp, vertical = 10.dp)
)
}
@Composable
private fun InvalidAmountView(
amountState: AmountState,
modifier: Modifier = Modifier
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier =
modifier
.fillMaxWidth()
.requiredHeight(48.dp)
) {
if (amountState is AmountState.InValid) {
Image(
painter = painterResource(id = R.drawable.ic_alert_outline),
contentDescription = null
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = stringResource(id = R.string.request_amount_invalid),
color = ZashiColors.Utility.WarningYellow.utilityOrange700,
style = ZashiTypography.textSm,
fontWeight = FontWeight.Medium,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.wrapContentWidth()
)
}
}
}

View File

@ -0,0 +1,214 @@
package co.electriccoin.zcash.ui.screen.request.view
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image
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.padding
import androidx.compose.foundation.relocation.BringIntoViewRequester
import androidx.compose.foundation.relocation.bringIntoViewRequester
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.withStyle
import cash.z.ecc.android.sdk.model.Memo
import co.electriccoin.zcash.ui.R
import co.electriccoin.zcash.ui.design.component.ZashiBadge
import co.electriccoin.zcash.ui.design.component.ZashiBadgeColors
import co.electriccoin.zcash.ui.design.component.ZashiTextField
import co.electriccoin.zcash.ui.design.component.ZashiTextFieldDefaults
import co.electriccoin.zcash.ui.design.theme.ZcashTheme
import co.electriccoin.zcash.ui.design.theme.colors.ZashiColors
import co.electriccoin.zcash.ui.design.theme.typography.ZashiTypography
import co.electriccoin.zcash.ui.screen.request.model.MemoState
import co.electriccoin.zcash.ui.screen.request.model.RequestState
@Composable
internal fun RequestMemoView(
state: RequestState.Memo,
modifier: Modifier = Modifier
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier =
modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = ZcashTheme.dimens.spacingLarge),
) {
Spacer(Modifier.height(ZcashTheme.dimens.spacingDefault))
Image(
painter = painterResource(id = R.drawable.ic_logo_empty_z),
colorFilter = ColorFilter.tint(ZashiColors.Surfaces.bgAlt),
contentDescription = null
)
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingLarge))
ZashiBadge(
text = stringResource(id = R.string.request_privacy_level_shielded),
leadingIconVector = painterResource(id = R.drawable.ic_solid_check),
colors =
ZashiBadgeColors(
border = ZashiColors.Utility.Purple.utilityPurple200,
text = ZashiColors.Utility.Purple.utilityPurple700,
container = ZashiColors.Utility.Purple.utilityPurple50,
)
)
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingDefault))
Text(
text = stringResource(id = R.string.request_memo_payment_request_subtitle),
style = ZashiTypography.textLg,
fontWeight = FontWeight.Medium,
color = ZashiColors.Text.textTertiary
)
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingMid))
RequestMemoZecAmountView(
state = state,
modifier = Modifier.padding(horizontal = ZcashTheme.dimens.spacingSmall)
)
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingBig))
RequestMemoTextField(state = state)
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingDefault))
}
}
@Composable
private fun RequestMemoZecAmountView(
state: RequestState.Memo,
modifier: Modifier = Modifier
) {
val zecText =
buildAnnotatedString {
withStyle(style = SpanStyle(color = ZashiColors.Text.textPrimary)) {
append(state.request.memoState.zecAmount)
}
append("\u2009") // Add an extra thin space between the texts
withStyle(style = SpanStyle(color = ZashiColors.Text.textQuaternary)) {
append(state.zcashCurrency.localizedName(LocalContext.current))
}
}
AutoSizingText(
text = zecText,
style =
ZashiTypography.header1.copy(
fontWeight = FontWeight.SemiBold
),
modifier = modifier
)
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun RequestMemoTextField(
state: RequestState.Memo,
modifier: Modifier = Modifier
) {
val memoState = state.request.memoState
val focusRequester = remember { FocusRequester() }
val bringIntoViewRequester = remember { BringIntoViewRequester() }
Column(
modifier =
modifier
// Animate error show/hide
.animateContentSize()
// Scroll TextField above ime keyboard
.bringIntoViewRequester(bringIntoViewRequester)
.focusRequester(focusRequester),
) {
ZashiTextField(
minLines = 3,
value = memoState.text,
// Empty error message as the length counter color is used for error signaling
error = if (memoState.isValid()) null else "",
onValueChange = {
state.onMemo(MemoState.new(it, memoState.zecAmount))
},
keyboardOptions =
KeyboardOptions(
keyboardType = KeyboardType.Text,
imeAction = ImeAction.Default,
capitalization = KeyboardCapitalization.Sentences
),
placeholder = {
Text(
text = stringResource(id = R.string.request_memo_text_field_hint),
style = ZashiTypography.textMd,
color = ZashiColors.Inputs.Default.text
)
},
colors =
if (memoState.isValid()) {
ZashiTextFieldDefaults.defaultColors()
} else {
ZashiTextFieldDefaults.defaultColors(
disabledTextColor = ZashiColors.Inputs.Disabled.text,
disabledHintColor = ZashiColors.Inputs.Disabled.hint,
disabledBorderColor = Color.Unspecified,
disabledContainerColor = ZashiColors.Inputs.Disabled.bg,
disabledPlaceholderColor = ZashiColors.Inputs.Disabled.text,
)
},
modifier = Modifier.fillMaxWidth(),
)
Text(
text =
stringResource(
id = R.string.request_memo_bytes_counter,
Memo.MAX_MEMO_LENGTH_BYTES - memoState.byteSize,
Memo.MAX_MEMO_LENGTH_BYTES
),
color =
if (memoState.isValid()) {
ZashiColors.Inputs.Default.hint
} else {
ZashiColors.Inputs.Filled.required
},
textAlign = TextAlign.End,
style = ZashiTypography.textSm,
modifier =
Modifier
.fillMaxWidth()
.padding(top = ZcashTheme.dimens.spacingTiny)
)
LaunchedEffect(Unit) {
focusRequester.requestFocus()
}
}
}

View File

@ -0,0 +1,199 @@
package co.electriccoin.zcash.ui.screen.request.view
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Image
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import cash.z.ecc.android.sdk.model.WalletAddress
import co.electriccoin.zcash.ui.R
import co.electriccoin.zcash.ui.design.component.CircularScreenProgressIndicator
import co.electriccoin.zcash.ui.design.component.ZashiBadge
import co.electriccoin.zcash.ui.design.component.ZashiBadgeColors
import co.electriccoin.zcash.ui.design.theme.ZcashTheme
import co.electriccoin.zcash.ui.design.theme.colors.ZashiColors
import co.electriccoin.zcash.ui.design.theme.dimensions.ZashiDimensionsInternal
import co.electriccoin.zcash.ui.design.theme.typography.ZashiTypography
import co.electriccoin.zcash.ui.screen.request.model.QrCodeState
import co.electriccoin.zcash.ui.screen.request.model.RequestState
import kotlin.math.roundToInt
@Composable
internal fun RequestQrCodeView(
state: RequestState.QrCode,
modifier: Modifier = Modifier
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier =
modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = ZcashTheme.dimens.spacingLarge),
) {
Spacer(Modifier.height(ZcashTheme.dimens.spacingDefault))
when (state.walletAddress) {
is WalletAddress.Transparent -> {
ZashiBadge(
text = stringResource(id = R.string.request_privacy_level_transparent),
leadingIconVector = painterResource(id = R.drawable.ic_alert_circle),
colors =
ZashiBadgeColors(
border = ZashiColors.Utility.WarningYellow.utilityOrange200,
text = ZashiColors.Utility.WarningYellow.utilityOrange700,
container = ZashiColors.Utility.WarningYellow.utilityOrange50,
)
)
}
is WalletAddress.Unified, is WalletAddress.Sapling -> {
ZashiBadge(
text = stringResource(id = R.string.request_privacy_level_shielded),
leadingIconVector = painterResource(id = R.drawable.ic_solid_check),
colors =
ZashiBadgeColors(
border = ZashiColors.Utility.Purple.utilityPurple200,
text = ZashiColors.Utility.Purple.utilityPurple700,
container = ZashiColors.Utility.Purple.utilityPurple50,
)
)
}
else -> error("Unsupported address type")
}
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingMid))
RequestQrCodeZecAmountView(
state = state,
modifier = Modifier.padding(horizontal = ZcashTheme.dimens.spacingSmall)
)
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingBig))
QrCode(
qrCodeState = state.request.qrCodeState,
onQrImageShare = state.onQrCodeShare,
onQrImageGenerate = state.onQrCodeGenerate,
modifier = Modifier.padding(horizontal = 24.dp),
)
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingDefault))
}
}
@Composable
private fun ColumnScope.QrCode(
qrCodeState: QrCodeState,
onQrImageShare: (ImageBitmap) -> Unit,
onQrImageGenerate: (pixels: Int) -> Unit,
modifier: Modifier = Modifier
) {
val sizePixels = with(LocalDensity.current) { DEFAULT_QR_CODE_SIZE.toPx() }.roundToInt()
if (qrCodeState.bitmap == null) {
onQrImageGenerate(sizePixels)
}
QrCode(
qrCodeImage = qrCodeState.bitmap,
onQrImageBitmapShare = onQrImageShare,
contentDescription = stringResource(id = R.string.request_qr_code_content_description),
modifier =
modifier
.align(Alignment.CenterHorizontally)
.border(
border =
BorderStroke(
width = 1.dp,
color = ZashiColors.Surfaces.strokePrimary
),
shape = RoundedCornerShape(ZashiDimensionsInternal.Radius.radius4xl)
)
.padding(all = 12.dp)
)
}
@Composable
private fun QrCode(
contentDescription: String,
qrCodeImage: ImageBitmap?,
onQrImageBitmapShare: (ImageBitmap) -> Unit,
modifier: Modifier = Modifier,
) {
Box(
contentAlignment = Alignment.Center,
modifier =
Modifier
.clickable(
indication = null,
interactionSource = remember { MutableInteractionSource() },
onClick = { qrCodeImage?.let { onQrImageBitmapShare(qrCodeImage) } },
)
.then(modifier)
) {
if (qrCodeImage == null) {
CircularScreenProgressIndicator()
} else {
Image(
bitmap = qrCodeImage,
contentDescription = contentDescription,
)
Image(
painter = painterResource(id = R.drawable.logo_zec_fill_stroke),
contentDescription = contentDescription,
)
}
}
}
@Composable
private fun RequestQrCodeZecAmountView(
state: RequestState.QrCode,
modifier: Modifier = Modifier
) {
val zecText =
buildAnnotatedString {
withStyle(style = SpanStyle(color = ZashiColors.Text.textPrimary)) {
append(state.request.qrCodeState.zecAmount)
}
append("\u2009") // Add an extra thin space between the texts
withStyle(style = SpanStyle(color = ZashiColors.Text.textQuaternary)) {
append(state.zcashCurrency.localizedName(LocalContext.current))
}
}
AutoSizingText(
text = zecText,
style =
ZashiTypography.header1.copy(
fontWeight = FontWeight.SemiBold
),
modifier = modifier
)
}
private val DEFAULT_QR_CODE_SIZE = 320.dp

View File

@ -0,0 +1,249 @@
@file:Suppress("TooManyFunctions")
package co.electriccoin.zcash.ui.screen.request.view
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import cash.z.ecc.android.sdk.model.Locale
import cash.z.ecc.android.sdk.model.MonetarySeparators
import cash.z.ecc.android.sdk.model.toJavaLocale
import cash.z.ecc.sdk.type.ZcashCurrency
import co.electriccoin.zcash.ui.R
import co.electriccoin.zcash.ui.common.model.TopAppBarSubTitleState
import co.electriccoin.zcash.ui.common.wallet.ExchangeRateState
import co.electriccoin.zcash.ui.design.component.BlankBgScaffold
import co.electriccoin.zcash.ui.design.component.CircularScreenProgressIndicator
import co.electriccoin.zcash.ui.design.component.ZashiBottomBar
import co.electriccoin.zcash.ui.design.component.ZashiButton
import co.electriccoin.zcash.ui.design.component.ZashiButtonDefaults
import co.electriccoin.zcash.ui.design.component.ZashiSmallTopAppBar
import co.electriccoin.zcash.ui.design.component.ZashiTopAppBarBackNavigation
import co.electriccoin.zcash.ui.design.newcomponent.PreviewScreens
import co.electriccoin.zcash.ui.design.theme.ZcashTheme
import co.electriccoin.zcash.ui.screen.request.model.AmountState
import co.electriccoin.zcash.ui.screen.request.model.MemoState
import co.electriccoin.zcash.ui.screen.request.model.QrCodeState
import co.electriccoin.zcash.ui.screen.request.model.Request
import co.electriccoin.zcash.ui.screen.request.model.RequestCurrency
import co.electriccoin.zcash.ui.screen.request.model.RequestState
@Composable
@PreviewScreens
private fun RequestLoadingPreview() =
ZcashTheme(forceDarkMode = true) {
RequestView(
state = RequestState.Loading,
snackbarHostState = SnackbarHostState(),
topAppBarSubTitleState = TopAppBarSubTitleState.None,
)
}
@Composable
@PreviewScreens
private fun RequestPreview() =
ZcashTheme(forceDarkMode = false) {
RequestView(
state =
RequestState.Amount(
request =
Request(
amountState = AmountState.Valid("2.25", RequestCurrency.Zec),
memoState = MemoState.Valid("", 0, "2.25"),
qrCodeState =
QrCodeState(
"zcash:t1duiEGg7b39nfQee3XaTY4f5McqfyJKhBi?amount=1&memo=VGhpcyBpcyBhIHNpbXBsZSBt",
"0.25",
memo = "Text memo",
null
),
),
exchangeRateState = ExchangeRateState.OptedOut,
zcashCurrency = ZcashCurrency.ZEC,
onAmount = {},
onBack = {},
onDone = {},
onSwitch = {},
monetarySeparators = MonetarySeparators.current(Locale.getDefault().toJavaLocale())
),
snackbarHostState = SnackbarHostState(),
topAppBarSubTitleState = TopAppBarSubTitleState.None,
)
}
@Composable
internal fun RequestView(
state: RequestState,
snackbarHostState: SnackbarHostState,
topAppBarSubTitleState: TopAppBarSubTitleState,
) {
when (state) {
RequestState.Loading -> {
CircularScreenProgressIndicator()
}
is RequestState.Prepared -> {
BlankBgScaffold(
topBar = {
RequestTopAppBar(
onBack = state.onBack,
subTitleState = topAppBarSubTitleState,
)
},
snackbarHost = { SnackbarHost(snackbarHostState) },
bottomBar = {
RequestBottomBar(state = state)
}
) { paddingValues ->
RequestContents(
state = state,
modifier =
Modifier.padding(
top = paddingValues.calculateTopPadding(),
bottom = paddingValues.calculateBottomPadding()
),
)
}
}
}
}
@Composable
private fun RequestTopAppBar(
onBack: () -> Unit,
subTitleState: TopAppBarSubTitleState,
) {
ZashiSmallTopAppBar(
subtitle =
when (subTitleState) {
TopAppBarSubTitleState.Disconnected -> stringResource(id = R.string.disconnected_label)
TopAppBarSubTitleState.Restoring -> stringResource(id = R.string.restoring_wallet_label)
TopAppBarSubTitleState.None -> null
},
title = stringResource(id = R.string.request_title),
navigationAction = {
ZashiTopAppBarBackNavigation(onBack = onBack)
},
)
}
@Composable
private fun RequestBottomBar(
state: RequestState.Prepared,
modifier: Modifier = Modifier,
) {
ZashiBottomBar(modifier = modifier.fillMaxWidth()) {
when (state) {
is RequestState.Amount -> {
ZashiButton(
text = stringResource(id = R.string.request_amount_btn),
onClick = state.onDone,
enabled = state.request.amountState.isValid(),
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp)
)
}
is RequestState.Memo -> {
ZashiButton(
enabled = state.request.memoState.isValid(),
onClick = state.onDone,
text = stringResource(id = R.string.request_memo_btn),
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp)
)
}
is RequestState.QrCode -> {
ZashiButton(
text = stringResource(id = R.string.request_qr_share_btn),
leadingIcon = painterResource(R.drawable.ic_share),
enabled = state.request.qrCodeState.isValid(),
onClick = { state.onQrCodeShare(state.request.qrCodeState.bitmap!!) },
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp)
)
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingTiny))
ZashiButton(
colors = ZashiButtonDefaults.secondaryColors(),
onClick = state.onClose,
text = stringResource(id = R.string.request_qr_close_btn),
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp)
)
}
}
}
}
@Composable
private fun RequestContents(
state: RequestState.Prepared,
modifier: Modifier = Modifier,
) {
Column(modifier = modifier) {
when (state) {
is RequestState.Amount -> {
RequestAmountView(state = state)
}
is RequestState.Memo -> {
RequestMemoView(state = state)
}
is RequestState.QrCode -> {
RequestQrCodeView(state = state)
}
}
}
}
// TODO [#1635]: Learn AutoSizingText scale up
// TODO [#1635]: https://github.com/Electric-Coin-Company/zashi-android/issues/1635
@Composable
internal fun AutoSizingText(
text: AnnotatedString,
style: TextStyle,
modifier: Modifier = Modifier
) {
var fontSize by remember { mutableStateOf(style.fontSize) }
Text(
text = text,
fontSize = fontSize,
fontFamily = style.fontFamily,
lineHeight = style.lineHeight,
fontWeight = style.fontWeight,
maxLines = 1,
modifier = modifier,
onTextLayout = { textLayoutResult ->
if (textLayoutResult.didOverflowHeight) {
fontSize = (fontSize.value - 1).sp
} else {
// We should make the text bigger again
}
}
)
}

View File

@ -0,0 +1,450 @@
package co.electriccoin.zcash.ui.screen.request.viewmodel
import android.app.Application
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asAndroidBitmap
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import cash.z.ecc.android.sdk.model.FiatCurrencyConversion
import cash.z.ecc.android.sdk.model.WalletAddress
import cash.z.ecc.sdk.ANDROID_STATE_FLOW_TIMEOUT
import co.electriccoin.zcash.spackle.Twig
import co.electriccoin.zcash.ui.R
import co.electriccoin.zcash.ui.common.provider.GetMonetarySeparatorProvider
import co.electriccoin.zcash.ui.common.provider.GetZcashCurrencyProvider
import co.electriccoin.zcash.ui.common.usecase.GetAddressesUseCase
import co.electriccoin.zcash.ui.common.usecase.ShareImageUseCase
import co.electriccoin.zcash.ui.common.usecase.Zip321BuildUriUseCase
import co.electriccoin.zcash.ui.common.viewmodel.WalletViewModel
import co.electriccoin.zcash.ui.common.wallet.ExchangeRateState
import co.electriccoin.zcash.ui.screen.qrcode.ext.fromReceiveAddressType
import co.electriccoin.zcash.ui.screen.qrcode.util.AndroidQrCodeImageGenerator
import co.electriccoin.zcash.ui.screen.qrcode.util.JvmQrCodeGenerator
import co.electriccoin.zcash.ui.screen.receive.model.ReceiveAddressType
import co.electriccoin.zcash.ui.screen.request.ext.convertToDouble
import co.electriccoin.zcash.ui.screen.request.model.AmountState
import co.electriccoin.zcash.ui.screen.request.model.MemoState
import co.electriccoin.zcash.ui.screen.request.model.OnAmount
import co.electriccoin.zcash.ui.screen.request.model.QrCodeState
import co.electriccoin.zcash.ui.screen.request.model.Request
import co.electriccoin.zcash.ui.screen.request.model.RequestCurrency
import co.electriccoin.zcash.ui.screen.request.model.RequestStage
import co.electriccoin.zcash.ui.screen.request.model.RequestState
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.WhileSubscribed
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
@Suppress("TooManyFunctions")
class RequestViewModel(
private val addressTypeOrdinal: Int,
private val application: Application,
getAddresses: GetAddressesUseCase,
walletViewModel: WalletViewModel,
getZcashCurrency: GetZcashCurrencyProvider,
getMonetarySeparators: GetMonetarySeparatorProvider,
shareImageBitmap: ShareImageUseCase,
zip321BuildUriUseCase: Zip321BuildUriUseCase,
) : ViewModel() {
companion object {
private const val MAX_ZCASH_SUPPLY = 21_000_000
private const val DEFAULT_MEMO = ""
private const val DEFAULT_URI = ""
}
private val defaultAmount = application.getString(R.string.request_amount_empty)
internal val request =
MutableStateFlow(
Request(
amountState = AmountState.Default(defaultAmount, RequestCurrency.Zec),
memoState = MemoState.Valid(DEFAULT_MEMO, 0, defaultAmount),
qrCodeState = QrCodeState(DEFAULT_URI, defaultAmount, DEFAULT_MEMO, null),
)
)
private val stage = MutableStateFlow(RequestStage.AMOUNT)
internal val state =
combine(
getAddresses(),
request,
stage,
walletViewModel.exchangeRateUsd,
) { addresses, request, currentStage, exchangeRateUsd ->
val walletAddress = addresses.fromReceiveAddressType(ReceiveAddressType.fromOrdinal(addressTypeOrdinal))
when (currentStage) {
RequestStage.AMOUNT -> {
RequestState.Amount(
exchangeRateState = exchangeRateUsd,
monetarySeparators = getMonetarySeparators(),
onAmount = { onAmount(resolveExchangeRateValue(exchangeRateUsd), it) },
onBack = { onBack() },
onDone = {
when (walletAddress) {
is WalletAddress.Transparent -> {
onAmountAndMemoDone(
walletAddress.address,
zip321BuildUriUseCase,
resolveExchangeRateValue(exchangeRateUsd)
)
}
is WalletAddress.Unified, is WalletAddress.Sapling -> {
onAmountDone(resolveExchangeRateValue(exchangeRateUsd))
}
else -> error("Unexpected address type")
}
},
onSwitch = { onSwitch(resolveExchangeRateValue(exchangeRateUsd), it) },
request = request,
zcashCurrency = getZcashCurrency(),
)
}
RequestStage.MEMO -> {
RequestState.Memo(
walletAddress = walletAddress,
request = request,
onMemo = { onMemo(it) },
onDone = { onMemoDone(walletAddress.address, zip321BuildUriUseCase) },
onBack = ::onBack,
zcashCurrency = getZcashCurrency(),
)
}
RequestStage.QR_CODE -> {
RequestState.QrCode(
walletAddress = walletAddress,
request = request,
onQrCodeGenerate = { qrCodeForValue(request.qrCodeState.requestUri, it) },
onQrCodeShare = { onRequestQrCodeShare(it, shareImageBitmap) },
onBack = ::onBack,
onClose = ::onClose,
zcashCurrency = getZcashCurrency(),
)
}
}
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(ANDROID_STATE_FLOW_TIMEOUT),
initialValue = RequestState.Loading
)
val backNavigationCommand = MutableSharedFlow<Unit>()
val shareResultCommand = MutableSharedFlow<Boolean>()
private fun resolveExchangeRateValue(exchangeRateUsd: ExchangeRateState): FiatCurrencyConversion? {
return when (exchangeRateUsd) {
is ExchangeRateState.Data -> {
if (exchangeRateUsd.currencyConversion == null) {
Twig.warn { "Currency conversion is currently not available" }
null
} else {
exchangeRateUsd.currencyConversion
}
}
else -> {
// Should not happen as the conversion rate related use cases should not be available
Twig.error { "Unexpected screen state" }
null
}
}
}
private fun onAmount(
conversion: FiatCurrencyConversion?,
onAmount: OnAmount
) = viewModelScope.launch {
val newState =
when (onAmount) {
is OnAmount.Number -> {
if (request.value.amountState.amount == defaultAmount) {
// Special case with current value only zero
validateAmountState(conversion, onAmount.number.toString())
} else {
// Adding new number to the result string
validateAmountState(
conversion,
request.value.amountState.amount + onAmount.number
)
}
}
is OnAmount.Delete -> {
if (request.value.amountState.amount.length == 1) {
// Deleting up to the last character
AmountState.Default(defaultAmount, request.value.amountState.currency)
} else {
validateAmountState(conversion, request.value.amountState.amount.dropLast(1))
}
}
is OnAmount.Separator -> {
if (request.value.amountState.amount.contains(onAmount.separator)) {
// Separator already present
validateAmountState(conversion, request.value.amountState.amount)
} else {
validateAmountState(
conversion,
request.value.amountState.amount + onAmount.separator
)
}
}
}
request.emit(
request.value.copy(amountState = newState)
)
}
// Validates only zeros and decimal separator
private val defaultAmountValidationRegex = "^[${defaultAmount}${getMonetarySeparators().decimal}]*$".toRegex()
// Validates only numbers the properly use grouping and decimal separators
// Note that this regex aligns with the one from ZcashSDK (sdk-incubator-lib/src/main/res/values/strings-regex.xml)
// It only adds check for 0-8 digits after the decimal separator at maximum
@Suppress("MaxLineLength", "ktlint:standard:max-line-length")
private val allowedNumberFormatValidationRegex = "^([0-9]*([0-9]+([${getMonetarySeparators().grouping}]\$|[${getMonetarySeparators().grouping}][0-9]+))*([${getMonetarySeparators().decimal}]\$|[${getMonetarySeparators().decimal}][0-9]{0,8})?)?\$".toRegex()
private fun validateAmountState(
conversion: FiatCurrencyConversion?,
resultAmount: String,
): AmountState {
val newAmount =
if (resultAmount.contains(defaultAmountValidationRegex)) {
AmountState.Default(
// Check for the max decimals in the default (i.e. 0.000) number, too
if (!resultAmount.contains(allowedNumberFormatValidationRegex)) {
request.value.amountState.amount
} else {
resultAmount
},
request.value.amountState.currency
)
} else if (!resultAmount.contains(allowedNumberFormatValidationRegex)) {
AmountState.Valid(request.value.amountState.amount, request.value.amountState.currency)
} else {
AmountState.Valid(resultAmount, request.value.amountState.currency)
}
// Check for max Zcash supply
return newAmount.amount.convertToDouble()?.let { currentValue ->
val zecValue =
if (newAmount.currency == RequestCurrency.Fiat && conversion != null) {
currentValue / conversion.priceOfZec
} else {
currentValue
}
if (zecValue > MAX_ZCASH_SUPPLY) {
newAmount.copyState(request.value.amountState.amount)
} else {
newAmount
}
} ?: newAmount
}
internal fun onBack() =
viewModelScope.launch {
when (stage.value) {
RequestStage.AMOUNT -> {
backNavigationCommand.emit(Unit)
}
RequestStage.MEMO -> {
stage.emit(RequestStage.AMOUNT)
}
RequestStage.QR_CODE -> {
when (ReceiveAddressType.fromOrdinal(addressTypeOrdinal)) {
ReceiveAddressType.Transparent -> {
stage.emit(RequestStage.AMOUNT)
}
ReceiveAddressType.Unified, ReceiveAddressType.Sapling -> {
stage.emit(RequestStage.MEMO)
}
}
}
}
}
private fun onClose() =
viewModelScope.launch {
backNavigationCommand.emit(Unit)
}
private fun onAmountDone(conversion: FiatCurrencyConversion?) =
viewModelScope.launch {
val memoAmount =
when (request.value.amountState.currency) {
RequestCurrency.Fiat ->
if (conversion != null) {
request.value.amountState.toZecString(conversion)
} else {
Twig.error { "Unexpected screen state" }
request.value.amountState.amount
}
RequestCurrency.Zec -> request.value.amountState.amount
}
request.emit(request.value.copy(memoState = MemoState.new(DEFAULT_MEMO, memoAmount)))
stage.emit(RequestStage.MEMO)
}
private fun onMemoDone(
address: String,
zip321BuildUriUseCase: Zip321BuildUriUseCase
) = viewModelScope.launch {
request.emit(
request.value.copy(
qrCodeState =
QrCodeState(
requestUri =
createZip321Uri(
address = address,
amount = request.value.memoState.zecAmount,
memo = request.value.memoState.text,
zip321BuildUriUseCase = zip321BuildUriUseCase
),
zecAmount = request.value.memoState.zecAmount,
memo = request.value.memoState.text,
bitmap = null
)
)
)
stage.emit(RequestStage.QR_CODE)
}
private fun onAmountAndMemoDone(
address: String,
zip321BuildUriUseCase: Zip321BuildUriUseCase,
conversion: FiatCurrencyConversion?
) = viewModelScope.launch {
val qrCodeAmount =
when (request.value.amountState.currency) {
RequestCurrency.Fiat ->
if (conversion != null) {
request.value.amountState.toZecString(conversion)
} else {
Twig.error { "Unexpected screen state" }
request.value.amountState.amount
}
RequestCurrency.Zec -> request.value.amountState.amount
}
val newRequest =
request.value.copy(
qrCodeState =
QrCodeState(
requestUri =
createZip321Uri(
address = address,
amount = qrCodeAmount,
memo = DEFAULT_MEMO,
zip321BuildUriUseCase = zip321BuildUriUseCase
),
zecAmount = qrCodeAmount,
memo = DEFAULT_MEMO,
bitmap = null
)
)
request.emit(newRequest)
stage.emit(RequestStage.QR_CODE)
}
private fun onSwitch(
conversion: FiatCurrencyConversion?,
onSwitchTo: RequestCurrency
) = viewModelScope.launch {
if (conversion == null) {
return@launch
}
val newAmount =
when (onSwitchTo) {
is RequestCurrency.Fiat -> {
request.value.amountState.toFiatString(
application.applicationContext,
conversion
)
}
is RequestCurrency.Zec -> {
request.value.amountState.toZecString(
conversion
)
}
}
// Check default value and shrink it to 0 if necessary
val newState =
if (newAmount.contains(defaultAmountValidationRegex)) {
request.value.amountState.copyState(defaultAmount, onSwitchTo)
} else {
request.value.amountState.copyState(newAmount, onSwitchTo)
}
request.emit(
request.value.copy(amountState = newState)
)
}
private fun onMemo(memoState: MemoState) =
viewModelScope.launch {
request.emit(request.value.copy(memoState = memoState))
}
private fun createZip321Uri(
address: String,
amount: String,
memo: String,
zip321BuildUriUseCase: Zip321BuildUriUseCase,
): String {
val amountNumber = amount.convertToDouble()?.toBigDecimal()
return if (amountNumber == null) {
Twig.error { "Unexpected amount state" }
DEFAULT_URI
} else {
zip321BuildUriUseCase.invoke(
address = address,
amount = amountNumber,
memo = memo
)
}
}
private fun onRequestQrCodeShare(
bitmap: ImageBitmap,
shareImageBitmap: ShareImageUseCase,
) = viewModelScope.launch {
shareImageBitmap(
shareImageBitmap = bitmap.asAndroidBitmap(),
filePrefix = TEMP_FILE_NAME_PREFIX,
fileSuffix = TEMP_FILE_NAME_SUFFIX,
shareText = application.getString(R.string.request_qr_code_share_chooser_text),
sharePickerText = application.getString(R.string.request_qr_code_share_chooser_title),
).collect { shareResult ->
if (shareResult) {
Twig.info { "Sharing the request QR code was successful" }
shareResultCommand.emit(true)
} else {
Twig.info { "Sharing the request QR code failed" }
shareResultCommand.emit(false)
}
}
}
private fun qrCodeForValue(
value: String,
size: Int,
) = viewModelScope.launch {
// In the future, use actual/expect to switch QR code generator implementations for multiplatform
// Note that our implementation has an extra array copy to BooleanArray, which is a cross-platform
// representation. This should have minimal performance impact since the QR code is relatively
// small and we only generate QR codes infrequently.
val qrCodePixelArray = JvmQrCodeGenerator.generate(value, size)
val bitmap = AndroidQrCodeImageGenerator.generate(qrCodePixelArray, size)
val newQrCodeState = request.value.qrCodeState.copy(bitmap = bitmap)
request.emit(request.value.copy(qrCodeState = newQrCodeState))
}
}
private const val TEMP_FILE_NAME_PREFIX = "zip_321_request_qr_" // NON-NLS
private const val TEMP_FILE_NAME_SUFFIX = ".png" // NON-NLS

View File

@ -1,12 +1,9 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="about_title">About</string>
<string name="about_version_format" formatted="true">Zashi Version <xliff:g example="1" id="version">%s</xliff:g></string>
<string name="about_debug_menu_app_name">App name:
<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!\nZashi is a minimal-design, self-custody, ZEC-only
shielded wallet that keeps your transaction history and wallet balance private. Built by Zcashers, for Zcashers. Developed and maintained by Electric Coin Co., the inventor of Zcash, Zashi features a built-in user-feedback mechanism to enable more features, more quickly.</string>
<string name="about_debug_menu_app_name">App name:<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!\nZashi is a minimal-design, self-custody, ZEC-only shielded wallet that keeps your transaction history and wallet balance private. Built by Zcashers, for Zcashers. Developed and maintained by Electric Coin Co., the inventor of Zcash, Zashi features a built-in user-feedback mechanism to enable more features, more quickly.</string>
<string name="about_pp_part_1">See our Privacy Policy\u0020</string>
<string name="about_pp_part_2">here</string>

View File

@ -13,7 +13,7 @@
<string name="qr_code_copy_btn">Copy Address</string>
<string name="qr_code_clipboard_tag">Zcash Wallet Address</string>
<string name="qr_code_data_unable_to_share">Unable to find an application for sharing the QR code.</string>
<string name="qr_code_share_chooser_title">Share internal Zashi data with:</string>
<string name="qr_code_share_chooser_title">My Zashi ZEC Address</string>
<string name="qr_code_share_chooser_text">Hi, scan this QR code to send me a ZEC payment! Download Link:
https://play.google.com/store/apps/details?id=co.electriccoin.zcash</string>
</resources>

View File

@ -0,0 +1,16 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="40dp"
android:height="40dp"
android:viewportWidth="40"
android:viewportHeight="40">
<path
android:pathData="M0,12C0,5.373 5.373,0 12,0H28C34.627,0 40,5.373 40,12V28C40,34.627 34.627,40 28,40H12C5.373,40 0,34.627 0,28V12Z"
android:fillColor="#343031"/>
<path
android:pathData="M25,12V28M25,28L21,24M25,28L29,24M15,28V12M15,12L11,16M15,12L19,16"
android:strokeLineJoin="round"
android:strokeWidth="2"
android:fillColor="#00000000"
android:strokeColor="#D2D1D2"
android:strokeLineCap="round"/>
</vector>

View File

@ -0,0 +1,13 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="19dp"
android:height="20dp"
android:viewportWidth="19"
android:viewportHeight="20">
<path
android:pathData="M9.5,13.333V10M9.5,6.667H9.508M17.833,10C17.833,14.602 14.102,18.333 9.5,18.333C4.898,18.333 1.167,14.602 1.167,10C1.167,5.397 4.898,1.666 9.5,1.666C14.102,1.666 17.833,5.397 17.833,10Z"
android:strokeLineJoin="round"
android:strokeWidth="1.66667"
android:fillColor="#00000000"
android:strokeColor="#EF6820"
android:strokeLineCap="round"/>
</vector>

View File

@ -0,0 +1,13 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="41dp"
android:height="40dp"
android:viewportWidth="41"
android:viewportHeight="40">
<path
android:pathData="M28.833,15L18.833,25M18.833,15L28.833,25M5.033,21.6L12.233,31.2C12.82,31.982 13.113,32.373 13.485,32.655C13.814,32.905 14.187,33.091 14.585,33.205C15.033,33.333 15.522,33.333 16.5,33.333H29.167C31.967,33.333 33.367,33.333 34.437,32.788C35.377,32.309 36.142,31.544 36.622,30.603C37.167,29.534 37.167,28.133 37.167,25.333V14.667C37.167,11.866 37.167,10.466 36.622,9.397C36.142,8.456 35.377,7.691 34.437,7.211C33.367,6.667 31.967,6.667 29.167,6.667H16.5C15.522,6.667 15.033,6.667 14.585,6.795C14.187,6.908 13.814,7.095 13.485,7.344C13.113,7.627 12.82,8.018 12.233,8.8L5.033,18.4C4.603,18.974 4.388,19.26 4.305,19.576C4.232,19.854 4.232,20.146 4.305,20.424C4.388,20.739 4.603,21.026 5.033,21.6Z"
android:strokeLineJoin="round"
android:strokeWidth="3.33333"
android:fillColor="#00000000"
android:strokeColor="#4D4941"
android:strokeLineCap="round"/>
</vector>

View File

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="65dp"
android:height="64dp"
android:viewportWidth="65"
android:viewportHeight="64">
<path
android:pathData="M0.5,32C0.5,14.353 14.853,0 32.5,0C50.147,0 64.5,14.353 64.5,32C64.5,49.647 50.147,64 32.5,64C14.853,64 0.5,49.647 0.5,32ZM43.915,17.15V22.02L30.37,40.39H43.915V46.849H35.183V52.201H29.817V46.849H21.086V41.979L34.616,23.609H21.086V17.15H29.817V11.783H35.183V17.15H43.915Z"
android:fillColor="#231F20"
android:fillType="evenOdd"/>
</vector>

View File

@ -0,0 +1,16 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="40dp"
android:height="40dp"
android:viewportWidth="40"
android:viewportHeight="40">
<path
android:pathData="M0,12C0,5.373 5.373,0 12,0H28C34.627,0 40,5.373 40,12V28C40,34.627 34.627,40 28,40H12C5.373,40 0,34.627 0,28V12Z"
android:fillColor="#EBEBE6"/>
<path
android:pathData="M25,12V28M25,28L21,24M25,28L29,24M15,28V12M15,12L11,16M15,12L19,16"
android:strokeLineJoin="round"
android:strokeWidth="2"
android:fillColor="#00000000"
android:strokeColor="#4D4941"
android:strokeLineCap="round"/>
</vector>

View File

@ -0,0 +1,36 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="request_title">Request</string>
<string name="request_amount_invalid">This transaction amount is invalid</string>
<string name="request_amount_btn">Next</string>
<string name="request_memo_btn">Request</string>
<string name="request_qr_share_btn">Share QR Code</string>
<string name="request_qr_close_btn">Close</string>
<string name="request_privacy_level_shielded">Maximum Privacy</string>
<string name="request_privacy_level_transparent">Low Privacy</string>
<string name="request_amount_empty">0</string>
<string name="request_amount_keyboard_zero">0</string>
<string name="request_amount_keyboard_one">1</string>
<string name="request_amount_keyboard_two">2</string>
<string name="request_amount_keyboard_three">3</string>
<string name="request_amount_keyboard_four">4</string>
<string name="request_amount_keyboard_five">5</string>
<string name="request_amount_keyboard_six">6</string>
<string name="request_amount_keyboard_seven">7</string>
<string name="request_amount_keyboard_eight">8</string>
<string name="request_amount_keyboard_nine">9</string>
<string name="request_amount_keyboard_delete">Delete</string>
<string name="request_memo_payment_request_subtitle">Payment Request</string>
<string name="request_memo_text_field_hint">What\'s this for?</string>
<string name="request_memo_bytes_counter">
<xliff:g id="typed_bytes" example="12">%1$s</xliff:g>/<xliff:g id="max_bytes" example="512">%2$s</xliff:g>
</string>
<string name="request_qr_code_content_description">Request QR Code</string>
<string name="request_qr_code_data_unable_to_share">Unable to find an application for sharing the QR code.</string>
<string name="request_qr_code_share_chooser_title">Request ZEC</string>
<string name="request_qr_code_share_chooser_text">Hi, scan this QR code to send me a ZEC payment! Download Link:
https://play.google.com/store/apps/details?id=co.electriccoin.zcash</string>
</resources>