diff --git a/CHANGELOG.md b/CHANGELOG.md index 07b8f4cc..67622971 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/whatsNew/WHATS_NEW_EN.md b/docs/whatsNew/WHATS_NEW_EN.md index 2796d473..25998c3c 100644 --- a/docs/whatsNew/WHATS_NEW_EN.md +++ b/docs/whatsNew/WHATS_NEW_EN.md @@ -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 diff --git a/gradle.properties b/gradle.properties index 219f9f50..b3f4b7b5 100644 --- a/gradle.properties +++ b/gradle.properties @@ -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 diff --git a/sdk-ext-lib/build.gradle.kts b/sdk-ext-lib/build.gradle.kts index f152d92d..ee22ff5a 100644 --- a/sdk-ext-lib/build.gradle.kts +++ b/sdk-ext-lib/build.gradle.kts @@ -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) diff --git a/settings.gradle.kts b/settings.gradle.kts index 7a919d41..8820bd0a 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -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") diff --git a/ui-lib/build.gradle.kts b/ui-lib/build.gradle.kts index f4e24405..9f3e05c9 100644 --- a/ui-lib/build.gradle.kts +++ b/ui-lib/build.gradle.kts @@ -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", diff --git a/ui-lib/src/androidTest/java/co/electriccoin/zcash/ui/screen/send/ext/ZecSendExtTest.kt b/ui-lib/src/androidTest/java/co/electriccoin/zcash/ui/screen/send/ext/ZecSendExtTest.kt index 99a081f9..ce219adc 100644 --- a/ui-lib/src/androidTest/java/co/electriccoin/zcash/ui/screen/send/ext/ZecSendExtTest.kt +++ b/ui-lib/src/androidTest/java/co/electriccoin/zcash/ui/screen/send/ext/ZecSendExtTest.kt @@ -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() diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/di/ProviderModule.kt b/ui-lib/src/main/java/co/electriccoin/zcash/di/ProviderModule.kt index 26091b6c..085e7745 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/di/ProviderModule.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/di/ProviderModule.kt @@ -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 } diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/di/UseCaseModule.kt b/ui-lib/src/main/java/co/electriccoin/zcash/di/UseCaseModule.kt index 9bb75ebc..aa960d37 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/di/UseCaseModule.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/di/UseCaseModule.kt @@ -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) } diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/di/ViewModelModule.kt b/ui-lib/src/main/java/co/electriccoin/zcash/di/ViewModelModule.kt index 5d84793e..2c28b394 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/di/ViewModelModule.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/di/ViewModelModule.kt @@ -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) } diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/Navigation.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/Navigation.kt index ca461d52..27e838df 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/Navigation.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/Navigation.kt @@ -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" diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/provider/GetMonetarySeparatorProvider.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/provider/GetMonetarySeparatorProvider.kt new file mode 100644 index 00000000..e21ffdb5 --- /dev/null +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/provider/GetMonetarySeparatorProvider.kt @@ -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()) +} diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/ShareImageUseCase.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/ShareImageUseCase.kt new file mode 100644 index 00000000..87255feb --- /dev/null +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/ShareImageUseCase.kt @@ -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 = + 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() + } + } +} diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/Zip321BuildUriUseCase.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/Zip321BuildUriUseCase.kt new file mode 100644 index 00000000..6aa1a4c1 --- /dev/null +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/Zip321BuildUriUseCase.kt @@ -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 + } +} diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/receive/view/ReceiveView.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/receive/view/ReceiveView.kt index 1bf4812b..f40b7b41 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/receive/view/ReceiveView.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/receive/view/ReceiveView.kt @@ -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) ) } }, diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/receive/viewmodel/ReceiveViewModel.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/receive/viewmodel/ReceiveViewModel.kt index 184a68a8..09fea3a2 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/receive/viewmodel/ReceiveViewModel.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/receive/viewmodel/ReceiveViewModel.kt @@ -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() - @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 { diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/request/AndroidRequest.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/request/AndroidRequest.kt new file mode 100644 index 00000000..6a125a06 --- /dev/null +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/request/AndroidRequest.kt @@ -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() + val walletState by walletViewModel.walletStateInformation.collectAsStateWithLifecycle() + + val requestViewModel = koinViewModel { 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 + ) +} diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/request/ext/RequestStringExt.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/request/ext/RequestStringExt.kt new file mode 100644 index 00000000..885aa233 --- /dev/null +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/request/ext/RequestStringExt.kt @@ -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 + } +} diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/request/model/OnAmount.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/request/model/OnAmount.kt new file mode 100644 index 00000000..9ba676f2 --- /dev/null +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/request/model/OnAmount.kt @@ -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() +} diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/request/model/Request.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/request/model/Request.kt new file mode 100644 index 00000000..7c48277e --- /dev/null +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/request/model/Request.kt @@ -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 +} diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/request/model/RequestCurrency.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/request/model/RequestCurrency.kt new file mode 100644 index 00000000..4e3fe082 --- /dev/null +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/request/model/RequestCurrency.kt @@ -0,0 +1,7 @@ +package co.electriccoin.zcash.ui.screen.request.model + +sealed class RequestCurrency { + data object Zec : RequestCurrency() + + data object Fiat : RequestCurrency() +} diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/request/model/RequestStage.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/request/model/RequestStage.kt new file mode 100644 index 00000000..9fd32cfd --- /dev/null +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/request/model/RequestStage.kt @@ -0,0 +1,7 @@ +package co.electriccoin.zcash.ui.screen.request.model + +enum class RequestStage { + AMOUNT, + MEMO, + QR_CODE +} diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/request/model/RequestState.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/request/model/RequestState.kt new file mode 100644 index 00000000..a8b1c51d --- /dev/null +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/request/model/RequestState.kt @@ -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) +} diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/request/view/RequestAmountView.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/request/view/RequestAmountView.kt new file mode 100644 index 00000000..c68e30ea --- /dev/null +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/request/view/RequestAmountView.kt @@ -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() + ) + } + } +} diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/request/view/RequestMemoView.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/request/view/RequestMemoView.kt new file mode 100644 index 00000000..f3a81370 --- /dev/null +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/request/view/RequestMemoView.kt @@ -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() + } + } +} diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/request/view/RequestQrCodeView.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/request/view/RequestQrCodeView.kt new file mode 100644 index 00000000..02130d2c --- /dev/null +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/request/view/RequestQrCodeView.kt @@ -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 diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/request/view/RequestView.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/request/view/RequestView.kt new file mode 100644 index 00000000..b3e6889f --- /dev/null +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/request/view/RequestView.kt @@ -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 + } + } + ) +} diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/request/viewmodel/RequestViewModel.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/request/viewmodel/RequestViewModel.kt new file mode 100644 index 00000000..c77ebdff --- /dev/null +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/request/viewmodel/RequestViewModel.kt @@ -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() + + val shareResultCommand = MutableSharedFlow() + + 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 diff --git a/ui-lib/src/main/res/ui/about/values/strings.xml b/ui-lib/src/main/res/ui/about/values/strings.xml index d1abe04d..79c863f5 100644 --- a/ui-lib/src/main/res/ui/about/values/strings.xml +++ b/ui-lib/src/main/res/ui/about/values/strings.xml @@ -1,12 +1,9 @@ About Zashi Version %s - App name: - %1$s - Build: %1$s - 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. + App name:%1$s + Build: %1$s + 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. See our Privacy Policy\u0020 here diff --git a/ui-lib/src/main/res/ui/qr_code/drawable-night/ic_alert_circle.xml b/ui-lib/src/main/res/ui/common/drawable-night/ic_alert_circle.xml similarity index 100% rename from ui-lib/src/main/res/ui/qr_code/drawable-night/ic_alert_circle.xml rename to ui-lib/src/main/res/ui/common/drawable-night/ic_alert_circle.xml diff --git a/ui-lib/src/main/res/ui/qr_code/drawable-night/ic_solid_check.xml b/ui-lib/src/main/res/ui/common/drawable-night/ic_solid_check.xml similarity index 100% rename from ui-lib/src/main/res/ui/qr_code/drawable-night/ic_solid_check.xml rename to ui-lib/src/main/res/ui/common/drawable-night/ic_solid_check.xml diff --git a/ui-lib/src/main/res/ui/qr_code/drawable/ic_alert_circle.xml b/ui-lib/src/main/res/ui/common/drawable/ic_alert_circle.xml similarity index 100% rename from ui-lib/src/main/res/ui/qr_code/drawable/ic_alert_circle.xml rename to ui-lib/src/main/res/ui/common/drawable/ic_alert_circle.xml diff --git a/ui-lib/src/main/res/ui/qr_code/drawable/ic_solid_check.xml b/ui-lib/src/main/res/ui/common/drawable/ic_solid_check.xml similarity index 100% rename from ui-lib/src/main/res/ui/qr_code/drawable/ic_solid_check.xml rename to ui-lib/src/main/res/ui/common/drawable/ic_solid_check.xml diff --git a/ui-lib/src/main/res/ui/qr_code/values/strings.xml b/ui-lib/src/main/res/ui/qr_code/values/strings.xml index a9721617..f1b5bf5d 100644 --- a/ui-lib/src/main/res/ui/qr_code/values/strings.xml +++ b/ui-lib/src/main/res/ui/qr_code/values/strings.xml @@ -13,7 +13,7 @@ Copy Address Zcash Wallet Address Unable to find an application for sharing the QR code. - Share internal Zashi data with: + My Zashi ZEC Address Hi, scan this QR code to send me a ZEC payment! Download Link: https://play.google.com/store/apps/details?id=co.electriccoin.zcash diff --git a/ui-lib/src/main/res/ui/request/drawable-night/ic_switch.xml b/ui-lib/src/main/res/ui/request/drawable-night/ic_switch.xml new file mode 100644 index 00000000..3bca4d4e --- /dev/null +++ b/ui-lib/src/main/res/ui/request/drawable-night/ic_switch.xml @@ -0,0 +1,16 @@ + + + + diff --git a/ui-lib/src/main/res/ui/request/drawable/ic_alert_outline.xml b/ui-lib/src/main/res/ui/request/drawable/ic_alert_outline.xml new file mode 100644 index 00000000..55b529ed --- /dev/null +++ b/ui-lib/src/main/res/ui/request/drawable/ic_alert_outline.xml @@ -0,0 +1,13 @@ + + + diff --git a/ui-lib/src/main/res/ui/request/drawable/ic_delete.xml b/ui-lib/src/main/res/ui/request/drawable/ic_delete.xml new file mode 100644 index 00000000..6d9e904f --- /dev/null +++ b/ui-lib/src/main/res/ui/request/drawable/ic_delete.xml @@ -0,0 +1,13 @@ + + + diff --git a/ui-lib/src/main/res/ui/request/drawable/ic_logo_empty_z.xml b/ui-lib/src/main/res/ui/request/drawable/ic_logo_empty_z.xml new file mode 100644 index 00000000..1a546f40 --- /dev/null +++ b/ui-lib/src/main/res/ui/request/drawable/ic_logo_empty_z.xml @@ -0,0 +1,10 @@ + + + diff --git a/ui-lib/src/main/res/ui/request/drawable/ic_switch.xml b/ui-lib/src/main/res/ui/request/drawable/ic_switch.xml new file mode 100644 index 00000000..e0c28a1a --- /dev/null +++ b/ui-lib/src/main/res/ui/request/drawable/ic_switch.xml @@ -0,0 +1,16 @@ + + + + diff --git a/ui-lib/src/main/res/ui/request/values/strings.xml b/ui-lib/src/main/res/ui/request/values/strings.xml new file mode 100644 index 00000000..127b3e6e --- /dev/null +++ b/ui-lib/src/main/res/ui/request/values/strings.xml @@ -0,0 +1,36 @@ + + + Request + This transaction amount is invalid + Next + Request + Share QR Code + Close + Maximum Privacy + Low Privacy + + 0 + 0 + 1 + 2 + 3 + 4 + 5 + 6 + 7 + 8 + 9 + Delete + + Payment Request + What\'s this for? + + %1$s/%2$s + + + Request QR Code + Unable to find an application for sharing the QR code. + Request ZEC + Hi, scan this QR code to send me a ZEC payment! Download Link: + https://play.google.com/store/apps/details?id=co.electriccoin.zcash +