* Payment Request screen logic update * Scan view model implementation * Zip321 Uri parsing and passing around screens * Pass PaymentRequestArguments * Fixed screens navigation * Screen balances UI part * Address UI part * Address UI part + logic * Memo UI part * Amounts UI part * Add stages and send logic with authentication * Send transaction error handling * Code analysis warnings fix * Tests update * Changelogs * [#1595] QR code design update * Address review comments --------- Co-authored-by: Milan Cerovsky <milan@z.cash>
This commit is contained in:
parent
711feb4251
commit
2129adaa8d
|
@ -15,6 +15,7 @@ and this application adheres to [Semantic Versioning](https://semver.org/spec/v2
|
|||
- Confirmation screen redesigned & added a contact name to the transaction if the contact is in address book
|
||||
- History item redesigned & added an option to create a contact from unknown address
|
||||
- Address Book, Create/Update/Delete Contact, Create Contact by QR screens added
|
||||
- The Scan QR code screen now supports scanning of ZIP 321 Uris
|
||||
|
||||
### Added
|
||||
- Address book local storage support
|
||||
|
|
|
@ -18,6 +18,7 @@ directly impact users rather than highlighting other key architectural updates.*
|
|||
- Confirmation screen redesigned & added a contact name to the transaction if the contact is in address book
|
||||
- History item redesigned & added an option to create a contact from unknown address
|
||||
- Address Book, Create/Update/Delete Contact, Create Contact by QR screens added
|
||||
- The Scan QR code screen now supports scanning of ZIP 321 Uris
|
||||
|
||||
### Added
|
||||
- New Integrations screen in settings
|
||||
|
|
|
@ -210,7 +210,7 @@ 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
|
||||
ZIP_321_VERSION = 0.0.3
|
||||
ZIP_321_VERSION = 0.0.6
|
||||
ZCASH_BIP39_VERSION=1.0.8
|
||||
|
||||
# WARNING: Ensure a non-snapshot version is used before releasing to production
|
||||
|
|
|
@ -83,7 +83,7 @@ fun ZashiButton(
|
|||
|
||||
@Composable
|
||||
override fun Loading() {
|
||||
if (enabled && isLoading) {
|
||||
if (isLoading) {
|
||||
LottieProgress(
|
||||
loadingRes =
|
||||
if (isSystemInDarkTheme()) {
|
||||
|
|
|
@ -9,7 +9,7 @@ import co.electriccoin.zcash.test.UiTestPrerequisites
|
|||
import co.electriccoin.zcash.ui.integration.test.common.IntegrationTestingActivity
|
||||
import co.electriccoin.zcash.ui.integration.test.common.getPermissionPositiveButtonUiObject
|
||||
import co.electriccoin.zcash.ui.screen.scan.ScanTag
|
||||
import co.electriccoin.zcash.ui.screen.scan.model.ScanState
|
||||
import co.electriccoin.zcash.ui.screen.scan.model.ScanScreenState
|
||||
import org.junit.Assert
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Before
|
||||
|
@ -36,15 +36,15 @@ class ScanViewIntegrationTest : UiTestPrerequisites() {
|
|||
testSetup.DefaultContent()
|
||||
}
|
||||
|
||||
assertEquals(testSetup.getScanState(), ScanState.Permission)
|
||||
assertEquals(testSetup.getScanState(), ScanScreenState.Permission)
|
||||
|
||||
testSetup.grantPermission()
|
||||
|
||||
assertEquals(testSetup.getScanState(), ScanState.Scanning)
|
||||
assertEquals(testSetup.getScanState(), ScanScreenState.Scanning)
|
||||
|
||||
restorationTester.emulateSavedInstanceStateRestore()
|
||||
|
||||
assertEquals(testSetup.getScanState(), ScanState.Scanning)
|
||||
assertEquals(testSetup.getScanState(), ScanScreenState.Scanning)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
|
@ -15,7 +15,7 @@ import co.electriccoin.zcash.ui.integration.test.common.getStringResource
|
|||
import co.electriccoin.zcash.ui.integration.test.common.getStringResourceWithArgs
|
||||
import co.electriccoin.zcash.ui.integration.test.common.waitForDeviceIdle
|
||||
import co.electriccoin.zcash.ui.screen.scan.ScanTag
|
||||
import co.electriccoin.zcash.ui.screen.scan.model.ScanState
|
||||
import co.electriccoin.zcash.ui.screen.scan.model.ScanScreenState
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertTrue
|
||||
|
@ -62,7 +62,7 @@ class ScanViewTest : UiTestPrerequisites() {
|
|||
@Test
|
||||
@LargeTest
|
||||
fun grant_camera_permission() {
|
||||
assertEquals(ScanState.Permission, testSetup.getScanState())
|
||||
assertEquals(ScanScreenState.Permission, testSetup.getScanState())
|
||||
|
||||
composeTestRule.onNodeWithTag(ScanTag.CAMERA_VIEW).also {
|
||||
it.assertDoesNotExist()
|
||||
|
@ -78,7 +78,7 @@ class ScanViewTest : UiTestPrerequisites() {
|
|||
it.assertIsDisplayed()
|
||||
}
|
||||
|
||||
assertEquals(ScanState.Scanning, testSetup.getScanState())
|
||||
assertEquals(ScanScreenState.Scanning, testSetup.getScanState())
|
||||
|
||||
// we need to actively wait for the camera preview initialization
|
||||
waitForDeviceIdle(timeout = 5000.milliseconds)
|
||||
|
@ -91,11 +91,11 @@ class ScanViewTest : UiTestPrerequisites() {
|
|||
@Test
|
||||
@LargeTest
|
||||
fun deny_camera_permission() {
|
||||
assertEquals(ScanState.Permission, testSetup.getScanState())
|
||||
assertEquals(ScanScreenState.Permission, testSetup.getScanState())
|
||||
|
||||
testSetup.denyPermission()
|
||||
|
||||
assertEquals(ScanState.Permission, testSetup.getScanState())
|
||||
assertEquals(ScanScreenState.Permission, testSetup.getScanState())
|
||||
|
||||
composeTestRule.onNodeWithTag(ScanTag.CAMERA_VIEW).also {
|
||||
it.assertDoesNotExist()
|
||||
|
|
|
@ -3,12 +3,12 @@ package co.electriccoin.zcash.ui.integration.test.screen.scan.view
|
|||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.test.junit4.ComposeContentTestRule
|
||||
import cash.z.ecc.android.sdk.type.AddressType
|
||||
import co.electriccoin.zcash.ui.common.model.TopAppBarSubTitleState
|
||||
import co.electriccoin.zcash.ui.design.theme.ZcashTheme
|
||||
import co.electriccoin.zcash.ui.integration.test.common.getPermissionNegativeButtonUiObject
|
||||
import co.electriccoin.zcash.ui.integration.test.common.getPermissionPositiveButtonUiObject
|
||||
import co.electriccoin.zcash.ui.screen.scan.model.ScanState
|
||||
import co.electriccoin.zcash.ui.screen.scan.model.ScanScreenState
|
||||
import co.electriccoin.zcash.ui.screen.scan.model.ScanValidationState
|
||||
import co.electriccoin.zcash.ui.screen.scan.view.Scan
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertTrue
|
||||
|
@ -19,14 +19,14 @@ class ScanViewTestSetup(
|
|||
private val composeTestRule: ComposeContentTestRule
|
||||
) {
|
||||
private val onOpenSettingsCount = AtomicInteger(0)
|
||||
private val scanState = AtomicReference(ScanState.Permission)
|
||||
private val scanState = AtomicReference(ScanScreenState.Permission)
|
||||
|
||||
fun getOnOpenSettingsCount(): Int {
|
||||
composeTestRule.waitForIdle()
|
||||
return onOpenSettingsCount.get()
|
||||
}
|
||||
|
||||
fun getScanState(): ScanState {
|
||||
fun getScanState(): ScanScreenState {
|
||||
composeTestRule.waitForIdle()
|
||||
return scanState.get()
|
||||
}
|
||||
|
@ -61,7 +61,7 @@ class ScanViewTestSetup(
|
|||
scanState.set(it)
|
||||
},
|
||||
topAppBarSubTitleState = TopAppBarSubTitleState.None,
|
||||
addressValidationResult = AddressType.Unified
|
||||
validationResult = ScanValidationState.VALID
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -47,6 +47,7 @@ android {
|
|||
"src/main/res/ui/integrations",
|
||||
"src/main/res/ui/new_wallet_recovery",
|
||||
"src/main/res/ui/onboarding",
|
||||
"src/main/res/ui/payment_request",
|
||||
"src/main/res/ui/qr_code",
|
||||
"src/main/res/ui/request",
|
||||
"src/main/res/ui/receive",
|
||||
|
|
|
@ -12,7 +12,7 @@ import androidx.test.rule.GrantPermissionRule
|
|||
import co.electriccoin.zcash.test.UiTestPrerequisites
|
||||
import co.electriccoin.zcash.ui.R
|
||||
import co.electriccoin.zcash.ui.screen.scan.ScanTag
|
||||
import co.electriccoin.zcash.ui.screen.scan.model.ScanState
|
||||
import co.electriccoin.zcash.ui.screen.scan.model.ScanScreenState
|
||||
import co.electriccoin.zcash.ui.test.getStringResource
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Rule
|
||||
|
@ -80,7 +80,7 @@ class ScanViewBasicTest : UiTestPrerequisites() {
|
|||
fun scan_state() {
|
||||
val testSetup = newTestSetup()
|
||||
|
||||
assertEquals(ScanState.Scanning, testSetup.getScanState())
|
||||
assertEquals(ScanScreenState.Scanning, testSetup.getScanState())
|
||||
}
|
||||
|
||||
private fun newTestSetup() =
|
||||
|
|
|
@ -3,10 +3,10 @@ package co.electriccoin.zcash.ui.screen.scan.view
|
|||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.test.junit4.ComposeContentTestRule
|
||||
import cash.z.ecc.android.sdk.type.AddressType
|
||||
import co.electriccoin.zcash.ui.common.model.TopAppBarSubTitleState
|
||||
import co.electriccoin.zcash.ui.design.theme.ZcashTheme
|
||||
import co.electriccoin.zcash.ui.screen.scan.model.ScanState
|
||||
import co.electriccoin.zcash.ui.screen.scan.model.ScanScreenState
|
||||
import co.electriccoin.zcash.ui.screen.scan.model.ScanValidationState
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
|
||||
|
@ -14,14 +14,14 @@ class ScanViewBasicTestSetup(
|
|||
private val composeTestRule: ComposeContentTestRule
|
||||
) {
|
||||
private val onBackCount = AtomicInteger(0)
|
||||
private val scanState = AtomicReference(ScanState.Permission)
|
||||
private val scanState = AtomicReference(ScanScreenState.Permission)
|
||||
|
||||
fun getOnBackCount(): Int {
|
||||
composeTestRule.waitForIdle()
|
||||
return onBackCount.get()
|
||||
}
|
||||
|
||||
fun getScanState(): ScanState {
|
||||
fun getScanState(): ScanScreenState {
|
||||
composeTestRule.waitForIdle()
|
||||
return scanState.get()
|
||||
}
|
||||
|
@ -30,7 +30,7 @@ class ScanViewBasicTestSetup(
|
|||
@Suppress("TestFunctionName")
|
||||
fun DefaultContent() {
|
||||
Scan(
|
||||
addressValidationResult = AddressType.Shielded,
|
||||
validationResult = ScanValidationState.VALID,
|
||||
onBack = {
|
||||
onBackCount.incrementAndGet()
|
||||
},
|
||||
|
|
|
@ -39,6 +39,7 @@ class SendViewIntegrationTest {
|
|||
goBack = {},
|
||||
goBalances = {},
|
||||
goSettings = {},
|
||||
goPaymentRequest = { _, _ -> },
|
||||
goSendConfirmation = {},
|
||||
)
|
||||
}
|
||||
|
|
|
@ -29,6 +29,8 @@ 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 co.electriccoin.zcash.ui.common.usecase.Zip321ParseUriValidationUseCase
|
||||
import co.electriccoin.zcash.ui.common.usecase.Zip321ProposalFromUriUseCase
|
||||
import org.koin.core.module.dsl.singleOf
|
||||
import org.koin.dsl.module
|
||||
|
||||
|
@ -58,9 +60,11 @@ val useCaseModule =
|
|||
singleOf(::ObserveContactPickedUseCase)
|
||||
singleOf(::GetAddressesUseCase)
|
||||
singleOf(::CopyToClipboardUseCase)
|
||||
singleOf(::ShareImageUseCase)
|
||||
singleOf(::Zip321BuildUriUseCase)
|
||||
singleOf(::Zip321ProposalFromUriUseCase)
|
||||
singleOf(::Zip321ParseUriValidationUseCase)
|
||||
singleOf(::ObserveWalletStateUseCase)
|
||||
singleOf(::IsCoinbaseAvailableUseCase)
|
||||
singleOf(::GetSpendingKeyUseCase)
|
||||
singleOf(::ShareImageUseCase)
|
||||
singleOf(::Zip321BuildUriUseCase)
|
||||
}
|
||||
|
|
|
@ -13,11 +13,14 @@ import co.electriccoin.zcash.ui.screen.contact.viewmodel.AddContactViewModel
|
|||
import co.electriccoin.zcash.ui.screen.contact.viewmodel.UpdateContactViewModel
|
||||
import co.electriccoin.zcash.ui.screen.integrations.viewmodel.IntegrationsViewModel
|
||||
import co.electriccoin.zcash.ui.screen.onboarding.viewmodel.OnboardingViewModel
|
||||
import co.electriccoin.zcash.ui.screen.paymentrequest.viewmodel.PaymentRequestViewModel
|
||||
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.scan.ScanNavigationArgs
|
||||
import co.electriccoin.zcash.ui.screen.scan.viewmodel.ScanViewModel
|
||||
import co.electriccoin.zcash.ui.screen.sendconfirmation.viewmodel.CreateTransactionsViewModel
|
||||
import co.electriccoin.zcash.ui.screen.settings.viewmodel.ScreenBrightnessViewModel
|
||||
import co.electriccoin.zcash.ui.screen.settings.viewmodel.SettingsViewModel
|
||||
|
@ -67,6 +70,14 @@ val viewModelModule =
|
|||
viewModelOf(::UpdateContactViewModel)
|
||||
viewModelOf(::ReceiveViewModel)
|
||||
viewModelOf(::QrCodeViewModel)
|
||||
viewModelOf(::IntegrationsViewModel)
|
||||
viewModelOf(::RequestViewModel)
|
||||
viewModelOf(::PaymentRequestViewModel)
|
||||
viewModelOf(::IntegrationsViewModel)
|
||||
viewModel { (args: ScanNavigationArgs) ->
|
||||
ScanViewModel(
|
||||
args = args,
|
||||
getSynchronizer = get(),
|
||||
zip321ParseUriValidationUseCase = get(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -22,12 +22,18 @@ import co.electriccoin.zcash.spackle.Twig
|
|||
import co.electriccoin.zcash.spackle.getSerializableCompat
|
||||
import co.electriccoin.zcash.ui.NavigationArgs.ADDRESS_TYPE
|
||||
import co.electriccoin.zcash.ui.NavigationArguments.MULTIPLE_SUBMISSION_CLEAR_FORM
|
||||
import co.electriccoin.zcash.ui.NavigationArguments.PAYMENT_REQUEST_ADDRESS
|
||||
import co.electriccoin.zcash.ui.NavigationArguments.PAYMENT_REQUEST_AMOUNT
|
||||
import co.electriccoin.zcash.ui.NavigationArguments.PAYMENT_REQUEST_MEMO
|
||||
import co.electriccoin.zcash.ui.NavigationArguments.PAYMENT_REQUEST_PROPOSAL
|
||||
import co.electriccoin.zcash.ui.NavigationArguments.PAYMENT_REQUEST_URI
|
||||
import co.electriccoin.zcash.ui.NavigationArguments.SEND_CONFIRM_AMOUNT
|
||||
import co.electriccoin.zcash.ui.NavigationArguments.SEND_CONFIRM_INITIAL_STAGE
|
||||
import co.electriccoin.zcash.ui.NavigationArguments.SEND_CONFIRM_MEMO
|
||||
import co.electriccoin.zcash.ui.NavigationArguments.SEND_CONFIRM_PROPOSAL
|
||||
import co.electriccoin.zcash.ui.NavigationArguments.SEND_CONFIRM_RECIPIENT_ADDRESS
|
||||
import co.electriccoin.zcash.ui.NavigationArguments.SEND_SCAN_RECIPIENT_ADDRESS
|
||||
import co.electriccoin.zcash.ui.NavigationArguments.SEND_SCAN_ZIP_321_URI
|
||||
import co.electriccoin.zcash.ui.NavigationTargets.ABOUT
|
||||
import co.electriccoin.zcash.ui.NavigationTargets.ADVANCED_SETTINGS
|
||||
import co.electriccoin.zcash.ui.NavigationTargets.CHOOSE_SERVER
|
||||
|
@ -37,6 +43,7 @@ import co.electriccoin.zcash.ui.NavigationTargets.EXPORT_PRIVATE_DATA
|
|||
import co.electriccoin.zcash.ui.NavigationTargets.HOME
|
||||
import co.electriccoin.zcash.ui.NavigationTargets.INTEGRATIONS
|
||||
import co.electriccoin.zcash.ui.NavigationTargets.NOT_ENOUGH_SPACE
|
||||
import co.electriccoin.zcash.ui.NavigationTargets.PAYMENT_REQUEST
|
||||
import co.electriccoin.zcash.ui.NavigationTargets.QR_CODE
|
||||
import co.electriccoin.zcash.ui.NavigationTargets.REQUEST
|
||||
import co.electriccoin.zcash.ui.NavigationTargets.SEED_RECOVERY
|
||||
|
@ -71,6 +78,8 @@ import co.electriccoin.zcash.ui.screen.exchangerate.settings.AndroidSettingsExch
|
|||
import co.electriccoin.zcash.ui.screen.exportdata.WrapExportPrivateData
|
||||
import co.electriccoin.zcash.ui.screen.home.WrapHome
|
||||
import co.electriccoin.zcash.ui.screen.integrations.WrapIntegrations
|
||||
import co.electriccoin.zcash.ui.screen.paymentrequest.WrapPaymentRequest
|
||||
import co.electriccoin.zcash.ui.screen.paymentrequest.model.PaymentRequestArguments
|
||||
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
|
||||
|
@ -250,28 +259,7 @@ internal fun MainActivity.Navigation() {
|
|||
backStackEntry.arguments
|
||||
?.getSerializableCompat<ScanNavigationArgs>(ScanNavigationArgs.KEY) ?: ScanNavigationArgs.DEFAULT
|
||||
|
||||
WrapScanValidator(
|
||||
onScanValid = { scanResult ->
|
||||
when (mode) {
|
||||
ScanNavigationArgs.DEFAULT -> {
|
||||
navController.previousBackStackEntry?.savedStateHandle?.apply {
|
||||
set(
|
||||
SEND_SCAN_RECIPIENT_ADDRESS,
|
||||
Json.encodeToString(SerializableAddress.serializer(), scanResult)
|
||||
)
|
||||
}
|
||||
navController.popBackStackJustOnce(ScanNavigationArgs.ROUTE)
|
||||
}
|
||||
|
||||
ScanNavigationArgs.ADDRESS_BOOK -> {
|
||||
val address = scanResult.address
|
||||
navController.popBackStack()
|
||||
navController.navigate(AddContactArgs(address))
|
||||
}
|
||||
}
|
||||
},
|
||||
goBack = { navController.popBackStackJustOnce(ScanNavigationArgs.ROUTE) }
|
||||
)
|
||||
WrapScanValidator(args = mode)
|
||||
}
|
||||
composable(EXPORT_PRIVATE_DATA) {
|
||||
WrapExportPrivateData(
|
||||
|
@ -357,6 +345,13 @@ internal fun MainActivity.Navigation() {
|
|||
val addressType = backStackEntry.arguments?.getInt(ADDRESS_TYPE) ?: ReceiveAddressType.Unified.ordinal
|
||||
WrapRequest(addressType)
|
||||
}
|
||||
composable(PAYMENT_REQUEST) {
|
||||
navController.previousBackStackEntry?.let { backStackEntry ->
|
||||
WrapPaymentRequest(
|
||||
arguments = PaymentRequestArguments.fromSavedStateHandle(backStackEntry.savedStateHandle)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -376,6 +371,12 @@ private fun MainActivity.NavigationHome(
|
|||
}
|
||||
navController.navigateJustOnce(SEND_CONFIRMATION)
|
||||
},
|
||||
goPaymentRequest = { zecSend, zip321Uri ->
|
||||
navController.currentBackStackEntry?.savedStateHandle?.let { handle ->
|
||||
fillInHandleForPaymentRequest(handle, zecSend, zip321Uri)
|
||||
}
|
||||
navController.navigateJustOnce(PAYMENT_REQUEST)
|
||||
},
|
||||
goSettings = { navController.navigateJustOnce(SETTINGS) },
|
||||
goMultiTrxSubmissionFailure = {
|
||||
// Ultimately we could approach reworking the MultipleTrxFailure screen into a separate
|
||||
|
@ -391,11 +392,13 @@ private fun MainActivity.NavigationHome(
|
|||
backStack.savedStateHandle.get<String>(SEND_SCAN_RECIPIENT_ADDRESS)?.let {
|
||||
Json.decodeFromString<SerializableAddress>(it).toRecipient()
|
||||
},
|
||||
zip321Uri = backStack.savedStateHandle.get<String>(SEND_SCAN_ZIP_321_URI),
|
||||
clearForm = backStack.savedStateHandle.get<Boolean>(MULTIPLE_SUBMISSION_CLEAR_FORM) ?: false
|
||||
).also {
|
||||
// Remove Send screen arguments passed from the Scan or MultipleSubmissionFailure screens if
|
||||
// some exist after we use them
|
||||
backStack.savedStateHandle.remove<String>(SEND_SCAN_RECIPIENT_ADDRESS)
|
||||
backStack.savedStateHandle.remove<String>(SEND_SCAN_ZIP_321_URI)
|
||||
backStack.savedStateHandle.remove<Boolean>(MULTIPLE_SUBMISSION_CLEAR_FORM)
|
||||
},
|
||||
)
|
||||
|
@ -487,6 +490,22 @@ private fun fillInHandleForConfirmation(
|
|||
handle[SEND_CONFIRM_INITIAL_STAGE] = initialStage.toStringName()
|
||||
}
|
||||
|
||||
private fun fillInHandleForPaymentRequest(
|
||||
handle: SavedStateHandle,
|
||||
zecSend: ZecSend,
|
||||
zip321: String
|
||||
) {
|
||||
handle[PAYMENT_REQUEST_ADDRESS] =
|
||||
Json.encodeToString(
|
||||
serializer = SerializableAddress.serializer(),
|
||||
value = zecSend.destination.toSerializableAddress()
|
||||
)
|
||||
handle[PAYMENT_REQUEST_AMOUNT] = zecSend.amount.value
|
||||
handle[PAYMENT_REQUEST_MEMO] = zecSend.memo.value
|
||||
handle[PAYMENT_REQUEST_PROPOSAL] = zecSend.proposal?.toByteArray() ?: byteArrayOf()
|
||||
handle[PAYMENT_REQUEST_URI] = zip321
|
||||
}
|
||||
|
||||
private fun NavHostController.navigateJustOnce(
|
||||
route: String,
|
||||
navOptionsBuilder: (NavOptionsBuilder.() -> Unit)? = null
|
||||
|
@ -508,7 +527,7 @@ private fun NavHostController.navigateJustOnce(
|
|||
*
|
||||
* @param currentRouteToBePopped current screen which should be popped up.
|
||||
*/
|
||||
private fun NavHostController.popBackStackJustOnce(currentRouteToBePopped: String) {
|
||||
fun NavHostController.popBackStackJustOnce(currentRouteToBePopped: String) {
|
||||
if (currentDestination?.route != currentRouteToBePopped) {
|
||||
return
|
||||
}
|
||||
|
@ -517,6 +536,7 @@ private fun NavHostController.popBackStackJustOnce(currentRouteToBePopped: Strin
|
|||
|
||||
object NavigationArguments {
|
||||
const val SEND_SCAN_RECIPIENT_ADDRESS = "send_scan_recipient_address"
|
||||
const val SEND_SCAN_ZIP_321_URI = "send_scan_zip_321_uri"
|
||||
|
||||
const val SEND_CONFIRM_RECIPIENT_ADDRESS = "send_confirm_recipient_address"
|
||||
const val SEND_CONFIRM_AMOUNT = "send_confirm_amount"
|
||||
|
@ -525,6 +545,12 @@ object NavigationArguments {
|
|||
const val SEND_CONFIRM_INITIAL_STAGE = "send_confirm_initial_stage"
|
||||
|
||||
const val MULTIPLE_SUBMISSION_CLEAR_FORM = "multiple_submission_clear_form"
|
||||
|
||||
const val PAYMENT_REQUEST_ADDRESS = "payment_request_address"
|
||||
const val PAYMENT_REQUEST_AMOUNT = "payment_request_amount"
|
||||
const val PAYMENT_REQUEST_MEMO = "payment_request_memo"
|
||||
const val PAYMENT_REQUEST_PROPOSAL = "payment_request_proposal"
|
||||
const val PAYMENT_REQUEST_URI = "payment_request_uri"
|
||||
}
|
||||
|
||||
object NavigationTargets {
|
||||
|
@ -535,7 +561,9 @@ object NavigationTargets {
|
|||
const val EXPORT_PRIVATE_DATA = "export_private_data"
|
||||
const val HOME = "home"
|
||||
const val CHOOSE_SERVER = "choose_server"
|
||||
const val INTEGRATIONS = "integrations"
|
||||
const val NOT_ENOUGH_SPACE = "not_enough_space"
|
||||
const val PAYMENT_REQUEST = "payment_request"
|
||||
const val QR_CODE = "qr_code"
|
||||
const val REQUEST = "request"
|
||||
const val SEED_RECOVERY = "seed_recovery"
|
||||
|
@ -544,7 +572,6 @@ object NavigationTargets {
|
|||
const val SETTINGS_EXCHANGE_RATE_OPT_IN = "settings_exchange_rate_opt_in"
|
||||
const val SUPPORT = "support"
|
||||
const val WHATS_NEW = "whats_new"
|
||||
const val INTEGRATIONS = "integrations"
|
||||
}
|
||||
|
||||
object NavigationArgs {
|
||||
|
|
|
@ -42,15 +42,13 @@ class Zip321BuildUriUseCase {
|
|||
|
||||
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" }
|
||||
Twig.debug { "Request Zip321 uri: $zip321Uri" }
|
||||
|
||||
return zip321Uri
|
||||
}
|
||||
|
|
|
@ -0,0 +1,55 @@
|
|||
package co.electriccoin.zcash.ui.common.usecase
|
||||
|
||||
import cash.z.ecc.android.sdk.type.AddressType
|
||||
import co.electriccoin.zcash.spackle.Twig
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.zecdev.zip321.ZIP321
|
||||
|
||||
internal class Zip321ParseUriValidationUseCase(
|
||||
private val getSynchronizerUseCase: GetSynchronizerUseCase
|
||||
) {
|
||||
operator fun invoke(zip321Uri: String) = validateZip321Uri(zip321Uri)
|
||||
|
||||
private fun validateZip321Uri(zip321Uri: String): Zip321ParseUriValidation {
|
||||
val paymentRequest =
|
||||
runCatching {
|
||||
ZIP321.request(
|
||||
uriString = zip321Uri,
|
||||
validatingRecipients = { address ->
|
||||
// We should be fine with the blocking implementation here
|
||||
runBlocking {
|
||||
getSynchronizerUseCase().validateAddress(address).let { validation ->
|
||||
when (validation) {
|
||||
is AddressType.Invalid -> {
|
||||
Twig.error { "Address from Zip321 validation failed: ${validation.reason}" }
|
||||
false
|
||||
}
|
||||
else -> {
|
||||
validation is AddressType.Valid
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}.onFailure {
|
||||
Twig.error(it) { "Failed to validate address" }
|
||||
}.getOrElse {
|
||||
false
|
||||
}
|
||||
|
||||
Twig.info { "Payment Request Zip321 validation result: $paymentRequest." }
|
||||
|
||||
return when (paymentRequest) {
|
||||
is ZIP321.ParserResult.Request -> Zip321ParseUriValidation.Valid(zip321Uri)
|
||||
// null or [ZIP321.ParserResult.SingleAddress] is not valid for our ZIP 321 Uri to Proposal use case
|
||||
else -> Zip321ParseUriValidation.Invalid
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class Zip321ParseUriValidation {
|
||||
data class Valid(val zip321Uri: String) : Zip321ParseUriValidation()
|
||||
|
||||
data object Invalid : Zip321ParseUriValidation()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,19 @@
|
|||
package co.electriccoin.zcash.ui.common.usecase
|
||||
|
||||
import cash.z.ecc.android.sdk.model.Account
|
||||
import cash.z.ecc.android.sdk.model.Proposal
|
||||
import co.electriccoin.zcash.spackle.Twig
|
||||
|
||||
class Zip321ProposalFromUriUseCase(
|
||||
private val getSynchronizerUseCase: GetSynchronizerUseCase
|
||||
) {
|
||||
suspend operator fun invoke(zip321Uri: String) = getProposal(zip321Uri)
|
||||
|
||||
private suspend fun getProposal(zip321Uri: String): Proposal {
|
||||
val proposal = getSynchronizerUseCase.invoke().proposeFulfillingPaymentUri(Account.DEFAULT, zip321Uri)
|
||||
|
||||
Twig.info { "Request Zip321 proposal: $proposal" }
|
||||
|
||||
return proposal
|
||||
}
|
||||
}
|
|
@ -45,6 +45,7 @@ internal fun WrapHome(
|
|||
goMultiTrxSubmissionFailure: () -> Unit,
|
||||
goScan: () -> Unit,
|
||||
goSendConfirmation: (ZecSend) -> Unit,
|
||||
goPaymentRequest: (ZecSend, String) -> Unit,
|
||||
sendArguments: SendArguments
|
||||
) {
|
||||
val homeViewModel = koinActivityViewModel<HomeViewModel>()
|
||||
|
@ -87,6 +88,7 @@ internal fun WrapHome(
|
|||
WrapHome(
|
||||
goScan = goScan,
|
||||
goSendConfirmation = goSendConfirmation,
|
||||
goPaymentRequest = goPaymentRequest,
|
||||
goSettings = goSettings,
|
||||
goMultiTrxSubmissionFailure = goMultiTrxSubmissionFailure,
|
||||
isKeepScreenOnWhileSyncing = isKeepScreenOnWhileSyncing,
|
||||
|
@ -105,6 +107,7 @@ internal fun WrapHome(
|
|||
goMultiTrxSubmissionFailure: () -> Unit,
|
||||
goScan: () -> Unit,
|
||||
goSendConfirmation: (ZecSend) -> Unit,
|
||||
goPaymentRequest: (ZecSend, String) -> Unit,
|
||||
isKeepScreenOnWhileSyncing: Boolean?,
|
||||
isShowingRestoreSuccess: Boolean,
|
||||
sendArguments: SendArguments,
|
||||
|
@ -182,6 +185,7 @@ internal fun WrapHome(
|
|||
}
|
||||
},
|
||||
goSendConfirmation = goSendConfirmation,
|
||||
goPaymentRequest = goPaymentRequest,
|
||||
goSettings = goSettings,
|
||||
sendArguments = sendArguments
|
||||
)
|
||||
|
|
|
@ -0,0 +1,130 @@
|
|||
@file:Suppress("ktlint:standard:filename")
|
||||
|
||||
package co.electriccoin.zcash.ui.screen.paymentrequest
|
||||
|
||||
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.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import cash.z.ecc.android.sdk.model.Proposal
|
||||
import co.electriccoin.zcash.di.koinActivityViewModel
|
||||
import co.electriccoin.zcash.ui.MainActivity
|
||||
import co.electriccoin.zcash.ui.NavigationTargets
|
||||
import co.electriccoin.zcash.ui.NavigationTargets.HOME
|
||||
import co.electriccoin.zcash.ui.R
|
||||
import co.electriccoin.zcash.ui.common.compose.LocalActivity
|
||||
import co.electriccoin.zcash.ui.common.compose.LocalNavController
|
||||
import co.electriccoin.zcash.ui.common.viewmodel.WalletViewModel
|
||||
import co.electriccoin.zcash.ui.screen.authentication.AuthenticationUseCase
|
||||
import co.electriccoin.zcash.ui.screen.authentication.WrapAuthentication
|
||||
import co.electriccoin.zcash.ui.screen.contact.AddContactArgs
|
||||
import co.electriccoin.zcash.ui.screen.paymentrequest.model.PaymentRequestArguments
|
||||
import co.electriccoin.zcash.ui.screen.paymentrequest.model.PaymentRequestStage
|
||||
import co.electriccoin.zcash.ui.screen.paymentrequest.model.PaymentRequestState
|
||||
import co.electriccoin.zcash.ui.screen.paymentrequest.view.PaymentRequestView
|
||||
import co.electriccoin.zcash.ui.screen.paymentrequest.viewmodel.PaymentRequestViewModel
|
||||
import org.koin.androidx.compose.koinViewModel
|
||||
import org.koin.core.parameter.parametersOf
|
||||
|
||||
@Composable
|
||||
internal fun WrapPaymentRequest(arguments: PaymentRequestArguments) {
|
||||
val activity = LocalActivity.current as MainActivity
|
||||
val navController = LocalNavController.current
|
||||
|
||||
val walletViewModel = koinActivityViewModel<WalletViewModel>()
|
||||
val walletState by walletViewModel.walletStateInformation.collectAsStateWithLifecycle()
|
||||
|
||||
val paymentRequestViewModel = koinViewModel<PaymentRequestViewModel> { parametersOf(arguments) }
|
||||
val paymentRequestState by paymentRequestViewModel.state.collectAsStateWithLifecycle()
|
||||
|
||||
val authenticateForProposal = rememberSaveable { mutableStateOf<Proposal?>(null) }
|
||||
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
|
||||
val onBackAction = {
|
||||
when (paymentRequestState) {
|
||||
PaymentRequestState.Loading -> {}
|
||||
is PaymentRequestState.Prepared -> {
|
||||
val state = (paymentRequestState as PaymentRequestState.Prepared)
|
||||
when (state.stage) {
|
||||
PaymentRequestStage.Initial,
|
||||
PaymentRequestStage.Confirmed -> navController.popBackStack()
|
||||
PaymentRequestStage.Sending -> {
|
||||
// No action - wait until the sending is done
|
||||
}
|
||||
is PaymentRequestStage.Failure -> paymentRequestViewModel.setStage(PaymentRequestStage.Initial)
|
||||
is PaymentRequestStage.FailureGrpc -> {
|
||||
paymentRequestViewModel.setStage(PaymentRequestStage.Confirmed)
|
||||
navController.navigate(HOME)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
BackHandler { onBackAction() }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
paymentRequestViewModel.backNavigationCommand.collect {
|
||||
onBackAction()
|
||||
}
|
||||
}
|
||||
LaunchedEffect(Unit) {
|
||||
paymentRequestViewModel.closeNavigationCommand.collect {
|
||||
navController.popBackStack()
|
||||
}
|
||||
}
|
||||
LaunchedEffect(Unit) {
|
||||
paymentRequestViewModel.addContactNavigationCommand.collect {
|
||||
navController.navigate(AddContactArgs(it))
|
||||
}
|
||||
}
|
||||
LaunchedEffect(Unit) {
|
||||
paymentRequestViewModel.authenticationNavigationCommand.collect {
|
||||
authenticateForProposal.value = it
|
||||
}
|
||||
}
|
||||
LaunchedEffect(Unit) {
|
||||
paymentRequestViewModel.homeNavigationCommand.collect {
|
||||
navController.navigate(HOME)
|
||||
}
|
||||
}
|
||||
LaunchedEffect(Unit) {
|
||||
paymentRequestViewModel.sendReportFailedNavigationCommand.collect {
|
||||
snackbarHostState.showSnackbar(
|
||||
message = activity.getString(R.string.payment_request_send_failed_report_unable_open_email)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
PaymentRequestView(
|
||||
state = paymentRequestState,
|
||||
topAppBarSubTitleState = walletState,
|
||||
snackbarHostState = snackbarHostState
|
||||
)
|
||||
|
||||
if (authenticateForProposal.value != null) {
|
||||
activity.WrapAuthentication(
|
||||
goSupport = {
|
||||
authenticateForProposal.value = null
|
||||
navController.navigate(NavigationTargets.SUPPORT)
|
||||
},
|
||||
onSuccess = {
|
||||
paymentRequestViewModel.onSendAllowed(authenticateForProposal.value!!)
|
||||
authenticateForProposal.value = null
|
||||
},
|
||||
onCancel = {
|
||||
authenticateForProposal.value = null
|
||||
},
|
||||
onFailed = {
|
||||
// No action needed
|
||||
},
|
||||
useCase = AuthenticationUseCase.SendFunds
|
||||
)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,23 @@
|
|||
package co.electriccoin.zcash.ui.screen.paymentrequest
|
||||
|
||||
import cash.z.ecc.android.sdk.fixture.WalletFixture
|
||||
import cash.z.ecc.android.sdk.model.FirstClassByteArray
|
||||
import cash.z.ecc.android.sdk.model.ZcashNetwork
|
||||
import cash.z.ecc.android.sdk.type.AddressType
|
||||
import co.electriccoin.zcash.ui.common.model.SerializableAddress
|
||||
import co.electriccoin.zcash.ui.screen.paymentrequest.model.PaymentRequestArguments
|
||||
|
||||
internal object PaymentRequestArgumentsFixture {
|
||||
fun new() =
|
||||
PaymentRequestArguments(
|
||||
address =
|
||||
SerializableAddress(
|
||||
WalletFixture.Alice.getAddresses(ZcashNetwork.Mainnet).unified,
|
||||
AddressType.Unified
|
||||
),
|
||||
amount = 10000000,
|
||||
memo = "For the coffee",
|
||||
proposal = FirstClassByteArray(byteArrayOf()),
|
||||
zip321Uri = "zcash:t1duiEGg7b39nfQee3XaTY4f5McqfyJKhBi?amount=1&memo=VGhpcyBpcyBhIHNpbXBsZSBt",
|
||||
)
|
||||
}
|
|
@ -0,0 +1,51 @@
|
|||
package co.electriccoin.zcash.ui.screen.paymentrequest.model
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import cash.z.ecc.android.sdk.model.FirstClassByteArray
|
||||
import cash.z.ecc.android.sdk.model.Memo
|
||||
import cash.z.ecc.android.sdk.model.Proposal
|
||||
import cash.z.ecc.android.sdk.model.Zatoshi
|
||||
import cash.z.ecc.android.sdk.model.ZecSend
|
||||
import co.electriccoin.zcash.ui.NavigationArguments
|
||||
import co.electriccoin.zcash.ui.common.model.SerializableAddress
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
data class PaymentRequestArguments(
|
||||
val address: SerializableAddress?,
|
||||
val amount: Long?,
|
||||
val memo: String?,
|
||||
val proposal: FirstClassByteArray?,
|
||||
val zip321Uri: String?,
|
||||
) {
|
||||
companion object {
|
||||
internal fun fromSavedStateHandle(savedStateHandle: SavedStateHandle) =
|
||||
PaymentRequestArguments(
|
||||
address =
|
||||
savedStateHandle.get<String>(NavigationArguments.PAYMENT_REQUEST_ADDRESS)?.let {
|
||||
Json.decodeFromString<SerializableAddress>(it)
|
||||
},
|
||||
amount = savedStateHandle.get<Long>(NavigationArguments.PAYMENT_REQUEST_AMOUNT),
|
||||
memo = savedStateHandle.get<String>(NavigationArguments.PAYMENT_REQUEST_MEMO),
|
||||
proposal =
|
||||
savedStateHandle.get<ByteArray>(NavigationArguments.PAYMENT_REQUEST_PROPOSAL)?.let {
|
||||
FirstClassByteArray(it)
|
||||
},
|
||||
zip321Uri = savedStateHandle.get<String>(NavigationArguments.PAYMENT_REQUEST_URI),
|
||||
).also {
|
||||
// Remove the screen arguments passed from the other screen if some exist
|
||||
savedStateHandle.remove<String>(NavigationArguments.PAYMENT_REQUEST_ADDRESS)
|
||||
savedStateHandle.remove<Long>(NavigationArguments.PAYMENT_REQUEST_AMOUNT)
|
||||
savedStateHandle.remove<String>(NavigationArguments.PAYMENT_REQUEST_MEMO)
|
||||
savedStateHandle.remove<ByteArray>(NavigationArguments.PAYMENT_REQUEST_PROPOSAL)
|
||||
savedStateHandle.remove<String>(NavigationArguments.PAYMENT_REQUEST_URI)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun toZecSend() =
|
||||
ZecSend(
|
||||
destination = address?.toWalletAddress() ?: error("Address null"),
|
||||
amount = amount?.let { Zatoshi(amount) } ?: error("Amount null"),
|
||||
memo = memo?.let { Memo(memo) } ?: error("Memo null"),
|
||||
proposal = proposal?.let { Proposal.fromByteArray(proposal.byteArray) } ?: error("Proposal null"),
|
||||
)
|
||||
}
|
|
@ -0,0 +1,16 @@
|
|||
package co.electriccoin.zcash.ui.screen.paymentrequest.model
|
||||
|
||||
sealed class PaymentRequestStage {
|
||||
data object Initial : PaymentRequestStage()
|
||||
|
||||
data object Sending : PaymentRequestStage()
|
||||
|
||||
data object Confirmed : PaymentRequestStage()
|
||||
|
||||
data class Failure(
|
||||
val error: String,
|
||||
val stackTrace: String,
|
||||
) : PaymentRequestStage()
|
||||
|
||||
data object FailureGrpc : PaymentRequestStage()
|
||||
}
|
|
@ -0,0 +1,25 @@
|
|||
package co.electriccoin.zcash.ui.screen.paymentrequest.model
|
||||
|
||||
import cash.z.ecc.android.sdk.model.MonetarySeparators
|
||||
import cash.z.ecc.android.sdk.model.Proposal
|
||||
import cash.z.ecc.android.sdk.model.ZecSend
|
||||
import co.electriccoin.zcash.ui.common.model.AddressBookContact
|
||||
import co.electriccoin.zcash.ui.common.wallet.ExchangeRateState
|
||||
|
||||
internal sealed class PaymentRequestState {
|
||||
data object Loading : PaymentRequestState()
|
||||
|
||||
data class Prepared(
|
||||
val arguments: PaymentRequestArguments,
|
||||
val contact: AddressBookContact?,
|
||||
val exchangeRateState: ExchangeRateState,
|
||||
val monetarySeparators: MonetarySeparators,
|
||||
val onAddToContacts: (String) -> Unit,
|
||||
val onClose: () -> Unit,
|
||||
val onBack: () -> Unit,
|
||||
val onSend: (proposal: Proposal) -> Unit,
|
||||
val zecSend: ZecSend,
|
||||
val stage: PaymentRequestStage,
|
||||
val onContactSupport: (String?) -> Unit,
|
||||
) : PaymentRequestState()
|
||||
}
|
|
@ -0,0 +1,545 @@
|
|||
@file:Suppress("TooManyFunctions")
|
||||
|
||||
package co.electriccoin.zcash.ui.screen.paymentrequest.view
|
||||
|
||||
import androidx.compose.animation.animateContentSize
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
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
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.IconButton
|
||||
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.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
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.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
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.model.MonetarySeparators
|
||||
import cash.z.ecc.android.sdk.model.WalletAddress
|
||||
import cash.z.ecc.sdk.extension.toZecStringFull
|
||||
import co.electriccoin.zcash.ui.R
|
||||
import co.electriccoin.zcash.ui.common.compose.BalanceWidgetBigLineOnly
|
||||
import co.electriccoin.zcash.ui.common.extension.asZecAmountTriple
|
||||
import co.electriccoin.zcash.ui.common.model.TopAppBarSubTitleState
|
||||
import co.electriccoin.zcash.ui.common.wallet.ExchangeRateState
|
||||
import co.electriccoin.zcash.ui.design.component.AppAlertDialog
|
||||
import co.electriccoin.zcash.ui.design.component.BlankBgScaffold
|
||||
import co.electriccoin.zcash.ui.design.component.BlankSurface
|
||||
import co.electriccoin.zcash.ui.design.component.CircularScreenProgressIndicator
|
||||
import co.electriccoin.zcash.ui.design.component.StyledBalance
|
||||
import co.electriccoin.zcash.ui.design.component.StyledBalanceDefaults
|
||||
import co.electriccoin.zcash.ui.design.component.ZashiBottomBar
|
||||
import co.electriccoin.zcash.ui.design.component.ZashiButton
|
||||
import co.electriccoin.zcash.ui.design.component.ZashiSmallTopAppBar
|
||||
import co.electriccoin.zcash.ui.design.newcomponent.PreviewScreens
|
||||
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.ZashiDimensions
|
||||
import co.electriccoin.zcash.ui.design.theme.typography.ZashiTypography
|
||||
import co.electriccoin.zcash.ui.design.util.scaffoldPadding
|
||||
import co.electriccoin.zcash.ui.screen.exchangerate.widget.StyledExchangeLabel
|
||||
import co.electriccoin.zcash.ui.screen.paymentrequest.PaymentRequestArgumentsFixture
|
||||
import co.electriccoin.zcash.ui.screen.paymentrequest.model.PaymentRequestStage
|
||||
import co.electriccoin.zcash.ui.screen.paymentrequest.model.PaymentRequestState
|
||||
import co.electriccoin.zcash.ui.screen.send.ext.abbreviated
|
||||
|
||||
@Composable
|
||||
@PreviewScreens
|
||||
private fun PaymentRequestLoadingPreview() =
|
||||
ZcashTheme(forceDarkMode = true) {
|
||||
PaymentRequestView(
|
||||
state = PaymentRequestState.Loading,
|
||||
topAppBarSubTitleState = TopAppBarSubTitleState.None,
|
||||
snackbarHostState = SnackbarHostState(),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@PreviewScreens
|
||||
private fun PaymentRequestPreview() =
|
||||
ZcashTheme(forceDarkMode = false) {
|
||||
PaymentRequestView(
|
||||
state =
|
||||
PaymentRequestState.Prepared(
|
||||
arguments = PaymentRequestArgumentsFixture.new(),
|
||||
contact = null,
|
||||
exchangeRateState = ExchangeRateState.Data(onRefresh = {}),
|
||||
monetarySeparators = MonetarySeparators.current(),
|
||||
onAddToContacts = {},
|
||||
onContactSupport = { _ -> },
|
||||
onBack = {},
|
||||
onClose = {},
|
||||
onSend = {},
|
||||
zecSend = PaymentRequestArgumentsFixture.new().toZecSend(),
|
||||
stage = PaymentRequestStage.Initial,
|
||||
),
|
||||
topAppBarSubTitleState = TopAppBarSubTitleState.None,
|
||||
snackbarHostState = SnackbarHostState(),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun PaymentRequestView(
|
||||
state: PaymentRequestState,
|
||||
topAppBarSubTitleState: TopAppBarSubTitleState,
|
||||
snackbarHostState: SnackbarHostState,
|
||||
) {
|
||||
when (state) {
|
||||
PaymentRequestState.Loading -> {
|
||||
CircularScreenProgressIndicator()
|
||||
}
|
||||
is PaymentRequestState.Prepared -> {
|
||||
BlankBgScaffold(
|
||||
topBar = {
|
||||
PaymentRequestTopAppBar(
|
||||
onClose = state.onClose,
|
||||
subTitleState = topAppBarSubTitleState,
|
||||
)
|
||||
},
|
||||
bottomBar = {
|
||||
PaymentRequestBottomBar(state = state)
|
||||
},
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
) { paddingValues ->
|
||||
Box {
|
||||
PaymentRequestContents(
|
||||
state = state,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxHeight()
|
||||
.verticalScroll(
|
||||
rememberScrollState()
|
||||
)
|
||||
.scaffoldPadding(paddingValues),
|
||||
)
|
||||
when (state.stage) {
|
||||
PaymentRequestStage.FailureGrpc -> {
|
||||
PaymentRequestSendFailureGrpc(
|
||||
onDone = state.onBack
|
||||
)
|
||||
}
|
||||
is PaymentRequestStage.Failure -> {
|
||||
PaymentRequestSendFailure(
|
||||
onDone = state.onBack,
|
||||
onReport = { status -> state.onContactSupport(status.stackTrace) },
|
||||
stage = state.stage,
|
||||
)
|
||||
}
|
||||
else -> {
|
||||
// No action needed
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PaymentRequestTopAppBar(
|
||||
onClose: () -> 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.payment_request_title),
|
||||
navigationAction = {
|
||||
IconButton(
|
||||
onClick = onClose,
|
||||
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),
|
||||
) {
|
||||
Image(
|
||||
painter =
|
||||
painterResource(
|
||||
id = co.electriccoin.zcash.ui.design.R.drawable.ic_close_full
|
||||
),
|
||||
contentDescription = stringResource(id = R.string.payment_request_close_content_description),
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(all = 3.dp)
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PaymentRequestBottomBar(
|
||||
state: PaymentRequestState.Prepared,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
ZashiBottomBar(modifier = modifier.fillMaxWidth()) {
|
||||
ZashiButton(
|
||||
text = stringResource(id = R.string.payment_request_send_btn),
|
||||
onClick = { state.onSend(state.zecSend.proposal!!) },
|
||||
enabled = state.stage != PaymentRequestStage.Sending && state.stage != PaymentRequestStage.Confirmed,
|
||||
isLoading = state.stage == PaymentRequestStage.Sending,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 24.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PaymentRequestContents(
|
||||
state: PaymentRequestState.Prepared,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(modifier = modifier) {
|
||||
Spacer(modifier = Modifier.height(ZashiDimensions.Spacing.spacingXl))
|
||||
|
||||
PaymentRequestBalances(state)
|
||||
|
||||
Spacer(modifier = Modifier.height(ZashiDimensions.Spacing.spacing4xl))
|
||||
|
||||
PaymentRequestAddresses(state)
|
||||
|
||||
if (state.zecSend.memo.value.isNotEmpty()) {
|
||||
Spacer(modifier = Modifier.height(ZashiDimensions.Spacing.spacing4xl))
|
||||
PaymentRequestMemo(state)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(ZashiDimensions.Spacing.spacing3xl))
|
||||
|
||||
PaymentRequestAmounts(state)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PaymentRequestBalances(
|
||||
state: PaymentRequestState.Prepared,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
BalanceWidgetBigLineOnly(
|
||||
parts = state.zecSend.amount.toZecStringFull().asZecAmountTriple(),
|
||||
// We don't hide any balance in confirmation screen
|
||||
isHideBalances = false
|
||||
)
|
||||
|
||||
StyledExchangeLabel(
|
||||
zatoshi = state.zecSend.amount,
|
||||
state = state.exchangeRateState,
|
||||
isHideBalances = false,
|
||||
style = ZashiTypography.textMd.copy(fontWeight = FontWeight.SemiBold),
|
||||
textColor = ZashiColors.Text.textPrimary
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PaymentRequestAddresses(
|
||||
state: PaymentRequestState.Prepared,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
var isShowingFullAddress by rememberSaveable {
|
||||
mutableStateOf(
|
||||
when (state.zecSend.destination) {
|
||||
is WalletAddress.Transparent -> true
|
||||
else -> false
|
||||
}
|
||||
)
|
||||
}
|
||||
Column(modifier = modifier) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.payment_request_requested_by),
|
||||
color = ZashiColors.Text.textTertiary,
|
||||
style = ZashiTypography.textSm,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
|
||||
if (state.contact != null) {
|
||||
Spacer(modifier = Modifier.height(ZashiDimensions.Spacing.spacingSm))
|
||||
Text(
|
||||
text = state.contact.name,
|
||||
color = ZashiColors.Inputs.Filled.label,
|
||||
style = ZashiTypography.textSm,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(ZashiDimensions.Spacing.spacingSm))
|
||||
|
||||
Text(
|
||||
text =
|
||||
if (isShowingFullAddress) {
|
||||
state.zecSend.destination.address
|
||||
} else {
|
||||
state.zecSend.destination.abbreviated()
|
||||
},
|
||||
color = ZashiColors.Text.textPrimary,
|
||||
style = ZashiTypography.textXs,
|
||||
fontWeight = FontWeight.Normal,
|
||||
modifier = Modifier.animateContentSize()
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(ZashiDimensions.Spacing.spacingXl))
|
||||
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.wrapContentHeight()
|
||||
) {
|
||||
if (state.zecSend.destination !is WalletAddress.Transparent) {
|
||||
if (isShowingFullAddress) {
|
||||
PaymentRequestChipText(
|
||||
text = stringResource(id = R.string.payment_request_btn_hide_address),
|
||||
icon = painterResource(id = R.drawable.ic_chevron_up),
|
||||
onClick = { isShowingFullAddress = false }
|
||||
)
|
||||
} else {
|
||||
PaymentRequestChipText(
|
||||
text = stringResource(id = R.string.payment_request_btn_show_address),
|
||||
icon = painterResource(id = R.drawable.ic_chevron_down),
|
||||
onClick = { isShowingFullAddress = true }
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(ZashiDimensions.Spacing.spacingLg))
|
||||
}
|
||||
if (state.contact == null) {
|
||||
PaymentRequestChipText(
|
||||
text = stringResource(id = R.string.payment_request_btn_save_contact),
|
||||
icon = painterResource(id = R.drawable.ic_user_plus),
|
||||
onClick = { state.onAddToContacts(state.zecSend.destination.address) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PaymentRequestChipText(
|
||||
text: String,
|
||||
icon: Painter,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Row(
|
||||
modifier =
|
||||
modifier
|
||||
.background(
|
||||
ZashiColors.Btns.Tertiary.btnTertiaryBg,
|
||||
RoundedCornerShape(ZashiDimensions.Radius.radiusMd)
|
||||
)
|
||||
.clip(RoundedCornerShape(ZashiDimensions.Radius.radiusMd))
|
||||
.clickable { onClick() }
|
||||
.padding(
|
||||
horizontal = ZashiDimensions.Spacing.spacingXl,
|
||||
vertical = 10.dp
|
||||
),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Image(
|
||||
painter = icon,
|
||||
contentDescription = null,
|
||||
colorFilter = ColorFilter.tint(ZashiColors.Btns.Tertiary.btnTertiaryFg)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text(
|
||||
text = text,
|
||||
color = ZashiColors.Btns.Tertiary.btnTertiaryFg,
|
||||
style = ZashiTypography.textSm,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PaymentRequestMemo(
|
||||
state: PaymentRequestState.Prepared,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Column(modifier = modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.payment_request_memo),
|
||||
color = ZashiColors.Text.textPrimary,
|
||||
style = ZashiTypography.textSm,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(ZashiDimensions.Spacing.spacingSm))
|
||||
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.background(ZashiColors.Inputs.Filled.bg, RoundedCornerShape(ZashiDimensions.Radius.radiusIg))
|
||||
.padding(all = ZashiDimensions.Spacing.spacingXl),
|
||||
) {
|
||||
Text(
|
||||
text = state.zecSend.memo.value,
|
||||
color = ZashiColors.Inputs.Filled.text,
|
||||
style = ZashiTypography.textXs,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PaymentRequestAmounts(
|
||||
state: PaymentRequestState.Prepared,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Column(modifier = modifier.fillMaxWidth()) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.payment_request_fee),
|
||||
color = ZashiColors.Text.textTertiary,
|
||||
style = ZashiTypography.textSm,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
Spacer(modifier = Modifier.width(ZashiDimensions.Spacing.spacingMd))
|
||||
StyledBalance(
|
||||
balanceParts = state.zecSend.proposal!!.totalFeeRequired().toZecStringFull().asZecAmountTriple(),
|
||||
textColor = ZashiColors.Text.textPrimary,
|
||||
textStyle =
|
||||
StyledBalanceDefaults.textStyles(
|
||||
mostSignificantPart = ZashiTypography.textSm.copy(fontWeight = FontWeight.SemiBold),
|
||||
leastSignificantPart = ZashiTypography.textXxs.copy(fontWeight = FontWeight.SemiBold),
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(ZashiDimensions.Spacing.spacing2xl))
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.payment_request_total),
|
||||
color = ZashiColors.Text.textTertiary,
|
||||
style = ZashiTypography.textSm,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
Spacer(modifier = Modifier.width(ZashiDimensions.Spacing.spacingMd))
|
||||
StyledBalance(
|
||||
balanceParts = state.zecSend.amount.toZecStringFull().asZecAmountTriple(),
|
||||
textColor = ZashiColors.Text.textPrimary,
|
||||
textStyle =
|
||||
StyledBalanceDefaults.textStyles(
|
||||
mostSignificantPart = ZashiTypography.textSm.copy(fontWeight = FontWeight.SemiBold),
|
||||
leastSignificantPart = ZashiTypography.textXxs.copy(fontWeight = FontWeight.SemiBold),
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@Preview("SendConfirmationFailure")
|
||||
private fun PreviewSendConfirmationFailure() {
|
||||
ZcashTheme(forceDarkMode = false) {
|
||||
BlankSurface {
|
||||
PaymentRequestSendFailure(
|
||||
onDone = {},
|
||||
onReport = {},
|
||||
stage = PaymentRequestStage.Failure("Failed - network error", "Failed stackTrace"),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PaymentRequestSendFailure(
|
||||
onDone: () -> Unit,
|
||||
onReport: (PaymentRequestStage.Failure) -> Unit,
|
||||
stage: PaymentRequestStage.Failure,
|
||||
) {
|
||||
// TODO [#1276]: Once we ensure that the reason contains a localized message, we can leverage it for the UI prompt
|
||||
// TODO [#1276]: Consider adding support for a specific exception in AppAlertDialog
|
||||
// TODO [#1276]: https://github.com/Electric-Coin-Company/zashi-android/issues/1276
|
||||
|
||||
AppAlertDialog(
|
||||
title = stringResource(id = R.string.payment_request_dialog_error_title),
|
||||
text = {
|
||||
Column(
|
||||
Modifier.verticalScroll(rememberScrollState())
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.payment_request_dialog_error_text),
|
||||
color = ZcashTheme.colors.textPrimary,
|
||||
)
|
||||
|
||||
if (stage.error.isNotEmpty()) {
|
||||
Spacer(modifier = Modifier.height(ZcashTheme.dimens.spacingDefault))
|
||||
|
||||
Text(
|
||||
text = stage.error,
|
||||
fontStyle = FontStyle.Italic,
|
||||
color = ZcashTheme.colors.textPrimary,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButtonText = stringResource(id = R.string.payment_request_dialog_error_ok_btn),
|
||||
onConfirmButtonClick = onDone,
|
||||
dismissButtonText = stringResource(id = R.string.payment_request_dialog_error_report_btn),
|
||||
onDismissButtonClick = { onReport(stage) },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PaymentRequestSendFailureGrpc(onDone: () -> Unit) {
|
||||
AppAlertDialog(
|
||||
title = stringResource(id = R.string.payment_request_dialog_error_grpc_title),
|
||||
text = {
|
||||
Column(
|
||||
Modifier.verticalScroll(rememberScrollState())
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.payment_request_dialog_error_grpc_text),
|
||||
color = ZcashTheme.colors.textPrimary,
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButtonText = stringResource(id = R.string.payment_request_dialog_error_grpc_btn),
|
||||
onConfirmButtonClick = onDone
|
||||
)
|
||||
}
|
|
@ -0,0 +1,239 @@
|
|||
package co.electriccoin.zcash.ui.screen.paymentrequest.viewmodel
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Intent
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import cash.z.ecc.android.sdk.SdkSynchronizer
|
||||
import cash.z.ecc.android.sdk.Synchronizer
|
||||
import cash.z.ecc.android.sdk.model.Proposal
|
||||
import cash.z.ecc.android.sdk.model.UnifiedSpendingKey
|
||||
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.usecase.GetSpendingKeyUseCase
|
||||
import co.electriccoin.zcash.ui.common.usecase.GetSynchronizerUseCase
|
||||
import co.electriccoin.zcash.ui.common.usecase.ObserveAddressBookContactsUseCase
|
||||
import co.electriccoin.zcash.ui.common.viewmodel.AuthenticationViewModel
|
||||
import co.electriccoin.zcash.ui.common.viewmodel.WalletViewModel
|
||||
import co.electriccoin.zcash.ui.screen.paymentrequest.model.PaymentRequestArguments
|
||||
import co.electriccoin.zcash.ui.screen.paymentrequest.model.PaymentRequestStage
|
||||
import co.electriccoin.zcash.ui.screen.paymentrequest.model.PaymentRequestState
|
||||
import co.electriccoin.zcash.ui.screen.sendconfirmation.model.SubmitResult
|
||||
import co.electriccoin.zcash.ui.screen.sendconfirmation.viewmodel.CreateTransactionsViewModel
|
||||
import co.electriccoin.zcash.ui.screen.support.model.SupportInfo
|
||||
import co.electriccoin.zcash.ui.screen.support.model.SupportInfoType
|
||||
import co.electriccoin.zcash.ui.screen.support.viewmodel.SupportViewModel
|
||||
import co.electriccoin.zcash.ui.util.EmailUtil
|
||||
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.filterNotNull
|
||||
import kotlinx.coroutines.flow.mapNotNull
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Suppress("TooManyFunctions")
|
||||
class PaymentRequestViewModel(
|
||||
private val application: Application,
|
||||
private val arguments: PaymentRequestArguments,
|
||||
private val authenticationViewModel: AuthenticationViewModel,
|
||||
private val createTransactionsViewModel: CreateTransactionsViewModel,
|
||||
getMonetarySeparators: GetMonetarySeparatorProvider,
|
||||
private val getSpendingKeyUseCase: GetSpendingKeyUseCase,
|
||||
private val getSynchronizer: GetSynchronizerUseCase,
|
||||
private val supportViewModel: SupportViewModel,
|
||||
walletViewModel: WalletViewModel,
|
||||
observeAddressBookContacts: ObserveAddressBookContactsUseCase,
|
||||
) : ViewModel() {
|
||||
private val stage = MutableStateFlow<PaymentRequestStage>(PaymentRequestStage.Initial)
|
||||
|
||||
internal val state =
|
||||
combine(
|
||||
walletViewModel.exchangeRateUsd,
|
||||
observeAddressBookContacts(),
|
||||
stage,
|
||||
supportViewModel.supportInfo.mapNotNull { it },
|
||||
) { rate, contacts, currentStage, supportInfo ->
|
||||
PaymentRequestState.Prepared(
|
||||
arguments = arguments,
|
||||
contact = contacts?.find { it.address == arguments.address?.address },
|
||||
exchangeRateState = rate,
|
||||
monetarySeparators = getMonetarySeparators(),
|
||||
onAddToContacts = { onAddToContacts(it) },
|
||||
onBack = ::onBack,
|
||||
onClose = ::onClose,
|
||||
onContactSupport = { message -> onContactSupport(message, supportInfo) },
|
||||
onSend = { onSend(it) },
|
||||
stage = currentStage,
|
||||
zecSend = arguments.toZecSend(),
|
||||
)
|
||||
}.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(ANDROID_STATE_FLOW_TIMEOUT),
|
||||
initialValue = PaymentRequestState.Loading
|
||||
)
|
||||
|
||||
internal val backNavigationCommand = MutableSharedFlow<Unit>()
|
||||
|
||||
internal val closeNavigationCommand = MutableSharedFlow<Unit>()
|
||||
|
||||
internal val addContactNavigationCommand = MutableSharedFlow<String>()
|
||||
|
||||
internal val homeNavigationCommand = MutableSharedFlow<Unit>()
|
||||
|
||||
internal val authenticationNavigationCommand = MutableSharedFlow<Proposal>()
|
||||
|
||||
internal val sendReportFailedNavigationCommand = MutableSharedFlow<Unit>()
|
||||
|
||||
internal fun onClose() =
|
||||
viewModelScope.launch {
|
||||
closeNavigationCommand.emit(Unit)
|
||||
}
|
||||
|
||||
internal fun onBack() =
|
||||
viewModelScope.launch {
|
||||
backNavigationCommand.emit(Unit)
|
||||
}
|
||||
|
||||
private fun onHome() =
|
||||
viewModelScope.launch {
|
||||
homeNavigationCommand.emit(Unit)
|
||||
}
|
||||
|
||||
internal fun setStage(newStage: PaymentRequestStage) =
|
||||
viewModelScope.launch {
|
||||
stage.emit(newStage)
|
||||
}
|
||||
|
||||
private fun onAddToContacts(address: String) =
|
||||
viewModelScope.launch {
|
||||
addContactNavigationCommand.emit(address)
|
||||
}
|
||||
|
||||
private fun onSend(proposal: Proposal) =
|
||||
viewModelScope.launch {
|
||||
authenticationViewModel.isSendFundsAuthenticationRequired
|
||||
.filterNotNull()
|
||||
.collect { isProtected ->
|
||||
if (isProtected) {
|
||||
authenticationNavigationCommand.emit(proposal)
|
||||
} else {
|
||||
onSendAllowed(proposal)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun onSendAllowed(proposal: Proposal) =
|
||||
viewModelScope.launch {
|
||||
runSendFundsAction(
|
||||
createTransactionsViewModel = createTransactionsViewModel,
|
||||
// The not-null assertion operator is necessary here even if we check its
|
||||
// nullability before due to property is declared in different module. See more
|
||||
// details on the Kotlin forum
|
||||
proposal = proposal,
|
||||
spendingKey = getSpendingKeyUseCase(),
|
||||
synchronizer = getSynchronizer(),
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun runSendFundsAction(
|
||||
createTransactionsViewModel: CreateTransactionsViewModel,
|
||||
proposal: Proposal,
|
||||
spendingKey: UnifiedSpendingKey,
|
||||
synchronizer: Synchronizer,
|
||||
) {
|
||||
setStage(PaymentRequestStage.Sending)
|
||||
|
||||
val submitResult =
|
||||
submitTransactions(
|
||||
createTransactionsViewModel = createTransactionsViewModel,
|
||||
proposal = proposal,
|
||||
synchronizer = synchronizer,
|
||||
spendingKey = spendingKey
|
||||
)
|
||||
|
||||
Twig.debug { "Transactions submitted with result: $submitResult" }
|
||||
|
||||
processSubmissionResult(submitResult = submitResult)
|
||||
}
|
||||
|
||||
private suspend fun submitTransactions(
|
||||
createTransactionsViewModel: CreateTransactionsViewModel,
|
||||
proposal: Proposal,
|
||||
synchronizer: Synchronizer,
|
||||
spendingKey: UnifiedSpendingKey
|
||||
): SubmitResult {
|
||||
Twig.debug { "Sending transactions..." }
|
||||
|
||||
val result =
|
||||
createTransactionsViewModel.runCreateTransactions(
|
||||
synchronizer = synchronizer,
|
||||
spendingKey = spendingKey,
|
||||
proposal = proposal
|
||||
)
|
||||
|
||||
// Triggering the transaction history and balances refresh to be notified immediately
|
||||
// about the wallet's updated state
|
||||
(synchronizer as SdkSynchronizer).run {
|
||||
refreshTransactions()
|
||||
refreshAllBalances()
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
private fun processSubmissionResult(submitResult: SubmitResult) {
|
||||
when (submitResult) {
|
||||
SubmitResult.Success -> {
|
||||
setStage(PaymentRequestStage.Confirmed)
|
||||
onHome()
|
||||
}
|
||||
is SubmitResult.SimpleTrxFailure.SimpleTrxFailureSubmit -> {
|
||||
setStage(PaymentRequestStage.Failure(submitResult.toErrorMessage(), submitResult.toErrorStacktrace()))
|
||||
}
|
||||
is SubmitResult.SimpleTrxFailure.SimpleTrxFailureGrpc -> {
|
||||
setStage(PaymentRequestStage.FailureGrpc)
|
||||
}
|
||||
is SubmitResult.SimpleTrxFailure.SimpleTrxFailureOther -> {
|
||||
setStage(PaymentRequestStage.Failure(submitResult.toErrorMessage(), submitResult.toErrorStacktrace()))
|
||||
}
|
||||
is SubmitResult.MultipleTrxFailure -> {
|
||||
Twig.error { "$submitResult is currently unsupported submission result" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun onContactSupport(
|
||||
message: String?,
|
||||
supportInfo: SupportInfo
|
||||
) = viewModelScope.launch {
|
||||
val fullMessage =
|
||||
EmailUtil.formatMessage(
|
||||
body = message,
|
||||
supportInfo =
|
||||
supportInfo.toSupportString(
|
||||
SupportInfoType.entries.toSet()
|
||||
)
|
||||
)
|
||||
val mailIntent =
|
||||
EmailUtil.newMailActivityIntent(
|
||||
application.applicationContext.getString(R.string.support_email_address),
|
||||
application.applicationContext.getString(R.string.app_name),
|
||||
fullMessage
|
||||
).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK
|
||||
}
|
||||
runCatching {
|
||||
application.startActivity(mailIntent)
|
||||
}.onSuccess {
|
||||
setStage(PaymentRequestStage.Initial)
|
||||
}.onFailure {
|
||||
setStage(PaymentRequestStage.Initial)
|
||||
sendReportFailedNavigationCommand.tryEmit(Unit)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -6,10 +6,12 @@ import androidx.compose.animation.animateContentSize
|
|||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
|
@ -435,6 +437,14 @@ private fun ColumnScope.QrCode(
|
|||
),
|
||||
shape = RoundedCornerShape(ZashiDimensions.Radius.radius4xl)
|
||||
)
|
||||
.background(
|
||||
if (isSystemInDarkTheme()) {
|
||||
ZashiColors.Surfaces.bgAlt
|
||||
} else {
|
||||
ZashiColors.Surfaces.bgPrimary
|
||||
},
|
||||
RoundedCornerShape(ZashiDimensions.Radius.radius4xl)
|
||||
)
|
||||
.padding(all = 12.dp)
|
||||
)
|
||||
}
|
||||
|
|
|
@ -2,9 +2,11 @@ package co.electriccoin.zcash.ui.screen.request.view
|
|||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
|
@ -133,6 +135,14 @@ private fun ColumnScope.QrCode(
|
|||
),
|
||||
shape = RoundedCornerShape(ZashiDimensions.Radius.radius4xl)
|
||||
)
|
||||
.background(
|
||||
if (isSystemInDarkTheme()) {
|
||||
ZashiColors.Surfaces.bgAlt
|
||||
} else {
|
||||
ZashiColors.Surfaces.bgPrimary
|
||||
},
|
||||
RoundedCornerShape(ZashiDimensions.Radius.radius4xl)
|
||||
)
|
||||
.padding(all = 12.dp)
|
||||
)
|
||||
}
|
||||
|
|
|
@ -3,68 +3,62 @@ package co.electriccoin.zcash.ui.screen.scan
|
|||
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.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import cash.z.ecc.android.sdk.Synchronizer
|
||||
import cash.z.ecc.android.sdk.type.AddressType
|
||||
import co.electriccoin.zcash.di.koinActivityViewModel
|
||||
import co.electriccoin.zcash.ui.NavigationArguments.SEND_SCAN_RECIPIENT_ADDRESS
|
||||
import co.electriccoin.zcash.ui.NavigationArguments.SEND_SCAN_ZIP_321_URI
|
||||
import co.electriccoin.zcash.ui.R
|
||||
import co.electriccoin.zcash.ui.common.model.SerializableAddress
|
||||
import co.electriccoin.zcash.ui.common.model.TopAppBarSubTitleState
|
||||
import co.electriccoin.zcash.ui.common.compose.LocalNavController
|
||||
import co.electriccoin.zcash.ui.common.viewmodel.WalletViewModel
|
||||
import co.electriccoin.zcash.ui.design.component.CircularScreenProgressIndicator
|
||||
import co.electriccoin.zcash.ui.popBackStackJustOnce
|
||||
import co.electriccoin.zcash.ui.screen.scan.model.ScanResultState
|
||||
import co.electriccoin.zcash.ui.screen.scan.view.Scan
|
||||
import co.electriccoin.zcash.ui.screen.scan.viewmodel.ScanViewModel
|
||||
import co.electriccoin.zcash.ui.util.SettingsUtil
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import org.koin.androidx.compose.koinViewModel
|
||||
import org.koin.core.parameter.parametersOf
|
||||
|
||||
@Composable
|
||||
internal fun WrapScanValidator(
|
||||
onScanValid: (address: SerializableAddress) -> Unit,
|
||||
goBack: () -> Unit
|
||||
) {
|
||||
internal fun WrapScanValidator(args: ScanNavigationArgs) {
|
||||
val navController = LocalNavController.current
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
val walletViewModel = koinActivityViewModel<WalletViewModel>()
|
||||
|
||||
val viewModel = koinViewModel<ScanViewModel> { parametersOf(args) }
|
||||
val synchronizer = walletViewModel.synchronizer.collectAsStateWithLifecycle().value
|
||||
|
||||
val walletState = walletViewModel.walletStateInformation.collectAsStateWithLifecycle().value
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
|
||||
BackHandler {
|
||||
goBack()
|
||||
navController.popBackStackJustOnce(ScanNavigationArgs.ROUTE)
|
||||
}
|
||||
|
||||
WrapScan(
|
||||
onScanValid = onScanValid,
|
||||
goBack = goBack,
|
||||
synchronizer = synchronizer,
|
||||
topAppBarSubTitleState = walletState,
|
||||
)
|
||||
}
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.navigateBack.collect { scanResult ->
|
||||
navController.previousBackStackEntry?.savedStateHandle?.apply {
|
||||
when (scanResult) {
|
||||
is ScanResultState.Address -> set(SEND_SCAN_RECIPIENT_ADDRESS, scanResult.address)
|
||||
is ScanResultState.Zip321Uri -> set(SEND_SCAN_ZIP_321_URI, scanResult.zip321Uri)
|
||||
}
|
||||
}
|
||||
navController.popBackStackJustOnce(ScanNavigationArgs.ROUTE)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun WrapScan(
|
||||
goBack: () -> Unit,
|
||||
onScanValid: (address: SerializableAddress) -> Unit,
|
||||
synchronizer: Synchronizer?,
|
||||
topAppBarSubTitleState: TopAppBarSubTitleState,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
|
||||
var hasBeenScannedSuccessfully by remember { mutableStateOf(false) }
|
||||
|
||||
val mutex = remember { Mutex() }
|
||||
|
||||
var addressValidationResult by remember { mutableStateOf<AddressType?>(null) }
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.navigateCommand.collect {
|
||||
navController.popBackStack()
|
||||
navController.navigate(it)
|
||||
}
|
||||
}
|
||||
|
||||
if (synchronizer == null) {
|
||||
// TODO [#1146]: Consider moving CircularScreenProgressIndicator from Android layer to View layer
|
||||
|
@ -74,24 +68,13 @@ fun WrapScan(
|
|||
} else {
|
||||
Scan(
|
||||
snackbarHostState = snackbarHostState,
|
||||
addressValidationResult = addressValidationResult,
|
||||
onBack = goBack,
|
||||
onScanned = { result ->
|
||||
scope.launch {
|
||||
mutex.withLock {
|
||||
if (!hasBeenScannedSuccessfully) {
|
||||
addressValidationResult = synchronizer.validateAddress(result)
|
||||
val isAddressValid = addressValidationResult?.let { !it.isNotValid } ?: false
|
||||
if (isAddressValid) {
|
||||
hasBeenScannedSuccessfully = true
|
||||
onScanValid(SerializableAddress(result, addressValidationResult!!))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
validationResult = state,
|
||||
onBack = { navController.popBackStackJustOnce(ScanNavigationArgs.ROUTE) },
|
||||
onScanned = {
|
||||
viewModel.onScanned(it)
|
||||
},
|
||||
onScanError = {
|
||||
addressValidationResult = AddressType.Invalid()
|
||||
viewModel.onScannedError()
|
||||
},
|
||||
onOpenSettings = {
|
||||
runCatching {
|
||||
|
@ -107,7 +90,7 @@ fun WrapScan(
|
|||
}
|
||||
},
|
||||
onScanStateChanged = {},
|
||||
topAppBarSubTitleState = topAppBarSubTitleState,
|
||||
topAppBarSubTitleState = walletState,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,7 @@
|
|||
package co.electriccoin.zcash.ui.screen.scan.model
|
||||
|
||||
sealed class ScanResultState {
|
||||
data class Address(val address: String) : ScanResultState()
|
||||
|
||||
data class Zip321Uri(val zip321Uri: String) : ScanResultState()
|
||||
}
|
|
@ -1,6 +1,6 @@
|
|||
package co.electriccoin.zcash.ui.screen.scan.model
|
||||
|
||||
enum class ScanState {
|
||||
enum class ScanScreenState {
|
||||
Failed,
|
||||
Permission,
|
||||
Scanning
|
|
@ -0,0 +1,7 @@
|
|||
package co.electriccoin.zcash.ui.screen.scan.model
|
||||
|
||||
enum class ScanValidationState {
|
||||
NONE,
|
||||
INVALID,
|
||||
VALID
|
||||
}
|
|
@ -74,7 +74,6 @@ import androidx.compose.ui.viewinterop.AndroidView
|
|||
import androidx.constraintlayout.compose.ConstraintLayout
|
||||
import androidx.constraintlayout.compose.Dimension
|
||||
import androidx.core.content.ContextCompat
|
||||
import cash.z.ecc.android.sdk.type.AddressType
|
||||
import co.electriccoin.zcash.spackle.Twig
|
||||
import co.electriccoin.zcash.ui.R
|
||||
import co.electriccoin.zcash.ui.common.model.TopAppBarSubTitleState
|
||||
|
@ -86,7 +85,8 @@ 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.scan.ScanTag
|
||||
import co.electriccoin.zcash.ui.screen.scan.model.ScanState
|
||||
import co.electriccoin.zcash.ui.screen.scan.model.ScanScreenState
|
||||
import co.electriccoin.zcash.ui.screen.scan.model.ScanValidationState
|
||||
import co.electriccoin.zcash.ui.screen.scan.util.ImageUriToQrCodeConverter
|
||||
import co.electriccoin.zcash.ui.screen.scan.util.QrCodeAnalyzer
|
||||
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||
|
@ -111,9 +111,9 @@ fun Scan(
|
|||
onScanned: (String) -> Unit,
|
||||
onScanError: () -> Unit,
|
||||
onOpenSettings: () -> Unit,
|
||||
onScanStateChanged: (ScanState) -> Unit,
|
||||
onScanStateChanged: (ScanScreenState) -> Unit,
|
||||
topAppBarSubTitleState: TopAppBarSubTitleState,
|
||||
addressValidationResult: AddressType?
|
||||
validationResult: ScanValidationState
|
||||
) = ZcashTheme(forceDarkMode = true) { // forces dark theme for this screen
|
||||
val permissionState =
|
||||
if (LocalInspectionMode.current) {
|
||||
|
@ -134,15 +134,15 @@ fun Scan(
|
|||
val (scanState, setScanState) =
|
||||
if (LocalInspectionMode.current) {
|
||||
remember {
|
||||
mutableStateOf(ScanState.Scanning)
|
||||
mutableStateOf(ScanScreenState.Scanning)
|
||||
}
|
||||
} else {
|
||||
rememberSaveable {
|
||||
mutableStateOf(
|
||||
if (permissionState.status.isGranted) {
|
||||
ScanState.Scanning
|
||||
ScanScreenState.Scanning
|
||||
} else {
|
||||
ScanState.Permission
|
||||
ScanScreenState.Permission
|
||||
}
|
||||
)
|
||||
}
|
||||
|
@ -153,7 +153,7 @@ fun Scan(
|
|||
) { _ ->
|
||||
Box {
|
||||
ScanMainContent(
|
||||
addressValidationResult = addressValidationResult,
|
||||
validationResult = validationResult,
|
||||
onScanned = onScanned,
|
||||
onScanError = onScanError,
|
||||
onOpenSettings = onOpenSettings,
|
||||
|
@ -166,7 +166,7 @@ fun Scan(
|
|||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
if (scanState != ScanState.Scanning) {
|
||||
if (scanState != ScanScreenState.Scanning) {
|
||||
ZcashTheme.colors.cameraDisabledBackgroundColor
|
||||
} else {
|
||||
Color.Black
|
||||
|
@ -177,7 +177,7 @@ fun Scan(
|
|||
|
||||
ScanTopAppBar(
|
||||
onBack = onBack,
|
||||
showBack = scanState != ScanState.Scanning,
|
||||
showBack = scanState != ScanScreenState.Scanning,
|
||||
subTitleState = topAppBarSubTitleState,
|
||||
)
|
||||
}
|
||||
|
@ -186,8 +186,8 @@ fun Scan(
|
|||
|
||||
@Composable
|
||||
fun ScanBottomItems(
|
||||
addressValidationResult: AddressType?,
|
||||
scanState: ScanState,
|
||||
validationResult: ScanValidationState,
|
||||
scanState: ScanScreenState,
|
||||
onOpenSettings: () -> Unit,
|
||||
onBack: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
|
@ -195,22 +195,21 @@ fun ScanBottomItems(
|
|||
Column(modifier) {
|
||||
var failureText: String? = null
|
||||
|
||||
// Check validation result, if any
|
||||
if (addressValidationResult is AddressType.Invalid) {
|
||||
if (validationResult == ScanValidationState.INVALID) {
|
||||
failureText = stringResource(id = R.string.scan_address_validation_failed)
|
||||
}
|
||||
|
||||
// Check permission request result, if any
|
||||
failureText =
|
||||
when (scanState) {
|
||||
ScanState.Permission ->
|
||||
ScanScreenState.Permission ->
|
||||
stringResource(
|
||||
id = R.string.scan_state_permission,
|
||||
stringResource(id = R.string.app_name)
|
||||
)
|
||||
|
||||
ScanState.Failed -> stringResource(id = R.string.scan_state_failed)
|
||||
ScanState.Scanning -> failureText
|
||||
ScanScreenState.Failed -> stringResource(id = R.string.scan_state_failed)
|
||||
ScanScreenState.Scanning -> failureText
|
||||
}
|
||||
|
||||
if (failureText != null) {
|
||||
|
@ -236,7 +235,7 @@ fun ScanBottomItems(
|
|||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
when (scanState) {
|
||||
ScanState.Scanning, ScanState.Failed -> {
|
||||
ScanScreenState.Scanning, ScanScreenState.Failed -> {
|
||||
ZashiButton(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
onClick = onBack,
|
||||
|
@ -244,7 +243,7 @@ fun ScanBottomItems(
|
|||
)
|
||||
}
|
||||
|
||||
ScanState.Permission -> {
|
||||
ScanScreenState.Permission -> {
|
||||
ZashiButton(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
onClick = onOpenSettings,
|
||||
|
@ -306,20 +305,20 @@ data class FramePosition(
|
|||
)
|
||||
@Composable
|
||||
private fun ScanMainContent(
|
||||
addressValidationResult: AddressType?,
|
||||
validationResult: ScanValidationState,
|
||||
onScanned: (String) -> Unit,
|
||||
onScanError: () -> Unit,
|
||||
onOpenSettings: () -> Unit,
|
||||
onBack: () -> Unit,
|
||||
onScanStateChanged: (ScanState) -> Unit,
|
||||
onScanStateChanged: (ScanScreenState) -> Unit,
|
||||
permissionState: PermissionState,
|
||||
scanState: ScanState,
|
||||
setScanState: (ScanState) -> Unit,
|
||||
scanState: ScanScreenState,
|
||||
setScanState: (ScanScreenState) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
when {
|
||||
(!permissionState.status.isGranted) -> {
|
||||
setScanState(ScanState.Permission)
|
||||
setScanState(ScanScreenState.Permission)
|
||||
if (permissionState.status.shouldShowRationale) {
|
||||
// Keep dark screen with a link to the app settings - user denied the permission previously
|
||||
} else {
|
||||
|
@ -329,13 +328,13 @@ private fun ScanMainContent(
|
|||
}
|
||||
}
|
||||
|
||||
(scanState == ScanState.Failed) -> {
|
||||
(scanState == ScanScreenState.Failed) -> {
|
||||
// Keep current state
|
||||
}
|
||||
|
||||
(permissionState.status.isGranted) -> {
|
||||
if (scanState != ScanState.Scanning) {
|
||||
setScanState(ScanState.Scanning)
|
||||
if (scanState != ScanScreenState.Scanning) {
|
||||
setScanState(ScanScreenState.Scanning)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -411,13 +410,13 @@ private fun ScanMainContent(
|
|||
val (frame, frameWindow, bottomItems, topAnchor) = createRefs()
|
||||
|
||||
when (scanState) {
|
||||
ScanState.Permission -> {
|
||||
ScanScreenState.Permission -> {
|
||||
// Keep initial ui state
|
||||
onScanStateChanged(ScanState.Permission)
|
||||
onScanStateChanged(ScanScreenState.Permission)
|
||||
}
|
||||
|
||||
ScanState.Scanning -> {
|
||||
onScanStateChanged(ScanState.Scanning)
|
||||
ScanScreenState.Scanning -> {
|
||||
onScanStateChanged(ScanScreenState.Scanning)
|
||||
|
||||
if (!LocalInspectionMode.current) {
|
||||
ScanCameraView(
|
||||
|
@ -487,8 +486,8 @@ private fun ScanMainContent(
|
|||
}
|
||||
}
|
||||
|
||||
ScanState.Failed -> {
|
||||
onScanStateChanged(ScanState.Failed)
|
||||
ScanScreenState.Failed -> {
|
||||
onScanStateChanged(ScanScreenState.Failed)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -542,7 +541,7 @@ private fun ScanMainContent(
|
|||
.constrainAs(bottomItems) { bottom.linkTo(parent.bottom) }
|
||||
) {
|
||||
ScanBottomItems(
|
||||
addressValidationResult = addressValidationResult,
|
||||
validationResult = validationResult,
|
||||
onBack = onBack,
|
||||
onOpenSettings = onOpenSettings,
|
||||
scanState = scanState,
|
||||
|
@ -627,7 +626,7 @@ fun ScanCameraView(
|
|||
isTorchOn: Boolean,
|
||||
onScanned: (result: String) -> Unit,
|
||||
permissionState: PermissionState,
|
||||
setScanState: (ScanState) -> Unit,
|
||||
setScanState: (ScanScreenState) -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
|
@ -691,7 +690,7 @@ fun ScanCameraView(
|
|||
).cameraControl
|
||||
}.onFailure {
|
||||
Twig.error { "Scan QR failed in bind phase with: ${it.message}" }
|
||||
setScanState(ScanState.Failed)
|
||||
setScanState(ScanScreenState.Failed)
|
||||
}
|
||||
|
||||
previewView
|
||||
|
@ -755,7 +754,7 @@ private fun ScanPreview() =
|
|||
onOpenSettings = {},
|
||||
onScanStateChanged = {},
|
||||
topAppBarSubTitleState = TopAppBarSubTitleState.None,
|
||||
addressValidationResult = AddressType.Invalid(),
|
||||
validationResult = ScanValidationState.INVALID,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,94 @@
|
|||
package co.electriccoin.zcash.ui.screen.scan.viewmodel
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import cash.z.ecc.android.sdk.type.AddressType
|
||||
import co.electriccoin.zcash.ui.common.model.SerializableAddress
|
||||
import co.electriccoin.zcash.ui.common.usecase.GetSynchronizerUseCase
|
||||
import co.electriccoin.zcash.ui.common.usecase.Zip321ParseUriValidationUseCase
|
||||
import co.electriccoin.zcash.ui.common.usecase.Zip321ParseUriValidationUseCase.Zip321ParseUriValidation
|
||||
import co.electriccoin.zcash.ui.screen.contact.AddContactArgs
|
||||
import co.electriccoin.zcash.ui.screen.scan.ScanNavigationArgs
|
||||
import co.electriccoin.zcash.ui.screen.scan.ScanNavigationArgs.ADDRESS_BOOK
|
||||
import co.electriccoin.zcash.ui.screen.scan.ScanNavigationArgs.DEFAULT
|
||||
import co.electriccoin.zcash.ui.screen.scan.model.ScanResultState
|
||||
import co.electriccoin.zcash.ui.screen.scan.model.ScanValidationState
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
internal class ScanViewModel(
|
||||
private val args: ScanNavigationArgs,
|
||||
private val getSynchronizer: GetSynchronizerUseCase,
|
||||
private val zip321ParseUriValidationUseCase: Zip321ParseUriValidationUseCase,
|
||||
) : ViewModel() {
|
||||
val navigateBack = MutableSharedFlow<ScanResultState>()
|
||||
|
||||
val navigateCommand = MutableSharedFlow<String>()
|
||||
|
||||
var state = MutableStateFlow(ScanValidationState.NONE)
|
||||
|
||||
private val mutex = Mutex()
|
||||
|
||||
private var hasBeenScannedSuccessfully = false
|
||||
|
||||
fun onScanned(result: String) =
|
||||
viewModelScope.launch {
|
||||
mutex.withLock {
|
||||
if (!hasBeenScannedSuccessfully) {
|
||||
val addressValidationResult = getSynchronizer().validateAddress(result)
|
||||
|
||||
val zip321ValidationResult = zip321ParseUriValidationUseCase(result)
|
||||
|
||||
state.update {
|
||||
if (addressValidationResult is AddressType.Valid) {
|
||||
ScanValidationState.INVALID
|
||||
} else if (zip321ValidationResult is Zip321ParseUriValidation.Valid) {
|
||||
ScanValidationState.INVALID
|
||||
} else {
|
||||
ScanValidationState.NONE
|
||||
}
|
||||
}
|
||||
|
||||
if (zip321ValidationResult is Zip321ParseUriValidation.Valid) {
|
||||
hasBeenScannedSuccessfully = true
|
||||
navigateBack.emit(ScanResultState.Zip321Uri(zip321ValidationResult.zip321Uri))
|
||||
} else if (addressValidationResult is AddressType.Valid) {
|
||||
hasBeenScannedSuccessfully = true
|
||||
|
||||
val serializableAddress = SerializableAddress(result, addressValidationResult)
|
||||
|
||||
when (args) {
|
||||
DEFAULT -> {
|
||||
navigateBack.emit(
|
||||
ScanResultState.Address(
|
||||
Json.encodeToString(
|
||||
SerializableAddress.serializer(),
|
||||
serializableAddress
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
ADDRESS_BOOK -> {
|
||||
navigateCommand.emit(AddContactArgs(serializableAddress.address))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onScannedError() =
|
||||
viewModelScope.launch {
|
||||
mutex.withLock {
|
||||
if (!hasBeenScannedSuccessfully) {
|
||||
state.update { ScanValidationState.INVALID }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -17,8 +17,12 @@ import androidx.compose.runtime.setValue
|
|||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import cash.z.ecc.android.sdk.Synchronizer
|
||||
import cash.z.ecc.android.sdk.ext.convertZecToZatoshi
|
||||
import cash.z.ecc.android.sdk.model.Account
|
||||
import cash.z.ecc.android.sdk.model.Memo
|
||||
import cash.z.ecc.android.sdk.model.MonetarySeparators
|
||||
import cash.z.ecc.android.sdk.model.UnifiedSpendingKey
|
||||
import cash.z.ecc.android.sdk.model.WalletAddress
|
||||
import cash.z.ecc.android.sdk.model.ZecSend
|
||||
import cash.z.ecc.android.sdk.model.proposeSend
|
||||
import cash.z.ecc.android.sdk.model.toZecString
|
||||
|
@ -50,6 +54,7 @@ import co.electriccoin.zcash.ui.screen.send.view.Send
|
|||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.compose.koinInject
|
||||
import org.zecdev.zip321.ZIP321
|
||||
import java.util.Locale
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
|
@ -61,6 +66,7 @@ internal fun WrapSend(
|
|||
goBack: () -> Unit,
|
||||
goBalances: () -> Unit,
|
||||
goSendConfirmation: (ZecSend) -> Unit,
|
||||
goPaymentRequest: (ZecSend, String) -> Unit,
|
||||
goSettings: () -> Unit,
|
||||
) {
|
||||
val activity = LocalActivity.current
|
||||
|
@ -100,6 +106,7 @@ internal fun WrapSend(
|
|||
goBalances = goBalances,
|
||||
goSettings = goSettings,
|
||||
goSendConfirmation = goSendConfirmation,
|
||||
goPaymentRequest = goPaymentRequest,
|
||||
hasCameraFeature = hasCameraFeature,
|
||||
monetarySeparators = monetarySeparators,
|
||||
topAppBarSubTitleState = walletState,
|
||||
|
@ -119,6 +126,7 @@ internal fun WrapSend(
|
|||
goBalances: () -> Unit,
|
||||
goSettings: () -> Unit,
|
||||
goSendConfirmation: (ZecSend) -> Unit,
|
||||
goPaymentRequest: (ZecSend, String) -> Unit,
|
||||
hasCameraFeature: Boolean,
|
||||
monetarySeparators: MonetarySeparators,
|
||||
onHideBalances: () -> Unit,
|
||||
|
@ -156,6 +164,25 @@ internal fun WrapSend(
|
|||
)
|
||||
}
|
||||
|
||||
// Zip321 Uri scan result processing
|
||||
if (sendArguments?.zip321Uri != null &&
|
||||
synchronizer != null &&
|
||||
spendingKey != null
|
||||
) {
|
||||
LaunchedEffect(goPaymentRequest) {
|
||||
scope.launch {
|
||||
processZip321Result(
|
||||
zip321Uri = sendArguments.zip321Uri,
|
||||
synchronizer = synchronizer,
|
||||
account = spendingKey.account,
|
||||
setSendStage = setSendStage,
|
||||
setZecSend = setZecSend,
|
||||
goPaymentRequest = goPaymentRequest
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val existingContact: MutableState<AddressBookContact?> = remember { mutableStateOf(null) }
|
||||
var isHintVisible by remember { mutableStateOf(false) }
|
||||
|
||||
|
@ -356,3 +383,68 @@ internal fun WrapSend(
|
|||
)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun processZip321Result(
|
||||
zip321Uri: String,
|
||||
synchronizer: Synchronizer,
|
||||
account: Account,
|
||||
setSendStage: (SendStage) -> Unit,
|
||||
setZecSend: (ZecSend?) -> Unit,
|
||||
goPaymentRequest: (ZecSend, String) -> Unit,
|
||||
) {
|
||||
val request =
|
||||
runCatching {
|
||||
// At this point there should by only a valid Zcash address coming
|
||||
ZIP321.request(zip321Uri, null)
|
||||
}.onFailure {
|
||||
Twig.error(it) { "Failed to validate address" }
|
||||
}.getOrElse {
|
||||
false
|
||||
}
|
||||
val payment =
|
||||
when (request) {
|
||||
// We support only one payment currently
|
||||
is ZIP321.ParserResult.Request -> {
|
||||
request.paymentRequest.payments[0]
|
||||
}
|
||||
else -> return
|
||||
}
|
||||
|
||||
val address =
|
||||
synchronizer
|
||||
.validateAddress(payment.recipientAddress.value)
|
||||
.toWalletAddress(payment.recipientAddress.value)
|
||||
|
||||
val amount = payment.nonNegativeAmount.value.convertZecToZatoshi()
|
||||
|
||||
val memo = Memo(payment.memo?.let { String(it.data, Charsets.UTF_8) } ?: "")
|
||||
|
||||
val zecSend =
|
||||
ZecSend(
|
||||
destination = address,
|
||||
amount = amount,
|
||||
memo = memo,
|
||||
proposal = null
|
||||
)
|
||||
setZecSend(zecSend)
|
||||
|
||||
runCatching {
|
||||
synchronizer.proposeFulfillingPaymentUri(account, zip321Uri)
|
||||
}.onSuccess { proposal ->
|
||||
Twig.debug { "Transaction proposal from Zip321 Uri: ${proposal.toPrettyString()}" }
|
||||
val enrichedZecSend = zecSend.copy(proposal = proposal)
|
||||
setZecSend(enrichedZecSend)
|
||||
goPaymentRequest(enrichedZecSend, zip321Uri)
|
||||
}.onFailure {
|
||||
Twig.error(it) { "Transaction proposal from Zip321 Uri failed" }
|
||||
setSendStage(SendStage.SendFailure(it.message ?: ""))
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun AddressType.toWalletAddress(value: String) =
|
||||
when (this) {
|
||||
AddressType.Unified -> WalletAddress.Unified.new(value)
|
||||
AddressType.Shielded -> WalletAddress.Sapling.new(value)
|
||||
AddressType.Transparent -> WalletAddress.Transparent.new(value)
|
||||
else -> error("Invalid address type")
|
||||
}
|
||||
|
|
|
@ -2,5 +2,6 @@ package co.electriccoin.zcash.ui.screen.send.model
|
|||
|
||||
data class SendArguments(
|
||||
val recipientAddress: RecipientAddressState? = null,
|
||||
val zip321Uri: String? = null,
|
||||
val clearForm: Boolean = false,
|
||||
)
|
||||
|
|
|
@ -0,0 +1,13 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="20dp"
|
||||
android:height="20dp"
|
||||
android:viewportWidth="20"
|
||||
android:viewportHeight="20">
|
||||
<path
|
||||
android:pathData="M5,7.5L10,12.5L15,7.5"
|
||||
android:strokeLineJoin="round"
|
||||
android:strokeWidth="2"
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#4D4941"
|
||||
android:strokeLineCap="round"/>
|
||||
</vector>
|
|
@ -0,0 +1,13 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="20dp"
|
||||
android:height="20dp"
|
||||
android:viewportWidth="20"
|
||||
android:viewportHeight="20">
|
||||
<path
|
||||
android:pathData="M15,12.5L10,7.5L5,12.5"
|
||||
android:strokeLineJoin="round"
|
||||
android:strokeWidth="2"
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#4D4941"
|
||||
android:strokeLineCap="round"/>
|
||||
</vector>
|
|
@ -0,0 +1,13 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="20dp"
|
||||
android:height="20dp"
|
||||
android:viewportWidth="20"
|
||||
android:viewportHeight="20">
|
||||
<path
|
||||
android:pathData="M10,12.917H6.25C5.087,12.917 4.505,12.917 4.032,13.06C2.967,13.383 2.133,14.217 1.81,15.282C1.667,15.755 1.667,16.337 1.667,17.5M15.833,17.5V12.5M13.333,15H18.333M12.083,6.25C12.083,8.321 10.404,10 8.333,10C6.262,10 4.583,8.321 4.583,6.25C4.583,4.179 6.262,2.5 8.333,2.5C10.404,2.5 12.083,4.179 12.083,6.25Z"
|
||||
android:strokeLineJoin="round"
|
||||
android:strokeWidth="2"
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#4D4941"
|
||||
android:strokeLineCap="round"/>
|
||||
</vector>
|
|
@ -0,0 +1,29 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<string name="payment_request_title">Payment Request</string>
|
||||
<string name="payment_request_close_content_description">Close</string>
|
||||
|
||||
<string name="payment_request_requested_by">Requested By</string>
|
||||
<string name="payment_request_btn_show_address">Show</string>
|
||||
<string name="payment_request_btn_hide_address">Hide</string>
|
||||
<string name="payment_request_btn_save_contact">Save</string>
|
||||
|
||||
<string name="payment_request_memo">For:</string>
|
||||
|
||||
<string name="payment_request_fee">Fee</string>
|
||||
<string name="payment_request_total">Total</string>
|
||||
|
||||
<string name="payment_request_send_btn">Send</string>
|
||||
|
||||
<string name="payment_request_dialog_error_title">Transaction Failed</string>
|
||||
<string name="payment_request_dialog_error_text">An error occurred and the attempt to send funds failed. Try it again, please.</string>
|
||||
<string name="payment_request_dialog_error_ok_btn">OK</string>
|
||||
<string name="payment_request_dialog_error_report_btn">Report</string>
|
||||
|
||||
<string name="payment_request_dialog_error_grpc_title">Connection Error</string>
|
||||
<string name="payment_request_dialog_error_grpc_text">Zashi encountered some connection issues when submitting
|
||||
the transaction to the network. It will retry during the next few minutes.</string>
|
||||
<string name="payment_request_dialog_error_grpc_btn">OK</string>
|
||||
|
||||
<string name="payment_request_send_failed_report_unable_open_email">Unable to launch email app.</string>
|
||||
</resources>
|
Loading…
Reference in New Issue