diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt index 774f483356..25ffe10800 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt @@ -567,6 +567,11 @@ class MessagesFlowNode( override fun onPaymentCancelled() { backstack.pop() } + + override fun onOpenWalletSettings() { + backstack.pop() + backstack.push(NavTarget.WalletPanel) + } } walletEntryPoint.paymentFlowBuilder( parentNode = this, diff --git a/features/wallet/api/src/main/kotlin/io/element/android/features/wallet/api/WalletEntryPoint.kt b/features/wallet/api/src/main/kotlin/io/element/android/features/wallet/api/WalletEntryPoint.kt index c8d5595dcc..9d08208c80 100644 --- a/features/wallet/api/src/main/kotlin/io/element/android/features/wallet/api/WalletEntryPoint.kt +++ b/features/wallet/api/src/main/kotlin/io/element/android/features/wallet/api/WalletEntryPoint.kt @@ -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() } } diff --git a/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/PaymentFlowNode.kt b/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/PaymentFlowNode.kt index 9fe3c2ee8c..0ca2c1ed83 100644 --- a/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/PaymentFlowNode.kt +++ b/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/PaymentFlowNode.kt @@ -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(buildContext, plugins = listOf(nodeInputs, nodeCallback)) } 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 70d9e30477..2413b07c6c 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 @@ -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().first() @@ -64,6 +65,9 @@ class PaymentEntryNode( onCancel = { callback.onCancel() }, + onOpenWalletSettings = { + callback.onOpenWalletSettings() + }, modifier = modifier, ) } 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 ad19b12829..b4d5281107 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 @@ -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(null) } var recipientResolutionState by remember { mutableStateOf(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, 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 87649a5872..238d00dd8b 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 @@ -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 = {}, + ) + } } /** 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 1af95c8abc..5357f69ff6 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 @@ -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 { 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, ) } diff --git a/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/timeline/TimelineItemContentPaymentFactory.kt b/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/timeline/TimelineItemContentPaymentFactory.kt index bf2098fd00..21f7034ac5 100644 --- a/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/timeline/TimelineItemContentPaymentFactory.kt +++ b/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/timeline/TimelineItemContentPaymentFactory.kt @@ -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 {