feat(wallet): add no-wallet guard for /pay and fix payment event type

Phase 3b: Deferred features completion

Task 1: /pay No-Wallet Guard
- Add noWalletSetup and isCheckingWallet flags to PaymentEntryState
- Update PaymentEntryPresenter to check wallet state early via collectAsState
- Add full-screen "Wallet Required" prompt to PaymentEntryView when no wallet
- Add onOpenWalletSettings callback through the entire navigation chain
- Wire callback in MessagesFlowNode to navigate to WalletPanel

Task 2: Payment Timeline Card (already existed, just fixed event type)
- Fix isPaymentEventType() to check for correct event types:
  - co.sulkta.payment.request (was incorrectly com.sulkta.cardano.payment)
  - co.sulkta.payment.status (for status updates)

Build verified: assembleGplayDebug passes
This commit is contained in:
Kayos 2026-03-28 09:47:55 -07:00
parent e33c87c164
commit 455f45ed59
8 changed files with 266 additions and 73 deletions

View file

@ -567,6 +567,11 @@ class MessagesFlowNode(
override fun onPaymentCancelled() {
backstack.pop()
}
override fun onOpenWalletSettings() {
backstack.pop()
backstack.push(NavTarget.WalletPanel)
}
}
walletEntryPoint.paymentFlowBuilder(
parentNode = this,

View file

@ -44,5 +44,7 @@ interface WalletEntryPoint : FeatureEntryPoint {
interface Callback : Plugin {
fun onPaymentSent(txHash: String)
fun onPaymentCancelled()
/** Called when user needs to set up wallet before paying. Caller should navigate to wallet panel. */
fun onOpenWalletSettings()
}
}

View file

@ -108,6 +108,11 @@ class PaymentFlowNode(
override fun onCancel() {
callback.onPaymentCancelled()
}
override fun onOpenWalletSettings() {
// Cancel the payment flow and request wallet settings to be opened
callback.onOpenWalletSettings()
}
}
createNode<PaymentEntryNode>(buildContext, plugins = listOf(nodeInputs, nodeCallback))
}

View file

@ -38,6 +38,7 @@ class PaymentEntryNode(
interface Callback : Plugin {
fun onContinue(recipientAddress: String, amountLovelace: Long)
fun onCancel()
fun onOpenWalletSettings()
}
private val inputs: Inputs = plugins.filterIsInstance<Inputs>().first()
@ -64,6 +65,9 @@ class PaymentEntryNode(
onCancel = {
callback.onCancel()
},
onOpenWalletSettings = {
callback.onOpenWalletSettings()
},
modifier = modifier,
)
}

View file

@ -8,10 +8,10 @@ package io.element.android.features.wallet.impl.payment
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
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 dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory
@ -53,6 +53,44 @@ class PaymentEntryPresenter @AssistedInject constructor(
@Composable
override fun present(): PaymentEntryState {
val walletState by walletManager.walletState.collectAsState()
var walletInitialized by remember { mutableStateOf(false) }
// Initialize wallet manager first
LaunchedEffect(Unit) {
val sessionId = matrixClient.sessionId
walletManager.initialize(sessionId)
walletInitialized = true
}
// Show loading state while checking wallet
if (!walletInitialized || walletState.isLoading) {
return PaymentEntryState.Loading
}
// If no wallet is set up, return early with that state
if (!walletState.hasWallet) {
return PaymentEntryState(
noWalletSetup = true,
isCheckingWallet = false,
amountInput = "",
recipientInput = "",
prefillAmount = null,
prefillRecipient = null,
parsedAmountLovelace = null,
isValidRecipient = false,
recipientResolutionState = RecipientResolutionState.NotNeeded,
senderAddress = null,
senderBalanceAda = null,
isTestnet = CardanoNetworkConfig.NETWORK == CardanoNetwork.TESTNET,
amountError = null,
recipientError = null,
canContinue = false,
eventSink = {},
)
}
// User has a wallet — proceed with normal payment flow
val (prefillAmount, prefillRecipient) = remember(parsedCommand) {
extractPrefills(parsedCommand)
}
@ -63,13 +101,14 @@ class PaymentEntryPresenter @AssistedInject constructor(
var senderBalanceLovelace by remember { mutableStateOf<Lovelace?>(null) }
var recipientResolutionState by remember { mutableStateOf<RecipientResolutionState>(RecipientResolutionState.NotNeeded) }
LaunchedEffect(Unit) {
val sessionId = matrixClient.sessionId
walletManager.initialize(sessionId)
senderAddress = walletManager.getAddress(sessionId).getOrNull()
senderAddress?.let { address ->
cardanoClient.getBalance(address).onSuccess { balance ->
senderBalanceLovelace = balance
LaunchedEffect(walletInitialized) {
if (walletInitialized) {
val sessionId = matrixClient.sessionId
senderAddress = walletManager.getAddress(sessionId).getOrNull()
senderAddress?.let { address ->
cardanoClient.getBalance(address).onSuccess { balance ->
senderBalanceLovelace = balance
}
}
}
}
@ -113,6 +152,8 @@ class PaymentEntryPresenter @AssistedInject constructor(
}
return PaymentEntryState(
noWalletSetup = false,
isCheckingWallet = false,
amountInput = amountInput,
recipientInput = recipientInput,
prefillAmount = prefillAmount,

View file

@ -12,6 +12,10 @@ import io.element.android.features.wallet.impl.slash.Lovelace
* State for the payment entry screen.
*/
data class PaymentEntryState(
/** True if the user has no wallet set up yet. */
val noWalletSetup: Boolean,
/** True while checking if wallet exists. */
val isCheckingWallet: Boolean,
val amountInput: String,
val recipientInput: String,
val prefillAmount: Lovelace?,
@ -32,6 +36,28 @@ data class PaymentEntryState(
val ada = lovelace / 1_000_000.0
String.format("%.6f", ada).trimEnd('0').trimEnd('.')
}
companion object {
/** Initial loading state while checking wallet. */
val Loading = PaymentEntryState(
noWalletSetup = false,
isCheckingWallet = true,
amountInput = "",
recipientInput = "",
prefillAmount = null,
prefillRecipient = null,
parsedAmountLovelace = null,
isValidRecipient = false,
recipientResolutionState = RecipientResolutionState.NotNeeded,
senderAddress = null,
senderBalanceAda = null,
isTestnet = false,
amountError = null,
recipientError = null,
canContinue = false,
eventSink = {},
)
}
}
/**

View file

@ -7,6 +7,7 @@
package io.element.android.features.wallet.impl.payment
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
@ -15,6 +16,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
@ -23,6 +25,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@ -35,6 +38,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.compose.ui.unit.dp
@ -48,6 +52,7 @@ fun PaymentEntryView(
state: PaymentEntryState,
onContinue: () -> Unit,
onCancel: () -> Unit,
onOpenWalletSettings: () -> Unit,
modifier: Modifier = Modifier,
) {
Scaffold(
@ -66,75 +71,167 @@ fun PaymentEntryView(
)
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(horizontal = 16.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
if (state.isTestnet) {
TestnetWarningCard()
}
state.senderBalanceAda?.let { balance ->
BalanceInfoCard(balanceAda = balance)
}
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = state.amountInput,
onValueChange = { state.eventSink(PaymentFlowEvents.AmountChanged(it)) },
label = { Text("Amount (ADA)") },
placeholder = { Text("0.00") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
isError = state.amountError != null,
supportingText = state.amountError?.let { { Text(it, color = MaterialTheme.colorScheme.error) } },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = state.recipientInput,
onValueChange = { state.eventSink(PaymentFlowEvents.RecipientChanged(it)) },
label = { Text("Recipient") },
placeholder = { Text("addr1... or @user:server") },
isError = state.recipientError != null,
supportingText = state.recipientError?.let { { Text(it, color = MaterialTheme.colorScheme.error) } },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
when (val resolution = state.recipientResolutionState) {
is RecipientResolutionState.NeedsManualEntry -> {
MatrixUserNeedsAddressCard(
matrixUserId = resolution.matrixUserId,
displayName = resolution.displayName,
)
when {
state.isCheckingWallet -> {
// Loading state
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator()
}
is RecipientResolutionState.Error -> {
Text(resolution.message, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
}
else -> Unit
}
Spacer(modifier = Modifier.weight(1f))
Button(
text = "Continue",
onClick = {
state.eventSink(PaymentFlowEvents.Continue)
onContinue()
},
enabled = state.canContinue,
modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp),
)
state.noWalletSetup -> {
// No wallet setup prompt
NoWalletSetupContent(
onOpenWalletSettings = onOpenWalletSettings,
onCancel = onCancel,
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(horizontal = 24.dp),
)
}
else -> {
// Normal payment form
PaymentFormContent(
state = state,
onContinue = onContinue,
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(horizontal = 16.dp),
)
}
}
}
}
@Composable
private fun NoWalletSetupContent(
onOpenWalletSettings: () -> Unit,
onCancel: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
// Cardano icon
Text(
text = "",
style = MaterialTheme.typography.displayLarge,
color = MaterialTheme.colorScheme.primary,
)
Spacer(modifier = Modifier.height(24.dp))
Text(
text = "Wallet Required",
style = MaterialTheme.typography.headlineMedium,
textAlign = TextAlign.Center,
)
Spacer(modifier = Modifier.height(12.dp))
Text(
text = "You need to set up a Cardano wallet before you can send payments.",
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(32.dp))
Button(
text = "Open Wallet Settings",
onClick = onOpenWalletSettings,
modifier = Modifier.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(12.dp))
Button(
text = "Cancel",
onClick = onCancel,
modifier = Modifier.fillMaxWidth(),
)
}
}
@Composable
private fun PaymentFormContent(
state: PaymentEntryState,
onContinue: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
if (state.isTestnet) {
TestnetWarningCard()
}
state.senderBalanceAda?.let { balance ->
BalanceInfoCard(balanceAda = balance)
}
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = state.amountInput,
onValueChange = { state.eventSink(PaymentFlowEvents.AmountChanged(it)) },
label = { Text("Amount (ADA)") },
placeholder = { Text("0.00") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
isError = state.amountError != null,
supportingText = state.amountError?.let { { Text(it, color = MaterialTheme.colorScheme.error) } },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = state.recipientInput,
onValueChange = { state.eventSink(PaymentFlowEvents.RecipientChanged(it)) },
label = { Text("Recipient") },
placeholder = { Text("addr1... or @user:server") },
isError = state.recipientError != null,
supportingText = state.recipientError?.let { { Text(it, color = MaterialTheme.colorScheme.error) } },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
when (val resolution = state.recipientResolutionState) {
is RecipientResolutionState.NeedsManualEntry -> {
MatrixUserNeedsAddressCard(
matrixUserId = resolution.matrixUserId,
displayName = resolution.displayName,
)
}
is RecipientResolutionState.Error -> {
Text(resolution.message, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
}
else -> Unit
}
Spacer(modifier = Modifier.weight(1f))
Button(
text = "Continue",
onClick = {
state.eventSink(PaymentFlowEvents.Continue)
onContinue()
},
enabled = state.canContinue,
modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp),
)
}
}
@Composable
private fun TestnetWarningCard(modifier: Modifier = Modifier) {
Card(
@ -186,16 +283,28 @@ private fun MatrixUserNeedsAddressCard(matrixUserId: String, displayName: String
@PreviewsDayNight
@Composable
internal fun PaymentEntryViewPreview(@PreviewParameter(PaymentEntryStateProvider::class) state: PaymentEntryState) {
ElementPreview { PaymentEntryView(state = state, onContinue = {}, onCancel = {}) }
ElementPreview { PaymentEntryView(state = state, onContinue = {}, onCancel = {}, onOpenWalletSettings = {}) }
}
internal class PaymentEntryStateProvider : PreviewParameterProvider<PaymentEntryState> {
override val values = sequenceOf(
// Normal state with wallet
PaymentEntryState(
noWalletSetup = false, isCheckingWallet = false,
amountInput = "", recipientInput = "", prefillAmount = null, prefillRecipient = null,
parsedAmountLovelace = null, isValidRecipient = false, recipientResolutionState = RecipientResolutionState.NotNeeded,
senderAddress = "addr_test1qp2fg...", senderBalanceAda = "100.5", isTestnet = true,
amountError = null, recipientError = null, canContinue = false, eventSink = {},
),
// No wallet state
PaymentEntryState(
noWalletSetup = true, isCheckingWallet = false,
amountInput = "", recipientInput = "", prefillAmount = null, prefillRecipient = null,
parsedAmountLovelace = null, isValidRecipient = false, recipientResolutionState = RecipientResolutionState.NotNeeded,
senderAddress = null, senderBalanceAda = null, isTestnet = false,
amountError = null, recipientError = null, canContinue = false, eventSink = {},
),
// Loading state
PaymentEntryState.Loading,
)
}

View file

@ -38,7 +38,8 @@ class TimelineItemContentPaymentFactory {
* Check if an event type is a payment event type.
*/
fun isPaymentEventType(eventType: String): Boolean {
return eventType == "com.sulkta.cardano.payment"
return eventType == TimelineItemPaymentContent.EVENT_TYPE ||
eventType == "co.sulkta.payment.status"
}
fun isPaymentMessage(body: String): Boolean {