diff --git a/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/payment/PaymentEntryNode.kt b/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/payment/PaymentEntryNode.kt index 2413b07c6c..b199386d5f 100644 --- a/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/payment/PaymentEntryNode.kt +++ b/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/payment/PaymentEntryNode.kt @@ -58,7 +58,8 @@ class PaymentEntryNode( PaymentEntryView( state = state, onContinue = { - val recipientAddress = state.recipientInput + // Use the resolved Cardano address (from lookup or manual entry) + val recipientAddress = state.resolvedAddress ?: return@PaymentEntryView val amount = state.parsedAmountLovelace ?: return@PaymentEntryView callback.onContinue(recipientAddress, amount) }, diff --git a/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/payment/PaymentEntryPresenter.kt b/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/payment/PaymentEntryPresenter.kt index a77559c95c..f61f7ed474 100644 --- a/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/payment/PaymentEntryPresenter.kt +++ b/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/payment/PaymentEntryPresenter.kt @@ -80,16 +80,19 @@ class PaymentEntryPresenter @AssistedInject constructor( isCheckingWallet = false, amountInput = "", recipientInput = "", + manualAddressInput = "", prefillAmount = null, prefillRecipient = null, parsedAmountLovelace = null, isValidRecipient = false, recipientResolutionState = RecipientResolutionState.NotNeeded, + resolvedAddress = null, senderAddress = null, senderBalanceAda = null, isTestnet = CardanoNetworkConfig.NETWORK == CardanoNetwork.TESTNET, amountError = null, recipientError = null, + manualAddressError = null, canContinue = false, eventSink = {}, ) @@ -102,6 +105,7 @@ class PaymentEntryPresenter @AssistedInject constructor( var amountInput by remember { mutableStateOf(prefillAmount?.let { formatLovelaceInput(it) } ?: "") } var recipientInput by remember { mutableStateOf(prefillRecipient ?: "") } + var manualAddressInput by remember { mutableStateOf("") } var senderAddress by remember { mutableStateOf(null) } var senderBalanceLovelace by remember { mutableStateOf(null) } var recipientResolutionState by remember { mutableStateOf(RecipientResolutionState.NotNeeded) } @@ -133,6 +137,8 @@ class PaymentEntryPresenter @AssistedInject constructor( isCardanoAddress -> { recipientResolutionState = RecipientResolutionState.NotNeeded resolvedCardanoAddress = recipientInput + // Clear manual entry when direct address is entered + manualAddressInput = "" } isMatrixUser -> { // Start lookup @@ -157,6 +163,7 @@ class PaymentEntryPresenter @AssistedInject constructor( matrixUserId = recipientInput, displayName = null ) + // Don't set resolvedCardanoAddress - user must enter manually } } .onFailure { e -> @@ -174,6 +181,20 @@ class PaymentEntryPresenter @AssistedInject constructor( } } + // When in manual entry mode, validate and use the manual address + val needsManualEntry = recipientResolutionState is RecipientResolutionState.NeedsManualEntry + val manualAddressError = if (needsManualEntry && manualAddressInput.isNotBlank()) { + validateManualAddress(manualAddressInput) + } else { + null + } + + // If manual address is valid, use it as the resolved address + val finalResolvedAddress = when { + needsManualEntry && manualAddressInput.isNotBlank() && manualAddressError == null -> manualAddressInput + else -> resolvedCardanoAddress + } + val parsedAmountLovelace = parseAmountInput(amountInput) val amountError = validateAmount(parsedAmountLovelace, amountInput) @@ -181,21 +202,25 @@ class PaymentEntryPresenter @AssistedInject constructor( val isMatrixUser = MATRIX_USER_REGEX.matches(recipientInput) val recipientError = validateRecipient(recipientInput, isCardanoAddress, isMatrixUser, recipientResolutionState) - // Recipient is valid if we have a direct Cardano address or a resolved one from Matrix lookup - val isValidRecipient = isCardanoAddress || resolvedCardanoAddress != null + // Recipient is valid if we have a final resolved address + val isValidRecipient = finalResolvedAddress != null val canContinue = parsedAmountLovelace != null && parsedAmountLovelace >= MIN_AMOUNT_LOVELACE && amountError == null && isValidRecipient && - recipientError == null + (recipientError == null || needsManualEntry) // Allow continue in manual entry mode if address is valid fun handleEvent(event: PaymentFlowEvents) { when (event) { is PaymentFlowEvents.AmountChanged -> amountInput = event.amount is PaymentFlowEvents.RecipientChanged -> { recipientInput = event.recipient - // Clear resolved address when input changes + // Clear resolved address and manual entry when input changes resolvedCardanoAddress = null + manualAddressInput = "" + } + is PaymentFlowEvents.ManualAddressChanged -> { + manualAddressInput = event.address } else -> Unit } @@ -210,16 +235,19 @@ class PaymentEntryPresenter @AssistedInject constructor( isCheckingWallet = false, amountInput = amountInput, recipientInput = recipientInput, + manualAddressInput = manualAddressInput, prefillAmount = prefillAmount, prefillRecipient = prefillRecipient, parsedAmountLovelace = parsedAmountLovelace, isValidRecipient = isValidRecipient, recipientResolutionState = recipientResolutionState, + resolvedAddress = finalResolvedAddress, senderAddress = senderAddress, senderBalanceAda = senderBalanceAda, isTestnet = CardanoNetworkConfig.NETWORK == CardanoNetwork.TESTNET, amountError = amountError, - recipientError = recipientError, + recipientError = if (needsManualEntry) null else recipientError, // Hide error in manual entry mode + manualAddressError = manualAddressError, canContinue = canContinue, eventSink = ::handleEvent, ) @@ -272,7 +300,7 @@ class PaymentEntryPresenter @AssistedInject constructor( return when (resolutionState) { is RecipientResolutionState.Resolving -> null // Still looking up is RecipientResolutionState.Found -> null // Found address - is RecipientResolutionState.NeedsManualEntry -> "${resolutionState.matrixUserId} hasn't linked a Cardano wallet" + is RecipientResolutionState.NeedsManualEntry -> null // Will use manual entry field is RecipientResolutionState.Error -> resolutionState.message else -> null } @@ -286,4 +314,31 @@ class PaymentEntryPresenter @AssistedInject constructor( } return null } + + private fun validateManualAddress(input: String): String? { + if (input.isBlank()) return null + + // Must start with addr_test1 (preprod) or addr1 (mainnet) + val isTestnet = input.startsWith("addr_test1") + val isMainnet = input.startsWith("addr1") && !input.startsWith("addr_test1") + + if (!isTestnet && !isMainnet) { + return "Address must start with addr1 or addr_test1" + } + + // Length check: Cardano addresses are typically 58-108 characters + if (input.length < 58) { + return "Address too short" + } + if (input.length > 108) { + return "Address too long" + } + + // Basic character validation + if (!CARDANO_ADDRESS_REGEX.matches(input)) { + return "Invalid Cardano address format" + } + + return null + } } diff --git a/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/payment/PaymentEntryState.kt b/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/payment/PaymentEntryState.kt index 71d2db926d..7c02777c10 100644 --- a/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/payment/PaymentEntryState.kt +++ b/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/payment/PaymentEntryState.kt @@ -18,16 +18,22 @@ data class PaymentEntryState( val isCheckingWallet: Boolean, val amountInput: String, val recipientInput: String, + /** Manual address entry field - shown when recipient has no linked wallet. */ + val manualAddressInput: String, val prefillAmount: Lovelace?, val prefillRecipient: String?, val parsedAmountLovelace: Lovelace?, val isValidRecipient: Boolean, val recipientResolutionState: RecipientResolutionState, + /** The final resolved Cardano address to use for the transaction. */ + val resolvedAddress: String?, val senderAddress: String?, val senderBalanceAda: String?, val isTestnet: Boolean, val amountError: String?, val recipientError: String?, + /** Validation error for manual address entry field. */ + val manualAddressError: String?, val canContinue: Boolean, val eventSink: (PaymentFlowEvents) -> Unit, ) { @@ -37,6 +43,10 @@ data class PaymentEntryState( String.format("%.6f", ada).trimEnd('0').trimEnd('.') } + /** True when the user must manually enter an address for the recipient. */ + val needsManualAddressEntry: Boolean + get() = recipientResolutionState is RecipientResolutionState.NeedsManualEntry + companion object { /** Initial loading state while checking wallet. */ val Loading = PaymentEntryState( @@ -44,16 +54,19 @@ data class PaymentEntryState( isCheckingWallet = true, amountInput = "", recipientInput = "", + manualAddressInput = "", prefillAmount = null, prefillRecipient = null, parsedAmountLovelace = null, isValidRecipient = false, recipientResolutionState = RecipientResolutionState.NotNeeded, + resolvedAddress = null, senderAddress = null, senderBalanceAda = null, isTestnet = false, amountError = null, recipientError = null, + manualAddressError = null, canContinue = false, eventSink = {}, ) diff --git a/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/payment/PaymentEntryView.kt b/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/payment/PaymentEntryView.kt index 24cdb86e61..54d9912a96 100644 --- a/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/payment/PaymentEntryView.kt +++ b/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/payment/PaymentEntryView.kt @@ -218,13 +218,20 @@ private fun PaymentFormContent( ) } is RecipientResolutionState.NeedsManualEntry -> { - MatrixUserNeedsAddressCard( + ManualAddressEntryCard( matrixUserId = resolution.matrixUserId, displayName = resolution.displayName, + manualAddressInput = state.manualAddressInput, + manualAddressError = state.manualAddressError, + onManualAddressChanged = { state.eventSink(PaymentFlowEvents.ManualAddressChanged(it)) }, ) } is RecipientResolutionState.Error -> { - Text(resolution.message, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall) + Text( + resolution.message, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall + ) } else -> Unit } @@ -255,7 +262,11 @@ private fun TestnetWarningCard(modifier: Modifier = Modifier) { horizontalArrangement = Arrangement.spacedBy(8.dp), ) { Text("⚠️", style = MaterialTheme.typography.titleMedium) - Text("Testnet transaction — no real ADA", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onTertiaryContainer) + Text( + "Testnet transaction — no real ADA", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onTertiaryContainer + ) } } } @@ -271,8 +282,16 @@ private fun BalanceInfoCard(balanceAda: String, modifier: Modifier = Modifier) { horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { - Text("Available balance", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) - Text("$balanceAda ADA", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + Text( + "Available balance", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + "$balanceAda ADA", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) } } } @@ -337,24 +356,88 @@ private fun AddressFoundCard(matrixUserId: String, address: String, modifier: Mo } } +/** + * Card shown when the Matrix user has no linked Cardano wallet. + * Includes a text field for manual address entry. + */ @Composable -private fun MatrixUserNeedsAddressCard(matrixUserId: String, displayName: String?, modifier: Modifier = Modifier) { +private fun ManualAddressEntryCard( + matrixUserId: String, + displayName: String?, + manualAddressInput: String, + manualAddressError: String?, + onManualAddressChanged: (String) -> Unit, + modifier: Modifier = Modifier, +) { Card( modifier = modifier.fillMaxWidth(), - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.tertiaryContainer), ) { - Column(modifier = Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { - val name = displayName ?: matrixUserId.substringBefore(":").removePrefix("@") - Text("$name hasn't linked a wallet yet", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onErrorContainer) - Text("Enter their Cardano address manually above", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onErrorContainer.copy(alpha = 0.7f)) + Column( + modifier = Modifier.padding(12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + // Warning header + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text("⚠️", style = MaterialTheme.typography.titleMedium) + val name = displayName ?: matrixUserId.substringBefore(":").removePrefix("@") + Text( + text = "$name hasn't linked a Cardano wallet", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onTertiaryContainer, + ) + } + + Text( + text = "Enter their Cardano address manually:", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onTertiaryContainer.copy(alpha = 0.8f), + ) + + // Manual address entry field + OutlinedTextField( + value = manualAddressInput, + onValueChange = onManualAddressChanged, + placeholder = { Text("addr1... or addr_test1...") }, + isError = manualAddressError != null, + supportingText = if (manualAddressError != null) { + { Text(manualAddressError, color = MaterialTheme.colorScheme.error) } + } else if (manualAddressInput.isNotBlank()) { + { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Icon( + imageVector = CompoundIcons.Check(), + contentDescription = null, + modifier = Modifier.size(14.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Text("Valid address", color = MaterialTheme.colorScheme.primary) + } + } + } else { + null + }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) } } } @PreviewsDayNight @Composable -internal fun PaymentEntryViewPreview(@PreviewParameter(PaymentEntryStateProvider::class) state: PaymentEntryState) { - ElementPreview { PaymentEntryView(state = state, onContinue = {}, onCancel = {}, onOpenWalletSettings = {}) } +internal fun PaymentEntryViewPreview( + @PreviewParameter(PaymentEntryStateProvider::class) state: PaymentEntryState +) { + ElementPreview { + PaymentEntryView(state = state, onContinue = {}, onCancel = {}, onOpenWalletSettings = {}) + } } internal class PaymentEntryStateProvider : PreviewParameterProvider { @@ -362,27 +445,66 @@ internal class PaymentEntryStateProvider : PreviewParameterProvider