feat(wallet): implement /pay fallback UX for recipients without linked wallets

- Add ManualAddressChanged event for manual address entry
- Add manualAddressInput and manualAddressError fields to PaymentEntryState
- Add resolvedAddress field to track the final Cardano address
- Update PaymentEntryPresenter to handle manual address entry flow
- Add ManualAddressEntryCard component with embedded text field
- Validate manual addresses (addr1/addr_test1, length 58-108)
- Update PaymentEntryNode to pass resolvedAddress to confirmation screen

Flow B: When recipient has no linked wallet, show warning banner
and editable address field for manual entry. Continue button
enables when valid address is entered.
This commit is contained in:
Kayos 2026-03-29 07:23:32 -07:00
parent c35289a3bd
commit 2b93236229
5 changed files with 222 additions and 29 deletions

View file

@ -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)
},

View file

@ -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<String?>(null) }
var senderBalanceLovelace by remember { mutableStateOf<Lovelace?>(null) }
var recipientResolutionState by remember { mutableStateOf<RecipientResolutionState>(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
}
}

View file

@ -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 = {},
)

View file

@ -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<PaymentEntryState> {
@ -362,27 +445,66 @@ internal class PaymentEntryStateProvider : PreviewParameterProvider<PaymentEntry
// Normal state with wallet
PaymentEntryState(
noWalletSetup = false, isCheckingWallet = false,
amountInput = "", recipientInput = "", prefillAmount = null, prefillRecipient = null,
parsedAmountLovelace = null, isValidRecipient = false, recipientResolutionState = RecipientResolutionState.NotNeeded,
amountInput = "", recipientInput = "", manualAddressInput = "",
prefillAmount = null, prefillRecipient = null,
parsedAmountLovelace = null, isValidRecipient = false,
recipientResolutionState = RecipientResolutionState.NotNeeded,
resolvedAddress = null,
senderAddress = "addr_test1qp2fg...", senderBalanceAda = "100.5", isTestnet = true,
amountError = null, recipientError = null, canContinue = false, eventSink = {},
amountError = null, recipientError = null, manualAddressError = null,
canContinue = false, eventSink = {},
),
// Address found from Matrix
PaymentEntryState(
noWalletSetup = false, isCheckingWallet = false,
amountInput = "10", recipientInput = "@alice:matrix.org", prefillAmount = null, prefillRecipient = null,
amountInput = "10", recipientInput = "@alice:matrix.org", manualAddressInput = "",
prefillAmount = null, prefillRecipient = null,
parsedAmountLovelace = 10_000_000L, isValidRecipient = true,
recipientResolutionState = RecipientResolutionState.Found("@alice:matrix.org", "addr_test1qz2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzer"),
recipientResolutionState = RecipientResolutionState.Found(
"@alice:matrix.org",
"addr_test1qz2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzer"
),
resolvedAddress = "addr_test1qz2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzer",
senderAddress = "addr_test1qp2fg...", senderBalanceAda = "100.5", isTestnet = true,
amountError = null, recipientError = null, canContinue = true, eventSink = {},
amountError = null, recipientError = null, manualAddressError = null,
canContinue = true, eventSink = {},
),
// Manual entry needed - empty
PaymentEntryState(
noWalletSetup = false, isCheckingWallet = false,
amountInput = "10", recipientInput = "@bob:matrix.org", manualAddressInput = "",
prefillAmount = null, prefillRecipient = null,
parsedAmountLovelace = 10_000_000L, isValidRecipient = false,
recipientResolutionState = RecipientResolutionState.NeedsManualEntry("@bob:matrix.org", "Bob"),
resolvedAddress = null,
senderAddress = "addr_test1qp2fg...", senderBalanceAda = "100.5", isTestnet = true,
amountError = null, recipientError = null, manualAddressError = null,
canContinue = false, eventSink = {},
),
// Manual entry with valid address
PaymentEntryState(
noWalletSetup = false, isCheckingWallet = false,
amountInput = "10", recipientInput = "@bob:matrix.org",
manualAddressInput = "addr_test1qp2fg770ddmqxxduasjsas8rgimrhknmqjn43mj74g7ta2",
prefillAmount = null, prefillRecipient = null,
parsedAmountLovelace = 10_000_000L, isValidRecipient = true,
recipientResolutionState = RecipientResolutionState.NeedsManualEntry("@bob:matrix.org", "Bob"),
resolvedAddress = "addr_test1qp2fg770ddmqxxduasjsas8rgimrhknmqjn43mj74g7ta2",
senderAddress = "addr_test1qp2fg...", senderBalanceAda = "100.5", isTestnet = true,
amountError = null, recipientError = null, manualAddressError = null,
canContinue = true, eventSink = {},
),
// No wallet state
PaymentEntryState(
noWalletSetup = true, isCheckingWallet = false,
amountInput = "", recipientInput = "", prefillAmount = null, prefillRecipient = null,
parsedAmountLovelace = null, isValidRecipient = false, recipientResolutionState = RecipientResolutionState.NotNeeded,
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, canContinue = false, eventSink = {},
amountError = null, recipientError = null, manualAddressError = null,
canContinue = false, eventSink = {},
),
// Loading state
PaymentEntryState.Loading,

View file

@ -13,6 +13,8 @@ sealed interface PaymentFlowEvents {
// Entry screen events
data class AmountChanged(val amount: String) : PaymentFlowEvents
data class RecipientChanged(val recipient: String) : PaymentFlowEvents
/** Manual address entry when recipient has no linked wallet. */
data class ManualAddressChanged(val address: String) : PaymentFlowEvents
data object Continue : PaymentFlowEvents
data object Cancel : PaymentFlowEvents