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:
parent
e33c87c164
commit
455f45ed59
8 changed files with 266 additions and 73 deletions
|
|
@ -567,6 +567,11 @@ class MessagesFlowNode(
|
|||
override fun onPaymentCancelled() {
|
||||
backstack.pop()
|
||||
}
|
||||
|
||||
override fun onOpenWalletSettings() {
|
||||
backstack.pop()
|
||||
backstack.push(NavTarget.WalletPanel)
|
||||
}
|
||||
}
|
||||
walletEntryPoint.paymentFlowBuilder(
|
||||
parentNode = this,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue