From 1dbc4c92c470b6d45319a2727804efd53886d606 Mon Sep 17 00:00:00 2001 From: Kayos Date: Sat, 28 Mar 2026 10:13:06 -0700 Subject: [PATCH] feat(wallet): add wallet setup flow and payment event wiring Phase 4: Final features for Element X ADA alpha ## Wallet Setup Flow - New setup state machine: WELCOME -> GENERATING -> ADDRESS -> BACKUP_PROMPT -> COMPLETE - WalletSetupState.kt: state data class and events - WalletSetupPresenter.kt: generates wallet via CardanoKeyStorage, state transitions - WalletSetupView.kt: Compose UI with FLAG_SECURE for mnemonic display - WalletSetupNode.kt: Appyx node with setup callbacks - Wired into MessagesFlowNode via NavTarget.WalletSetup - SSSS backup skipped for alpha (local-only, TODO for Phase 5) ## Payment Event Wiring - PaymentProgressPresenter now sends Matrix payment event on tx confirmation - Added roomId to PaymentProgressNode.Inputs and NavTarget.Progress - Calls paymentEventSender.sendPaymentEvent() when SubmissionState.Confirmed - Non-fatal if event fails (tx already succeeded) ## Files Changed - features/wallet/impl/setup/ (new directory, 4 files) - MessagesFlowNode.kt: NavTarget.WalletSetup, navigation wiring - PaymentFlowNode.kt: roomId passthrough to Progress - PaymentProgressNode.kt: roomId in Inputs - PaymentProgressPresenter.kt: event sending on confirmation --- .../messages/impl/MessagesFlowNode.kt | 19 +- .../features/wallet/impl/PaymentFlowNode.kt | 3 + .../impl/payment/PaymentProgressNode.kt | 6 +- .../impl/payment/PaymentProgressPresenter.kt | 47 ++- .../wallet/impl/setup/WalletSetupNode.kt | 45 +++ .../wallet/impl/setup/WalletSetupPresenter.kt | 124 ++++++ .../wallet/impl/setup/WalletSetupState.kt | 38 ++ .../wallet/impl/setup/WalletSetupView.kt | 368 ++++++++++++++++++ 8 files changed, 646 insertions(+), 4 deletions(-) create mode 100644 features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/setup/WalletSetupNode.kt create mode 100644 features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/setup/WalletSetupPresenter.kt create mode 100644 features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/setup/WalletSetupState.kt create mode 100644 features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/setup/WalletSetupView.kt 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 25ffe10800..31545a0597 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 @@ -54,6 +54,7 @@ import io.element.android.features.poll.api.create.CreatePollEntryPoint import io.element.android.features.poll.api.create.CreatePollMode import io.element.android.features.wallet.api.WalletEntryPoint import io.element.android.features.wallet.impl.panel.WalletPanelNode +import io.element.android.features.wallet.impl.setup.WalletSetupNode import io.element.android.libraries.architecture.BackstackWithOverlayBox import io.element.android.libraries.architecture.BaseFlowNode import io.element.android.libraries.architecture.callback @@ -186,6 +187,9 @@ class MessagesFlowNode( @Parcelize data object WalletPanel : NavTarget + @Parcelize + data object WalletSetup : NavTarget + @Parcelize data class PaymentFlow( val roomId: RoomId, @@ -553,11 +557,24 @@ class MessagesFlowNode( } override fun onSetupWallet() { - // TODO: Navigate to wallet setup flow + backstack.push(NavTarget.WalletSetup) } } createNode(buildContext, listOf(walletPanelCallback)) } + is NavTarget.WalletSetup -> { + val setupCallback = object : WalletSetupNode.Callback { + override fun onSetupComplete() { + // Pop setup, stay on wallet panel which will now show the wallet + backstack.pop() + } + + override fun onBack() { + backstack.pop() + } + } + createNode(buildContext, listOf(setupCallback)) + } is NavTarget.PaymentFlow -> { val walletCallback = object : WalletEntryPoint.Callback { override fun onPaymentSent(txHash: String) { 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 0ca2c1ed83..2c7f164b5f 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 @@ -87,6 +87,7 @@ class PaymentFlowNode( data class Progress( val recipientAddress: String, val amountLovelace: Lovelace, + val roomId: RoomId, ) : NavTarget } @@ -127,6 +128,7 @@ class PaymentFlowNode( backstack.replace(NavTarget.Progress( recipientAddress = navTarget.recipientAddress, amountLovelace = navTarget.amountLovelace, + roomId = inputs.roomId, )) } @@ -141,6 +143,7 @@ class PaymentFlowNode( val nodeInputs = PaymentProgressNode.Inputs( recipientAddress = navTarget.recipientAddress, amountLovelace = navTarget.amountLovelace, + roomId = navTarget.roomId, ) val nodeCallback = object : PaymentProgressNode.Callback { override fun onPaymentComplete(txHash: String?) { diff --git a/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/payment/PaymentProgressNode.kt b/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/payment/PaymentProgressNode.kt index b255137611..5d9e7f48f7 100644 --- a/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/payment/PaymentProgressNode.kt +++ b/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/payment/PaymentProgressNode.kt @@ -18,6 +18,7 @@ import io.element.android.annotations.ContributesNode import io.element.android.features.wallet.impl.slash.Lovelace import io.element.android.libraries.architecture.NodeInputs import io.element.android.libraries.di.SessionScope +import io.element.android.libraries.matrix.api.core.RoomId import kotlinx.parcelize.Parcelize /** @@ -26,8 +27,7 @@ import kotlinx.parcelize.Parcelize * Displays transaction submission progress and polls for confirmation. */ @ContributesNode(SessionScope::class) -@AssistedInject -class PaymentProgressNode( +class PaymentProgressNode @AssistedInject constructor( @Assisted buildContext: BuildContext, @Assisted plugins: List, private val presenterFactory: PaymentProgressPresenter.Factory, @@ -37,6 +37,7 @@ class PaymentProgressNode( data class Inputs( val recipientAddress: String, val amountLovelace: Lovelace, + val roomId: RoomId, ) : NodeInputs, Parcelable interface Callback : Plugin { @@ -51,6 +52,7 @@ class PaymentProgressNode( presenterFactory.create( recipientAddress = inputs.recipientAddress, amountLovelace = inputs.amountLovelace, + roomId = inputs.roomId, ) } diff --git a/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/payment/PaymentProgressPresenter.kt b/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/payment/PaymentProgressPresenter.kt index 094522c18d..7cdbeb1512 100644 --- a/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/payment/PaymentProgressPresenter.kt +++ b/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/payment/PaymentProgressPresenter.kt @@ -16,8 +16,10 @@ import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.AssistedFactory import dev.zacsweers.metro.AssistedInject import io.element.android.features.wallet.api.CardanoClient +import io.element.android.features.wallet.api.PaymentEventSender import io.element.android.features.wallet.api.PaymentRequest import io.element.android.features.wallet.api.PaymentStatusPoller +import io.element.android.features.wallet.api.SignedTransaction import io.element.android.features.wallet.api.TransactionBuilder import io.element.android.features.wallet.api.TxStatus import io.element.android.features.wallet.impl.cardano.CardanoNetwork @@ -26,6 +28,8 @@ import io.element.android.features.wallet.impl.cardano.CardanoWalletManager import io.element.android.features.wallet.impl.slash.Lovelace import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.room.JoinedRoom import timber.log.Timber /** @@ -34,16 +38,18 @@ import timber.log.Timber class PaymentProgressPresenter @AssistedInject constructor( @Assisted private val recipientAddress: String, @Assisted private val amountLovelace: Lovelace, + @Assisted private val roomId: RoomId, private val matrixClient: MatrixClient, private val walletManager: CardanoWalletManager, private val transactionBuilder: TransactionBuilder, private val cardanoClient: CardanoClient, private val paymentStatusPoller: PaymentStatusPoller, + private val paymentEventSender: PaymentEventSender, ) : Presenter { @AssistedFactory interface Factory { - fun create(recipientAddress: String, amountLovelace: Lovelace): PaymentProgressPresenter + fun create(recipientAddress: String, amountLovelace: Lovelace, roomId: RoomId): PaymentProgressPresenter } companion object { @@ -61,6 +67,12 @@ class PaymentProgressPresenter @AssistedInject constructor( var errorMessage by remember { mutableStateOf(null) } var submissionStartTime by remember { mutableStateOf(0L) } + // Store for event sending + var lastRequest by remember { mutableStateOf(null) } + var lastSignedTx by remember { mutableStateOf(null) } + var eventSent by remember { mutableStateOf(false) } + + // Build and submit LaunchedEffect(Unit) { submissionStartTime = System.currentTimeMillis() submissionState = SubmissionState.Submitting @@ -84,6 +96,8 @@ class PaymentProgressPresenter @AssistedInject constructor( transactionBuilder.buildAndSign(request).onSuccess { signedTx -> Timber.tag(TAG).d("Transaction built successfully, hash: ${signedTx.txHash}") txHash = signedTx.txHash + lastRequest = request + lastSignedTx = signedTx cardanoClient.submitTx(signedTx.txCbor).onSuccess { submittedHash -> Timber.tag(TAG).i("Transaction submitted: $submittedHash") @@ -100,6 +114,7 @@ class PaymentProgressPresenter @AssistedInject constructor( } } + // Poll for confirmation val currentTxHash = txHash LaunchedEffect(currentTxHash) { if (currentTxHash == null) return@LaunchedEffect @@ -130,6 +145,36 @@ class PaymentProgressPresenter @AssistedInject constructor( } } + // Send Matrix event on confirmation + LaunchedEffect(submissionState, eventSent) { + if (submissionState == SubmissionState.Confirmed && !eventSent) { + val req = lastRequest ?: return@LaunchedEffect + val signedTx = lastSignedTx ?: return@LaunchedEffect + + eventSent = true + + val room = matrixClient.getRoom(roomId) + val joinedRoom = room as? JoinedRoom + val timeline = joinedRoom?.liveTimeline + + if (timeline != null) { + paymentEventSender.sendPaymentEvent( + timeline = timeline, + request = req, + signedTx = signedTx, + network = CardanoNetworkConfig.NETWORK_NAME, + ).onSuccess { + Timber.tag(TAG).i("Payment event sent to timeline") + }.onFailure { e -> + Timber.tag(TAG).e(e, "Failed to send payment event to timeline") + // Non-fatal - tx succeeded, just event didn't send + } + } else { + Timber.tag(TAG).w("Could not get room timeline to send payment event") + } + } + } + val explorerUrl = txHash?.let { "${CardanoNetworkConfig.EXPLORER_BASE_URL}/transaction/$it" } diff --git a/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/setup/WalletSetupNode.kt b/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/setup/WalletSetupNode.kt new file mode 100644 index 0000000000..93d503d9ad --- /dev/null +++ b/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/setup/WalletSetupNode.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2026 Sulkta Coop. + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package io.element.android.features.wallet.impl.setup + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import io.element.android.annotations.ContributesNode +import io.element.android.libraries.architecture.callback +import io.element.android.libraries.di.SessionScope + +@ContributesNode(SessionScope::class) +class WalletSetupNode @AssistedInject constructor( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + private val presenter: WalletSetupPresenter, +) : Node(buildContext = buildContext, plugins = plugins) { + + interface Callback : Plugin { + fun onSetupComplete() + fun onBack() + } + + private val callback: Callback = callback() + + @Composable + override fun View(modifier: Modifier) { + val state = presenter.present() + + WalletSetupView( + state = state, + onComplete = { callback.onSetupComplete() }, + onBack = { callback.onBack() }, + modifier = modifier, + ) + } +} diff --git a/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/setup/WalletSetupPresenter.kt b/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/setup/WalletSetupPresenter.kt new file mode 100644 index 0000000000..6063e92324 --- /dev/null +++ b/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/setup/WalletSetupPresenter.kt @@ -0,0 +1,124 @@ +/* + * Copyright (c) 2026 Sulkta Coop. + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package io.element.android.features.wallet.impl.setup + +import androidx.compose.runtime.Composable +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.Inject +import io.element.android.features.wallet.api.storage.CardanoKeyStorage +import io.element.android.features.wallet.impl.cardano.CardanoWalletManager +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.matrix.api.MatrixClient +import kotlinx.coroutines.launch +import timber.log.Timber + +// TODO: Phase 5 - Add optional SSSS backup +// When Matrix SDK exposes setAccountData, store encrypted mnemonic +// under m.cross_signing.user_signing_key or custom type. +// For alpha: wallet backup is LOCAL ONLY (device-bound). +// User must write down mnemonic manually. + +class WalletSetupPresenter @Inject constructor( + private val keyStorage: CardanoKeyStorage, + private val walletManager: CardanoWalletManager, + private val matrixClient: MatrixClient, +) : Presenter { + + companion object { + private const val TAG = "WalletSetupPresenter" + } + + @Composable + override fun present(): WalletSetupState { + val scope = rememberCoroutineScope() + val sessionId = matrixClient.sessionId + + var step by remember { mutableStateOf(SetupStep.WELCOME) } + var generatedMnemonic by remember { mutableStateOf>(emptyList()) } + var generatedAddress by remember { mutableStateOf(null) } + var isGenerating by remember { mutableStateOf(false) } + var error by remember { mutableStateOf(null) } + var hasConfirmedBackup by remember { mutableStateOf(false) } + + fun handleEvent(event: WalletSetupEvent) { + when (event) { + WalletSetupEvent.CreateNewWallet -> { + step = SetupStep.GENERATING + isGenerating = true + error = null + + scope.launch { + keyStorage.generateWallet(sessionId) + .onSuccess { result -> + Timber.tag(TAG).i("Wallet generated: ${result.baseAddress.take(20)}...") + generatedMnemonic = result.mnemonic + generatedAddress = result.baseAddress + isGenerating = false + step = SetupStep.SHOW_ADDRESS + } + .onFailure { e -> + Timber.tag(TAG).e(e, "Failed to generate wallet") + error = e.message ?: "Failed to generate wallet" + isGenerating = false + step = SetupStep.WELCOME + } + } + } + + WalletSetupEvent.ImportExistingWallet -> { + // TODO: Navigate to import flow (out of scope for alpha) + // For now, just show an error + error = "Import not yet supported. Please create a new wallet." + } + + WalletSetupEvent.ProceedToBackup -> { + step = SetupStep.BACKUP_PROMPT + } + + WalletSetupEvent.ConfirmBackup -> { + hasConfirmedBackup = true + step = SetupStep.COMPLETE + + // Reinitialize wallet manager so panel sees the new wallet + scope.launch { + walletManager.initialize(sessionId) + } + } + + WalletSetupEvent.Complete -> { + // Callback handled by node + } + + WalletSetupEvent.Back -> { + when (step) { + SetupStep.SHOW_ADDRESS -> step = SetupStep.WELCOME + SetupStep.BACKUP_PROMPT -> step = SetupStep.SHOW_ADDRESS + else -> { /* Let node handle close */ } + } + } + + WalletSetupEvent.DismissError -> { + error = null + } + } + } + + return WalletSetupState( + step = step, + generatedMnemonic = generatedMnemonic, + generatedAddress = generatedAddress, + isGenerating = isGenerating, + error = error, + hasConfirmedBackup = hasConfirmedBackup, + eventSink = ::handleEvent, + ) + } +} diff --git a/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/setup/WalletSetupState.kt b/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/setup/WalletSetupState.kt new file mode 100644 index 0000000000..770dda9549 --- /dev/null +++ b/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/setup/WalletSetupState.kt @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2026 Sulkta Coop. + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package io.element.android.features.wallet.impl.setup + +import androidx.compose.runtime.Immutable + +@Immutable +data class WalletSetupState( + val step: SetupStep, + val generatedMnemonic: List, + val generatedAddress: String?, + val isGenerating: Boolean, + val error: String?, + val hasConfirmedBackup: Boolean, + val eventSink: (WalletSetupEvent) -> Unit, +) + +enum class SetupStep { + WELCOME, // "Create New Wallet" or "Import Existing" + GENERATING, // Spinning while generating keys + SHOW_ADDRESS, // Display the derived address + BACKUP_PROMPT, // Show mnemonic with "I've backed it up" checkbox + COMPLETE, // Done - ready to close +} + +sealed interface WalletSetupEvent { + data object CreateNewWallet : WalletSetupEvent + data object ImportExistingWallet : WalletSetupEvent + data object ProceedToBackup : WalletSetupEvent + data object ConfirmBackup : WalletSetupEvent + data object Complete : WalletSetupEvent + data object Back : WalletSetupEvent + data object DismissError : WalletSetupEvent +} diff --git a/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/setup/WalletSetupView.kt b/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/setup/WalletSetupView.kt new file mode 100644 index 0000000000..a84d75c99e --- /dev/null +++ b/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/setup/WalletSetupView.kt @@ -0,0 +1,368 @@ +/* + * Copyright (c) 2026 Sulkta Coop. + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package io.element.android.features.wallet.impl.setup + +import android.view.WindowManager +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.systemBarsPadding +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.itemsIndexed +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.Download +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Checkbox +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import io.element.android.libraries.designsystem.theme.components.Button +import io.element.android.libraries.designsystem.theme.components.IconSource +import io.element.android.libraries.designsystem.theme.components.OutlinedButton + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun WalletSetupView( + state: WalletSetupState, + onComplete: () -> Unit, + onBack: () -> Unit, + modifier: Modifier = Modifier, +) { + // FLAG_SECURE when showing mnemonic + val view = LocalView.current + DisposableEffect(state.step) { + if (state.step == SetupStep.BACKUP_PROMPT) { + val window = (view.context as? android.app.Activity)?.window + window?.addFlags(WindowManager.LayoutParams.FLAG_SECURE) + onDispose { window?.clearFlags(WindowManager.LayoutParams.FLAG_SECURE) } + } else { + onDispose { } + } + } + + Scaffold( + modifier = modifier.fillMaxSize().systemBarsPadding(), + topBar = { + TopAppBar( + title = { Text("Set Up Wallet") }, + navigationIcon = { + if (state.step != SetupStep.COMPLETE) { + IconButton(onClick = { + if (state.step == SetupStep.WELCOME) { + onBack() + } else { + state.eventSink(WalletSetupEvent.Back) + } + }) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + } + } + ) + } + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .padding(horizontal = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + when (state.step) { + SetupStep.WELCOME -> WelcomeContent(state) + SetupStep.GENERATING -> GeneratingContent() + SetupStep.SHOW_ADDRESS -> AddressContent(state) + SetupStep.BACKUP_PROMPT -> BackupContent(state) + SetupStep.COMPLETE -> CompleteContent(onComplete) + } + } + } +} + +@Composable +private fun ColumnScope.WelcomeContent(state: WalletSetupState) { + Spacer(modifier = Modifier.height(48.dp)) + + Text( + text = "Create Your Wallet", + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = "Your Cardano wallet will be secured with your device's biometric authentication.", + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Spacer(modifier = Modifier.weight(1f)) + + Button( + text = "Create New Wallet", + onClick = { state.eventSink(WalletSetupEvent.CreateNewWallet) }, + modifier = Modifier.fillMaxWidth(), + leadingIcon = IconSource.Vector(Icons.Default.Add), + ) + + Spacer(modifier = Modifier.height(12.dp)) + + OutlinedButton( + text = "Import Existing Wallet", + onClick = { state.eventSink(WalletSetupEvent.ImportExistingWallet) }, + modifier = Modifier.fillMaxWidth(), + leadingIcon = IconSource.Vector(Icons.Default.Download), + ) + + Spacer(modifier = Modifier.height(32.dp)) + + state.error?.let { error -> + Card( + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer), + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = error, + modifier = Modifier.padding(16.dp), + color = MaterialTheme.colorScheme.onErrorContainer, + ) + } + Spacer(modifier = Modifier.height(16.dp)) + } +} + +@Composable +private fun ColumnScope.GeneratingContent() { + Spacer(modifier = Modifier.weight(1f)) + + CircularProgressIndicator(modifier = Modifier.size(64.dp)) + + Spacer(modifier = Modifier.height(24.dp)) + + Text( + text = "Generating Wallet...", + style = MaterialTheme.typography.titleLarge, + ) + + Text( + text = "Creating secure keys", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Spacer(modifier = Modifier.weight(1f)) +} + +@Composable +private fun ColumnScope.AddressContent(state: WalletSetupState) { + Spacer(modifier = Modifier.height(48.dp)) + + Icon( + imageVector = Icons.Default.CheckCircle, + contentDescription = null, + modifier = Modifier.size(64.dp), + tint = MaterialTheme.colorScheme.primary, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = "Wallet Created!", + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + ) + + Spacer(modifier = Modifier.height(24.dp)) + + Text( + text = "Your Cardano Address:", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Card( + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = state.generatedAddress ?: "", + modifier = Modifier.padding(16.dp), + fontFamily = FontFamily.Monospace, + style = MaterialTheme.typography.bodySmall, + ) + } + + Spacer(modifier = Modifier.weight(1f)) + + Button( + text = "Continue to Backup", + onClick = { state.eventSink(WalletSetupEvent.ProceedToBackup) }, + modifier = Modifier.fillMaxWidth(), + ) + + Spacer(modifier = Modifier.height(32.dp)) +} + +@Composable +private fun ColumnScope.BackupContent(state: WalletSetupState) { + var isChecked by remember { mutableStateOf(false) } + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = "Back Up Your Wallet", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.5f) + ), + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = "⚠️ Write down these 24 words in order. Anyone with this phrase can access your funds.", + modifier = Modifier.padding(12.dp), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onErrorContainer, + ) + } + + Spacer(modifier = Modifier.height(16.dp)) + + LazyVerticalGrid( + columns = GridCells.Fixed(3), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.weight(1f), + ) { + itemsIndexed(state.generatedMnemonic) { index, word -> + Card( + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), + ) { + Row( + modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "${index + 1}.", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = word, + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + modifier = Modifier.padding(start = 4.dp), + ) + } + } + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + Checkbox( + checked = isChecked, + onCheckedChange = { isChecked = it }, + ) + Text( + text = "I have written down my recovery phrase", + style = MaterialTheme.typography.bodyMedium, + ) + } + + Spacer(modifier = Modifier.height(16.dp)) + + Button( + text = "Complete Setup", + onClick = { state.eventSink(WalletSetupEvent.ConfirmBackup) }, + enabled = isChecked, + modifier = Modifier.fillMaxWidth(), + ) + + Spacer(modifier = Modifier.height(32.dp)) +} + +@Composable +private fun ColumnScope.CompleteContent(onComplete: () -> Unit) { + Spacer(modifier = Modifier.weight(1f)) + + Icon( + imageVector = Icons.Default.CheckCircle, + contentDescription = null, + modifier = Modifier.size(80.dp), + tint = MaterialTheme.colorScheme.primary, + ) + + Spacer(modifier = Modifier.height(24.dp)) + + Text( + text = "You're All Set!", + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = "Your wallet is ready to use.", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Spacer(modifier = Modifier.weight(1f)) + + Button( + text = "Done", + onClick = onComplete, + modifier = Modifier.fillMaxWidth(), + ) + + Spacer(modifier = Modifier.height(32.dp)) +}