[#1595] Request ZEC flow (QR generation)
This commit is contained in:
parent
624bee88ef
commit
0e67d826d3
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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())
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
},
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
)
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
package co.electriccoin.zcash.ui.screen.request.model
|
||||
|
||||
sealed class RequestCurrency {
|
||||
data object Zec : RequestCurrency()
|
||||
|
||||
data object Fiat : RequestCurrency()
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
package co.electriccoin.zcash.ui.screen.request.model
|
||||
|
||||
enum class RequestStage {
|
||||
AMOUNT,
|
||||
MEMO,
|
||||
QR_CODE
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
|
@ -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
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
|
@ -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
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
Loading…
Reference in New Issue