From e33c87c16457196488deade3c933db56136f892c Mon Sep 17 00:00:00 2001 From: Kayos Date: Sat, 28 Mar 2026 09:23:58 -0700 Subject: [PATCH] Phase 3: Wallet panel UI and full /pay flow wiring - Add WalletPanelView with 4 tabs (Overview, Assets, History, Settings) - Overview tab shows balance, QR code for receiving, and Send ADA button - Assets tab shows native tokens held at address - History tab shows recent transactions with explorer links - Settings tab shows address, network, and backup/delete options - Add NativeAsset and TxSummary models to wallet API - Add getAddressAssets() and getAddressTransactions() to CardanoClient - Implement new methods in KoiosCardanoClient and FakeCardanoClient - Add wallet button to MessagesViewTopBar (DM rooms only) - Add isDmRoom to MessagesState for conditional UI - Wire navigateToWallet() callback through to MessagesFlowNode - Add NavTarget.WalletPanel and WalletPanelNode integration - Add string resources for wallet panel UI Known limitations: - Uses Chart icon as placeholder for wallet (Compound lacks wallet icon) - Wallet setup flow not implemented (TODO) - Transaction amounts in history need additional API calls to calculate --- .../messages/impl/MessagesFlowNode.kt | 25 ++ .../features/messages/impl/MessagesNode.kt | 2 + .../messages/impl/MessagesPresenter.kt | 1 + .../features/messages/impl/MessagesState.kt | 1 + .../messages/impl/MessagesStateProvider.kt | 2 + .../features/messages/impl/MessagesView.kt | 7 + .../MessagesViewWithIdentityChangePreview.kt | 1 + .../impl/threads/ThreadedMessagesNode.kt | 1 + .../impl/topbars/MessagesViewTopBar.kt | 18 +- .../features/wallet/api/CardanoClient.kt | 17 ++ .../features/wallet/api/NativeAsset.kt | 48 ++++ .../android/features/wallet/api/TxSummary.kt | 81 ++++++ features/wallet/impl/build.gradle.kts | 2 + .../wallet/impl/cardano/KoiosCardanoClient.kt | 55 +++++ .../wallet/impl/panel/WalletPanelNode.kt | 76 ++++++ .../wallet/impl/panel/WalletPanelPresenter.kt | 152 ++++++++++++ .../wallet/impl/panel/WalletPanelState.kt | 90 +++++++ .../wallet/impl/panel/WalletPanelView.kt | 204 +++++++++++++++ .../wallet/impl/panel/tabs/AssetsTabView.kt | 153 ++++++++++++ .../wallet/impl/panel/tabs/HistoryTabView.kt | 206 ++++++++++++++++ .../wallet/impl/panel/tabs/OverviewTabView.kt | 233 ++++++++++++++++++ .../wallet/impl/panel/tabs/SettingsTabView.kt | 223 +++++++++++++++++ .../impl/src/main/res/values/strings.xml | 50 ++++ .../features/wallet/test/FakeCardanoClient.kt | 38 +++ 24 files changed, 1685 insertions(+), 1 deletion(-) create mode 100644 features/wallet/api/src/main/kotlin/io/element/android/features/wallet/api/NativeAsset.kt create mode 100644 features/wallet/api/src/main/kotlin/io/element/android/features/wallet/api/TxSummary.kt create mode 100644 features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/panel/WalletPanelNode.kt create mode 100644 features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/panel/WalletPanelPresenter.kt create mode 100644 features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/panel/WalletPanelState.kt create mode 100644 features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/panel/WalletPanelView.kt create mode 100644 features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/panel/tabs/AssetsTabView.kt create mode 100644 features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/panel/tabs/HistoryTabView.kt create mode 100644 features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/panel/tabs/OverviewTabView.kt create mode 100644 features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/panel/tabs/SettingsTabView.kt create mode 100644 features/wallet/impl/src/main/res/values/strings.xml 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 84ef5c4499..774f483356 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 @@ -53,6 +53,7 @@ import io.element.android.features.messages.impl.timeline.model.event.duration 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.libraries.architecture.BackstackWithOverlayBox import io.element.android.libraries.architecture.BaseFlowNode import io.element.android.libraries.architecture.callback @@ -182,6 +183,9 @@ class MessagesFlowNode( @Parcelize data class Thread(val threadRootId: ThreadId, val focusedEventId: EventId?) : NavTarget + @Parcelize + data object WalletPanel : NavTarget + @Parcelize data class PaymentFlow( val roomId: RoomId, @@ -304,6 +308,10 @@ class MessagesFlowNode( backstack.push(NavTarget.Thread(threadRootId, focusedEventId)) } + override fun navigateToWallet() { + backstack.push(NavTarget.WalletPanel) + } + override fun navigateToPaymentFlow( roomId: RoomId, recipientUserId: UserId?, @@ -533,6 +541,23 @@ class MessagesFlowNode( } createNode(buildContext, listOf(inputs, callback)) } + is NavTarget.WalletPanel -> { + val walletPanelCallback = object : WalletPanelNode.Callback { + override fun onClose() { + backstack.pop() + } + + override fun onSendAda() { + backstack.pop() + backstack.push(NavTarget.PaymentFlow(room.roomId, null, null, null)) + } + + override fun onSetupWallet() { + // TODO: Navigate to wallet setup flow + } + } + createNode(buildContext, listOf(walletPanelCallback)) + } is NavTarget.PaymentFlow -> { val walletCallback = object : WalletEntryPoint.Callback { override fun onPaymentSent(txHash: String) { diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt index 780904bd8f..41ccb686cd 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt @@ -130,6 +130,7 @@ class MessagesNode( fun navigateToRoomDetails() fun navigateToPinnedMessagesList() fun navigateToKnockRequestsList() + fun navigateToWallet() fun navigateToPaymentFlow(roomId: RoomId, recipientUserId: UserId?, recipientAddress: String?, amountLovelace: Long?) } @@ -293,6 +294,7 @@ class MessagesNode( callback.navigateToRoomCall(room.roomId, isAudioCall) }, onViewAllPinnedMessagesClick = callback::navigateToPinnedMessagesList, + onWalletClick = callback::navigateToWallet, modifier = modifier, knockRequestsBannerView = { knockRequestsBannerRenderer.View( diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt index d9c3d17afa..c71144529d 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt @@ -295,6 +295,7 @@ class MessagesPresenter( dmUserVerificationState = dmUserVerificationState, roomMemberModerationState = roomMemberModerationState, topBarSharedHistoryIcon = topBarSharedHistoryIcon, + isDmRoom = roomInfo.isDm, successorRoom = roomInfo.successorRoom, eventSink = ::handleEvent, ) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesState.kt index c18fb461e0..1b67cb6929 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesState.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesState.kt @@ -56,6 +56,7 @@ data class MessagesState( val roomMemberModerationState: RoomMemberModerationState, /** Type of "shared history" icon to show in the top bar. */ val topBarSharedHistoryIcon: SharedHistoryIcon, + val isDmRoom: Boolean, val successorRoom: SuccessorRoom?, val eventSink: (MessagesEvent) -> Unit ) { diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt index d969ae1491..e9555f656d 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt @@ -121,6 +121,7 @@ fun aMessagesState( dmUserVerificationState: IdentityState? = null, roomMemberModerationState: RoomMemberModerationState = aRoomMemberModerationState(), topBarSharedHistoryIcon: SharedHistoryIcon = SharedHistoryIcon.NONE, + isDmRoom: Boolean = false, successorRoom: SuccessorRoom? = null, eventSink: (MessagesEvent) -> Unit = {}, ) = MessagesState( @@ -149,6 +150,7 @@ fun aMessagesState( dmUserVerificationState = dmUserVerificationState, roomMemberModerationState = roomMemberModerationState, topBarSharedHistoryIcon = topBarSharedHistoryIcon, + isDmRoom = isDmRoom, successorRoom = successorRoom, eventSink = eventSink, ) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt index 0caebea8d5..2bb320fdba 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt @@ -131,6 +131,7 @@ fun MessagesView( onSendLocationClick: () -> Unit, onCreatePollClick: () -> Unit, onJoinCallClick: (isAudioCall: Boolean) -> Unit, + onWalletClick: () -> Unit, onViewAllPinnedMessagesClick: () -> Unit, modifier: Modifier = Modifier, forceJumpToBottomVisibility: Boolean = false, @@ -226,9 +227,11 @@ fun MessagesView( roomCallState = state.roomCallState, dmUserIdentityState = state.dmUserVerificationState, sharedHistoryIcon = state.topBarSharedHistoryIcon, + isDmRoom = state.isDmRoom, onBackClick = { hidingKeyboard { onBackClick() } }, onRoomDetailsClick = { hidingKeyboard { onRoomDetailsClick() } }, onJoinCallClick = onJoinCallClick, + onWalletClick = onWalletClick, ) } }, @@ -268,6 +271,7 @@ fun MessagesView( }, forceJumpToBottomVisibility = forceJumpToBottomVisibility, onJoinCallClick = onJoinCallClick, + onWalletClick = onWalletClick, onViewAllPinnedMessagesClick = onViewAllPinnedMessagesClick, knockRequestsBannerView = knockRequestsBannerView, ) @@ -424,6 +428,7 @@ private fun MessagesViewContent( onSendLocationClick: () -> Unit, onCreatePollClick: () -> Unit, onJoinCallClick: (isAudioCall: Boolean) -> Unit, + onWalletClick: () -> Unit, onViewAllPinnedMessagesClick: () -> Unit, forceJumpToBottomVisibility: Boolean, onSwipeToReply: (TimelineItem.Event) -> Unit, @@ -592,6 +597,7 @@ internal fun MessagesViewPreview(@PreviewParameter(MessagesStateProvider::class) onSendLocationClick = {}, onCreatePollClick = {}, onJoinCallClick = {}, + onWalletClick = {}, onViewAllPinnedMessagesClick = { }, forceJumpToBottomVisibility = true, knockRequestsBannerView = {}, @@ -646,6 +652,7 @@ internal fun MessagesViewA11yPreview() = ElementPreview { onSendLocationClick = {}, onCreatePollClick = {}, onJoinCallClick = {}, + onWalletClick = {}, onViewAllPinnedMessagesClick = { }, forceJumpToBottomVisibility = true, knockRequestsBannerView = {}, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/identity/MessagesViewWithIdentityChangePreview.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/identity/MessagesViewWithIdentityChangePreview.kt index b434656f7a..1ca1df0393 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/identity/MessagesViewWithIdentityChangePreview.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/identity/MessagesViewWithIdentityChangePreview.kt @@ -40,6 +40,7 @@ internal fun MessagesViewWithIdentityChangePreview( onSendLocationClick = {}, onCreatePollClick = {}, onJoinCallClick = {}, + onWalletClick = {}, onViewAllPinnedMessagesClick = {}, knockRequestsBannerView = {} ) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/threads/ThreadedMessagesNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/threads/ThreadedMessagesNode.kt index 8dc21d4f40..e1c053f259 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/threads/ThreadedMessagesNode.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/threads/ThreadedMessagesNode.kt @@ -298,6 +298,7 @@ class ThreadedMessagesNode( onJoinCallClick = { isAudioCall -> callback.navigateToRoomCall(room.roomId, isAudioCall) }, + onWalletClick = {}, onViewAllPinnedMessagesClick = {}, modifier = modifier, knockRequestsBannerView = {}, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/topbars/MessagesViewTopBar.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/topbars/MessagesViewTopBar.kt index 24cd71ae84..af5a5fffa3 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/topbars/MessagesViewTopBar.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/topbars/MessagesViewTopBar.kt @@ -45,6 +45,7 @@ import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.theme.components.HorizontalDivider import io.element.android.libraries.designsystem.theme.components.Icon +import io.element.android.libraries.designsystem.theme.components.IconButton import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.TopAppBar import io.element.android.libraries.matrix.api.encryption.identity.IdentityState @@ -65,8 +66,10 @@ internal fun MessagesViewTopBar( roomCallState: RoomCallState, dmUserIdentityState: IdentityState?, sharedHistoryIcon: SharedHistoryIcon, + isDmRoom: Boolean, onRoomDetailsClick: () -> Unit, onJoinCallClick: (isAudioCall: Boolean) -> Unit, + onWalletClick: () -> Unit, onBackClick: () -> Unit, modifier: Modifier = Modifier, ) { @@ -127,6 +130,15 @@ internal fun MessagesViewTopBar( } }, actions = { + // Wallet button - only show in DM rooms + if (isDmRoom) { + IconButton(onClick = onWalletClick) { + Icon( + imageVector = CompoundIcons.Chart(), + contentDescription = "Cardano Wallet", + ) + } + } CallMenuItem( roomCallState = roomCallState, onJoinCallClick = onJoinCallClick, @@ -186,6 +198,7 @@ internal fun MessagesViewTopBarPreview() = ElementPreview { roomCallState: RoomCallState = RoomCallState.Unavailable, dmUserIdentityState: IdentityState? = null, sharedHistoryIcon: SharedHistoryIcon = SharedHistoryIcon.NONE, + isDmRoom: Boolean = false, ) = MessagesViewTopBar( roomName = roomName, roomAvatar = roomAvatar, @@ -194,8 +207,10 @@ internal fun MessagesViewTopBarPreview() = ElementPreview { roomCallState = roomCallState, dmUserIdentityState = dmUserIdentityState, sharedHistoryIcon = sharedHistoryIcon, + isDmRoom = isDmRoom, onRoomDetailsClick = {}, onJoinCallClick = {}, + onWalletClick = {}, onBackClick = {}, ) Column { @@ -218,7 +233,8 @@ internal fun MessagesViewTopBarPreview() = ElementPreview { url = "https://some-avatar.jpg" ), roomCallState = aStandByCallState(canStartCall = false), - dmUserIdentityState = IdentityState.Verified + dmUserIdentityState = IdentityState.Verified, + isDmRoom = true, ) HorizontalDivider() AMessagesViewTopBar( diff --git a/features/wallet/api/src/main/kotlin/io/element/android/features/wallet/api/CardanoClient.kt b/features/wallet/api/src/main/kotlin/io/element/android/features/wallet/api/CardanoClient.kt index 20940aa73d..a74f15a377 100644 --- a/features/wallet/api/src/main/kotlin/io/element/android/features/wallet/api/CardanoClient.kt +++ b/features/wallet/api/src/main/kotlin/io/element/android/features/wallet/api/CardanoClient.kt @@ -53,4 +53,21 @@ interface CardanoClient { * @return Current [ProtocolParameters] from the latest epoch */ suspend fun getProtocolParameters(): Result + + /** + * Get native assets (tokens) for a given address. + * + * @param address Bech32 Cardano address + * @return List of [NativeAsset] objects + */ + suspend fun getAddressAssets(address: String): Result> + + /** + * Get transaction history for a given address. + * + * @param address Bech32 Cardano address + * @param limit Maximum number of transactions to return (default 20) + * @return List of [TxSummary] objects, most recent first + */ + suspend fun getAddressTransactions(address: String, limit: Int = 20): Result> } diff --git a/features/wallet/api/src/main/kotlin/io/element/android/features/wallet/api/NativeAsset.kt b/features/wallet/api/src/main/kotlin/io/element/android/features/wallet/api/NativeAsset.kt new file mode 100644 index 0000000000..b9b132c2f8 --- /dev/null +++ b/features/wallet/api/src/main/kotlin/io/element/android/features/wallet/api/NativeAsset.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2026 Sulkta Coop. + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package io.element.android.features.wallet.api + +/** + * Represents a native asset (token) on Cardano. + * + * @property policyId The minting policy ID (hex) + * @property assetName The asset name (hex or decoded) + * @property quantity The amount of this asset + * @property displayName Human-readable name if available + * @property fingerprint The asset fingerprint (CIP-14) + */ +data class NativeAsset( + val policyId: String, + val assetName: String, + val quantity: Long, + val displayName: String?, + val fingerprint: String?, +) { + /** + * Truncated policy ID for display. + */ + val truncatedPolicyId: String + get() = if (policyId.length > 16) { + "${policyId.take(8)}...${policyId.takeLast(8)}" + } else { + policyId + } + + /** + * Display name, falling back to truncated asset name. + */ + val name: String + get() = displayName ?: assetName.takeIf { it.isNotEmpty() }?.let { + // Try to decode hex to ASCII if it looks printable + try { + val decoded = it.chunked(2).map { hex -> hex.toInt(16).toChar() }.joinToString("") + if (decoded.all { c -> c.isLetterOrDigit() || c in " -_" }) decoded else it + } catch (_: Exception) { + it + } + } ?: "Unknown" +} diff --git a/features/wallet/api/src/main/kotlin/io/element/android/features/wallet/api/TxSummary.kt b/features/wallet/api/src/main/kotlin/io/element/android/features/wallet/api/TxSummary.kt new file mode 100644 index 0000000000..8fb5c01026 --- /dev/null +++ b/features/wallet/api/src/main/kotlin/io/element/android/features/wallet/api/TxSummary.kt @@ -0,0 +1,81 @@ +/* + * Copyright (c) 2026 Sulkta Coop. + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package io.element.android.features.wallet.api + +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter + +/** + * Summary of a Cardano transaction for history display. + * + * @property txHash The transaction hash + * @property blockTime Unix timestamp when the tx was included in a block + * @property totalOutput Total output in lovelace + * @property fee Transaction fee in lovelace + * @property direction Whether this was sent or received + */ +data class TxSummary( + val txHash: String, + val blockTime: Long, + val totalOutput: Long, + val fee: Long, + val direction: Direction, +) { + enum class Direction { + SENT, + RECEIVED, + } + + /** + * Formatted date for display. + */ + val formattedDate: String + get() = try { + val instant = Instant.ofEpochSecond(blockTime) + val formatter = DateTimeFormatter.ofPattern("MMM d, yyyy") + .withZone(ZoneId.systemDefault()) + formatter.format(instant) + } catch (_: Exception) { + "Unknown date" + } + + /** + * Truncated tx hash for display. + */ + val truncatedTxHash: String + get() = if (txHash.length > 16) { + "${txHash.take(8)}...${txHash.takeLast(8)}" + } else { + txHash + } + + /** + * Amount formatted as ADA. + */ + val amountAda: String + get() { + val ada = totalOutput / 1_000_000.0 + return if (ada == ada.toLong().toDouble()) { + "${ada.toLong()} ADA" + } else { + val formatted = "%.6f".format(ada).trimEnd('0').trimEnd('.') + "$formatted ADA" + } + } + + /** + * Explorer URL for this transaction. + */ + fun explorerUrl(isTestnet: Boolean): String { + return if (isTestnet) { + "https://preprod.cardanoscan.io/transaction/$txHash" + } else { + "https://cardanoscan.io/transaction/$txHash" + } + } +} diff --git a/features/wallet/impl/build.gradle.kts b/features/wallet/impl/build.gradle.kts index b52c0d1d45..ed3a49f2f4 100644 --- a/features/wallet/impl/build.gradle.kts +++ b/features/wallet/impl/build.gradle.kts @@ -44,6 +44,8 @@ dependencies { // JSON implementation(libs.serialization.json) + // QR code generation + implementation(libs.google.zxing) // Coroutines implementation(libs.coroutines.core) diff --git a/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/cardano/KoiosCardanoClient.kt b/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/cardano/KoiosCardanoClient.kt index 15401060bd..80110e7637 100644 --- a/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/cardano/KoiosCardanoClient.kt +++ b/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/cardano/KoiosCardanoClient.kt @@ -12,8 +12,10 @@ import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.Inject import io.element.android.features.wallet.api.CardanoClient import io.element.android.features.wallet.api.CardanoException +import io.element.android.features.wallet.api.NativeAsset import io.element.android.features.wallet.api.ProtocolParameters import io.element.android.features.wallet.api.TxStatus +import io.element.android.features.wallet.api.TxSummary import io.element.android.features.wallet.api.Utxo import io.element.android.libraries.di.SessionScope import kotlinx.coroutines.Dispatchers @@ -168,6 +170,59 @@ class KoiosCardanoClient @Inject constructor() : CardanoClient { } } + override suspend fun getAddressAssets(address: String): Result> = + withRetry("getAddressAssets($address)") { + withContext(Dispatchers.IO) { + throttleRequest() + + val result = backendService.addressService.getAddressInfo(address) + if (result.isSuccessful) { + val info = result.value + val assets = info.amount + ?.filter { it.unit != "lovelace" } + ?.map { amount -> + // Unit format is policyId + assetNameHex + val policyId = amount.unit.take(56) + val assetNameHex = amount.unit.drop(56) + NativeAsset( + policyId = policyId, + assetName = assetNameHex, + quantity = amount.quantity?.toLong() ?: 0L, + displayName = null, + fingerprint = null, + ) + } + ?: emptyList() + Result.success(assets) + } else { + Result.failure(parseError(result.response)) + } + } + } + + override suspend fun getAddressTransactions(address: String, limit: Int): Result> = + withRetry("getAddressTransactions($address)") { + withContext(Dispatchers.IO) { + throttleRequest() + + val result = backendService.addressService.getTransactions(address, limit, 1, null) + if (result.isSuccessful) { + val txs = result.value.map { tx -> + TxSummary( + txHash = tx.txHash, + blockTime = tx.blockTime ?: 0L, + totalOutput = 0L, // Would need additional API call to get output amount + fee = 0L, // Would need additional API call + direction = TxSummary.Direction.RECEIVED, // Simplified - would need UTXO analysis + ) + } + Result.success(txs) + } else { + Result.failure(parseError(result.response)) + } + } + } + private suspend fun withRetry( operation: String, block: suspend () -> Result, diff --git a/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/panel/WalletPanelNode.kt b/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/panel/WalletPanelNode.kt new file mode 100644 index 0000000000..ca1d38a46b --- /dev/null +++ b/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/panel/WalletPanelNode.kt @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2026 Sulkta Coop. + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package io.element.android.features.wallet.impl.panel + +import android.content.Intent +import android.net.Uri +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +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.features.wallet.impl.cardano.CardanoNetworkConfig +import io.element.android.libraries.architecture.NodeInputs +import io.element.android.libraries.architecture.callback +import io.element.android.libraries.di.SessionScope + +/** + * Node for displaying the wallet panel. + */ +@ContributesNode(SessionScope::class) +class WalletPanelNode @AssistedInject constructor( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + private val presenter: WalletPanelPresenter, +) : Node( + buildContext = buildContext, + plugins = plugins, +) { + /** + * Callback interface for wallet panel navigation events. + */ + interface Callback : Plugin { + fun onClose() + fun onSendAda() + fun onSetupWallet() + } + + private val callback: Callback = callback() + + @Composable + override fun View(modifier: Modifier) { + val state = presenter.present() + val context = LocalContext.current + + WalletPanelView( + state = state.copy( + eventSink = { event -> + when (event) { + is WalletPanelEvent.OpenTransaction -> { + val url = if (CardanoNetworkConfig.NETWORK_NAME != "mainnet") { + "https://preprod.cardanoscan.io/transaction/${event.txHash}" + } else { + "https://cardanoscan.io/transaction/${event.txHash}" + } + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)) + context.startActivity(intent) + } + else -> state.eventSink(event) + } + } + ), + onBackClick = { callback.onClose() }, + onSendClick = { callback.onSendAda() }, + onSetupClick = { callback.onSetupWallet() }, + modifier = modifier, + ) + } +} diff --git a/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/panel/WalletPanelPresenter.kt b/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/panel/WalletPanelPresenter.kt new file mode 100644 index 0000000000..09f6fe2952 --- /dev/null +++ b/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/panel/WalletPanelPresenter.kt @@ -0,0 +1,152 @@ +/* + * Copyright (c) 2026 Sulkta Coop. + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package io.element.android.features.wallet.impl.panel + +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.Inject +import io.element.android.features.wallet.api.CardanoClient +import io.element.android.features.wallet.api.NativeAsset +import io.element.android.features.wallet.api.TxSummary +import io.element.android.features.wallet.impl.cardano.CardanoNetworkConfig +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 + +/** + * Presenter for the wallet panel. + */ +class WalletPanelPresenter @Inject constructor( + private val walletManager: CardanoWalletManager, + private val cardanoClient: CardanoClient, + private val matrixClient: MatrixClient, +) : Presenter { + + @Composable + override fun present(): WalletPanelState { + val walletState by walletManager.walletState.collectAsState() + val scope = rememberCoroutineScope() + + var assets by remember { mutableStateOf>(emptyList()) } + var transactions by remember { mutableStateOf>(emptyList()) } + var isLoading by remember { mutableStateOf(true) } + var error by remember { mutableStateOf(null) } + + // Initialize wallet on first composition + LaunchedEffect(Unit) { + walletManager.initialize(matrixClient.sessionId) + } + + // Load assets and transactions when we have an address + LaunchedEffect(walletState.address) { + val address = walletState.address ?: return@LaunchedEffect + + isLoading = true + error = null + + try { + // Fetch balance + val balanceResult = cardanoClient.getBalance(address) + balanceResult.onSuccess { balance -> + walletManager.refreshBalance(matrixClient.sessionId, balance) + } + + // Fetch assets + cardanoClient.getAddressAssets(address) + .onSuccess { assets = it } + .onFailure { Timber.w(it, "Failed to fetch assets") } + + // Fetch transactions + cardanoClient.getAddressTransactions(address, 20) + .onSuccess { transactions = it } + .onFailure { Timber.w(it, "Failed to fetch transactions") } + } catch (e: Exception) { + Timber.e(e, "Failed to load wallet data") + error = e.message + } finally { + isLoading = false + } + } + + fun handleEvent(event: WalletPanelEvent) { + when (event) { + WalletPanelEvent.Refresh -> { + scope.launch { + val address = walletState.address ?: return@launch + isLoading = true + error = null + + try { + val balanceResult = cardanoClient.getBalance(address) + balanceResult.onSuccess { balance -> + walletManager.refreshBalance(matrixClient.sessionId, balance) + } + + cardanoClient.getAddressAssets(address) + .onSuccess { assets = it } + + cardanoClient.getAddressTransactions(address, 20) + .onSuccess { transactions = it } + } catch (e: Exception) { + error = e.message + } finally { + isLoading = false + } + } + } + WalletPanelEvent.CopyAddress -> { + // Handled by view via clipboard manager + } + WalletPanelEvent.SendAda -> { + // Navigation handled by node callback + } + WalletPanelEvent.SetupWallet -> { + // Navigation handled by node callback + } + WalletPanelEvent.ExportRecoveryPhrase -> { + // Handled by separate flow with biometric + } + WalletPanelEvent.DeleteWallet -> { + // Show confirmation dialog + } + WalletPanelEvent.ConfirmDeleteWallet -> { + // Handled by separate action + } + WalletPanelEvent.CancelDeleteWallet -> { + // Dismiss dialog + } + is WalletPanelEvent.OpenTransaction -> { + // Handled by view via intent + } + WalletPanelEvent.Close -> { + // Navigation handled by node callback + } + } + } + + return WalletPanelState( + hasWallet = walletState.hasWallet, + isLoading = isLoading || walletState.isLoading, + address = walletState.address, + balanceLovelace = walletState.balanceLovelace, + balanceAda = walletState.balanceAda, + assets = assets, + transactions = transactions, + isTestnet = CardanoNetworkConfig.NETWORK_NAME != "mainnet", + error = error ?: walletState.error, + eventSink = ::handleEvent, + ) + } +} diff --git a/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/panel/WalletPanelState.kt b/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/panel/WalletPanelState.kt new file mode 100644 index 0000000000..971b20aad9 --- /dev/null +++ b/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/panel/WalletPanelState.kt @@ -0,0 +1,90 @@ +/* + * Copyright (c) 2026 Sulkta Coop. + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package io.element.android.features.wallet.impl.panel + +import androidx.compose.runtime.Immutable +import io.element.android.features.wallet.api.NativeAsset +import io.element.android.features.wallet.api.TxSummary + +/** + * UI state for the wallet panel. + */ +@Immutable +data class WalletPanelState( + val hasWallet: Boolean, + val isLoading: Boolean, + val address: String?, + val balanceLovelace: Long?, + val balanceAda: String?, + val assets: List, + val transactions: List, + val isTestnet: Boolean, + val error: String?, + val eventSink: (WalletPanelEvent) -> Unit, +) { + companion object { + val Initial = WalletPanelState( + hasWallet = false, + isLoading = true, + address = null, + balanceLovelace = null, + balanceAda = null, + assets = emptyList(), + transactions = emptyList(), + isTestnet = true, + error = null, + eventSink = {}, + ) + } + + /** + * Truncated address for display (first 12 + last 8 chars). + */ + val truncatedAddress: String? + get() = address?.let { addr -> + if (addr.length > 24) { + "${addr.take(12)}...${addr.takeLast(8)}" + } else { + addr + } + } +} + +/** + * Events that can be triggered from the wallet panel UI. + */ +sealed interface WalletPanelEvent { + /** Refresh wallet data from the network. */ + data object Refresh : WalletPanelEvent + + /** Navigate to send ADA flow. */ + data object SendAda : WalletPanelEvent + + /** Copy address to clipboard. */ + data object CopyAddress : WalletPanelEvent + + /** Navigate to wallet setup flow. */ + data object SetupWallet : WalletPanelEvent + + /** Export recovery phrase. */ + data object ExportRecoveryPhrase : WalletPanelEvent + + /** Delete wallet. */ + data object DeleteWallet : WalletPanelEvent + + /** Confirm wallet deletion. */ + data object ConfirmDeleteWallet : WalletPanelEvent + + /** Cancel wallet deletion. */ + data object CancelDeleteWallet : WalletPanelEvent + + /** Open transaction in block explorer. */ + data class OpenTransaction(val txHash: String) : WalletPanelEvent + + /** Close the panel. */ + data object Close : WalletPanelEvent +} diff --git a/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/panel/WalletPanelView.kt b/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/panel/WalletPanelView.kt new file mode 100644 index 0000000000..6391f7ac07 --- /dev/null +++ b/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/panel/WalletPanelView.kt @@ -0,0 +1,204 @@ +/* + * Copyright (c) 2026 Sulkta Coop. + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package io.element.android.features.wallet.impl.panel + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Tab +import androidx.compose.material3.TabRow +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.features.wallet.impl.R +import io.element.android.features.wallet.impl.panel.tabs.AssetsTabView +import io.element.android.features.wallet.impl.panel.tabs.HistoryTabView +import io.element.android.features.wallet.impl.panel.tabs.OverviewTabView +import io.element.android.features.wallet.impl.panel.tabs.SettingsTabView +import io.element.android.libraries.designsystem.components.button.BackButton +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.Scaffold +import io.element.android.libraries.designsystem.theme.components.TopAppBar +import kotlinx.coroutines.launch + +private enum class WalletTab(val titleRes: Int) { + Overview(R.string.wallet_tab_overview), + Assets(R.string.wallet_tab_assets), + History(R.string.wallet_tab_history), + Settings(R.string.wallet_tab_settings), +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun WalletPanelView( + state: WalletPanelState, + onBackClick: () -> Unit, + onSendClick: () -> Unit, + onSetupClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val tabs = WalletTab.entries + val pagerState = rememberPagerState(pageCount = { tabs.size }) + val scope = rememberCoroutineScope() + + Scaffold( + modifier = modifier, + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.wallet_panel_title)) }, + navigationIcon = { + BackButton(onClick = onBackClick) + }, + ) + }, + ) { padding -> + if (!state.hasWallet && !state.isLoading) { + // Show setup prompt + WalletSetupPromptView( + onSetupClick = onSetupClick, + modifier = Modifier + .fillMaxSize() + .padding(padding), + ) + } else { + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding), + ) { + TabRow( + selectedTabIndex = pagerState.currentPage, + ) { + tabs.forEachIndexed { index, tab -> + Tab( + selected = pagerState.currentPage == index, + onClick = { + scope.launch { + pagerState.animateScrollToPage(index) + } + }, + text = { Text(stringResource(tab.titleRes)) }, + ) + } + } + + HorizontalPager( + state = pagerState, + modifier = Modifier.fillMaxSize(), + ) { page -> + when (tabs[page]) { + WalletTab.Overview -> OverviewTabView( + state = state, + onSendClick = onSendClick, + modifier = Modifier.fillMaxSize(), + ) + WalletTab.Assets -> AssetsTabView( + assets = state.assets, + isLoading = state.isLoading, + modifier = Modifier.fillMaxSize(), + ) + WalletTab.History -> HistoryTabView( + transactions = state.transactions, + isTestnet = state.isTestnet, + isLoading = state.isLoading, + onTransactionClick = { txHash -> + state.eventSink(WalletPanelEvent.OpenTransaction(txHash)) + }, + modifier = Modifier.fillMaxSize(), + ) + WalletTab.Settings -> SettingsTabView( + address = state.address, + isTestnet = state.isTestnet, + onCopyAddress = { state.eventSink(WalletPanelEvent.CopyAddress) }, + onExportPhrase = { state.eventSink(WalletPanelEvent.ExportRecoveryPhrase) }, + onDeleteWallet = { state.eventSink(WalletPanelEvent.DeleteWallet) }, + modifier = Modifier.fillMaxSize(), + ) + } + } + } + } + } +} + +@Composable +private fun WalletSetupPromptView( + onSetupClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.padding(24.dp), + horizontalAlignment = androidx.compose.ui.Alignment.CenterHorizontally, + verticalArrangement = androidx.compose.foundation.layout.Arrangement.Center, + ) { + androidx.compose.material3.Icon( + imageVector = CompoundIcons.Chart(), + contentDescription = null, + modifier = Modifier + .padding(bottom = 16.dp) + .then(Modifier.padding(48.dp)), + ) + Text( + text = stringResource(R.string.wallet_setup_title), + style = androidx.compose.material3.MaterialTheme.typography.headlineMedium, + modifier = Modifier.padding(bottom = 8.dp), + ) + Text( + text = stringResource(R.string.wallet_setup_description), + style = androidx.compose.material3.MaterialTheme.typography.bodyMedium, + textAlign = androidx.compose.ui.text.style.TextAlign.Center, + modifier = Modifier.padding(bottom = 24.dp), + ) + androidx.compose.material3.Button(onClick = onSetupClick) { + Text(stringResource(R.string.wallet_setup_button)) + } + } +} + +@PreviewsDayNight +@Composable +internal fun WalletPanelViewPreview() = ElementPreview { + WalletPanelView( + state = WalletPanelState( + hasWallet = true, + isLoading = false, + address = "addr_test1qpu5vlrf4xkxs2m4wcn7hpq98aqspflj3tdx8ax9qk9qw8zqh2c4tkqehp4j0y8awxmjcgv5p2vz8z5zycq7vq4q2dqst7pf8y", + balanceLovelace = 5_500_000L, + balanceAda = "5.5", + assets = emptyList(), + transactions = emptyList(), + isTestnet = true, + error = null, + eventSink = {}, + ), + onBackClick = {}, + onSendClick = {}, + onSetupClick = {}, + ) +} + +@PreviewsDayNight +@Composable +internal fun WalletPanelViewNoWalletPreview() = ElementPreview { + WalletPanelView( + state = WalletPanelState.Initial.copy( + hasWallet = false, + isLoading = false, + ), + onBackClick = {}, + onSendClick = {}, + onSetupClick = {}, + ) +} diff --git a/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/panel/tabs/AssetsTabView.kt b/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/panel/tabs/AssetsTabView.kt new file mode 100644 index 0000000000..5c6d9f1927 --- /dev/null +++ b/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/panel/tabs/AssetsTabView.kt @@ -0,0 +1,153 @@ +/* + * Copyright (c) 2026 Sulkta Coop. + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package io.element.android.features.wallet.impl.panel.tabs + +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.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Card +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.features.wallet.api.NativeAsset +import io.element.android.features.wallet.impl.R +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.Icon + +@Composable +fun AssetsTabView( + assets: List, + isLoading: Boolean, + modifier: Modifier = Modifier, +) { + Box(modifier = modifier) { + when { + isLoading -> { + CircularProgressIndicator( + modifier = Modifier.align(Alignment.Center), + ) + } + assets.isEmpty() -> { + Column( + modifier = Modifier + .fillMaxSize() + .padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Icon( + imageVector = CompoundIcons.Files(), + contentDescription = null, + modifier = Modifier.padding(bottom = 16.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = stringResource(R.string.wallet_no_assets), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + else -> { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = androidx.compose.foundation.layout.PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(assets) { asset -> + AssetCard(asset = asset) + } + } + } + } + } +} + +@Composable +private fun AssetCard( + asset: NativeAsset, + modifier: Modifier = Modifier, +) { + Card( + modifier = modifier.fillMaxWidth(), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column( + modifier = Modifier.weight(1f), + ) { + Text( + text = asset.name, + style = MaterialTheme.typography.bodyLarge.copy( + fontWeight = FontWeight.Medium, + ), + ) + Text( + text = asset.truncatedPolicyId, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Text( + text = asset.quantity.toString(), + style = MaterialTheme.typography.titleMedium, + ) + } + } +} + +@PreviewsDayNight +@Composable +internal fun AssetsTabViewPreview() = ElementPreview { + AssetsTabView( + assets = listOf( + NativeAsset( + policyId = "aabbccdd11223344556677889900aabbccdd11223344556677889900", + assetName = "4d79546f6b656e", + quantity = 1000, + displayName = "MyToken", + fingerprint = null, + ), + NativeAsset( + policyId = "11223344556677889900aabbccdd11223344556677889900aabbccdd", + assetName = "", + quantity = 5, + displayName = null, + fingerprint = null, + ), + ), + isLoading = false, + ) +} + +@PreviewsDayNight +@Composable +internal fun AssetsTabViewEmptyPreview() = ElementPreview { + AssetsTabView( + assets = emptyList(), + isLoading = false, + ) +} diff --git a/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/panel/tabs/HistoryTabView.kt b/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/panel/tabs/HistoryTabView.kt new file mode 100644 index 0000000000..30f3c025db --- /dev/null +++ b/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/panel/tabs/HistoryTabView.kt @@ -0,0 +1,206 @@ +/* + * Copyright (c) 2026 Sulkta Coop. + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package io.element.android.features.wallet.impl.panel.tabs + +import androidx.compose.foundation.clickable +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.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Card +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.features.wallet.api.TxSummary +import io.element.android.features.wallet.impl.R +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.Icon + +@Composable +fun HistoryTabView( + transactions: List, + isTestnet: Boolean, + isLoading: Boolean, + onTransactionClick: (String) -> Unit, + modifier: Modifier = Modifier, +) { + Box(modifier = modifier) { + when { + isLoading -> { + CircularProgressIndicator( + modifier = Modifier.align(Alignment.Center), + ) + } + transactions.isEmpty() -> { + Column( + modifier = Modifier + .fillMaxSize() + .padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Icon( + imageVector = CompoundIcons.History(), + contentDescription = null, + modifier = Modifier.padding(bottom = 16.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = stringResource(R.string.wallet_no_transactions), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + else -> { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = androidx.compose.foundation.layout.PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(transactions) { tx -> + TransactionCard( + transaction = tx, + isTestnet = isTestnet, + onClick = { onTransactionClick(tx.txHash) }, + ) + } + } + } + } + } +} + +@Composable +private fun TransactionCard( + transaction: TxSummary, + isTestnet: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Card( + modifier = modifier + .fillMaxWidth() + .clickable(onClick = onClick), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column( + modifier = Modifier.weight(1f), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = when (transaction.direction) { + TxSummary.Direction.SENT -> CompoundIcons.ArrowUpRight() + TxSummary.Direction.RECEIVED -> CompoundIcons.ArrowDown() + }, + contentDescription = null, + tint = when (transaction.direction) { + TxSummary.Direction.SENT -> MaterialTheme.colorScheme.error + TxSummary.Direction.RECEIVED -> MaterialTheme.colorScheme.primary + }, + modifier = Modifier.padding(end = 8.dp), + ) + Text( + text = when (transaction.direction) { + TxSummary.Direction.SENT -> stringResource(R.string.wallet_tx_sent) + TxSummary.Direction.RECEIVED -> stringResource(R.string.wallet_tx_received) + }, + style = MaterialTheme.typography.bodyLarge.copy( + fontWeight = FontWeight.Medium, + ), + ) + } + Text( + text = transaction.formattedDate, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = transaction.truncatedTxHash, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Column( + horizontalAlignment = Alignment.End, + ) { + Text( + text = transaction.amountAda, + style = MaterialTheme.typography.titleMedium, + color = when (transaction.direction) { + TxSummary.Direction.SENT -> MaterialTheme.colorScheme.error + TxSummary.Direction.RECEIVED -> MaterialTheme.colorScheme.primary + }, + ) + Icon( + imageVector = CompoundIcons.PopOut(), + contentDescription = stringResource(R.string.wallet_view_on_explorer), + modifier = Modifier.padding(top = 4.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} + +@PreviewsDayNight +@Composable +internal fun HistoryTabViewPreview() = ElementPreview { + HistoryTabView( + transactions = listOf( + TxSummary( + txHash = "aabbccdd11223344556677889900aabbccdd11223344556677889900aabbccdd", + blockTime = 1710000000, + totalOutput = 5_500_000, + fee = 170000, + direction = TxSummary.Direction.SENT, + ), + TxSummary( + txHash = "11223344556677889900aabbccdd11223344556677889900aabbccdd11223344", + blockTime = 1709900000, + totalOutput = 10_000_000, + fee = 165000, + direction = TxSummary.Direction.RECEIVED, + ), + ), + isTestnet = true, + isLoading = false, + onTransactionClick = {}, + ) +} + +@PreviewsDayNight +@Composable +internal fun HistoryTabViewEmptyPreview() = ElementPreview { + HistoryTabView( + transactions = emptyList(), + isTestnet = true, + isLoading = false, + onTransactionClick = {}, + ) +} diff --git a/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/panel/tabs/OverviewTabView.kt b/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/panel/tabs/OverviewTabView.kt new file mode 100644 index 0000000000..1dfd589120 --- /dev/null +++ b/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/panel/tabs/OverviewTabView.kt @@ -0,0 +1,233 @@ +/* + * Copyright (c) 2026 Sulkta Coop. + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package io.element.android.features.wallet.impl.panel.tabs + +import android.graphics.Bitmap +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +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 +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.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.google.zxing.BarcodeFormat +import com.google.zxing.EncodeHintType +import com.google.zxing.qrcode.QRCodeWriter +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.features.wallet.impl.R +import io.element.android.features.wallet.impl.panel.WalletPanelEvent +import io.element.android.features.wallet.impl.panel.WalletPanelState +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.Icon + +@Composable +fun OverviewTabView( + state: WalletPanelState, + onSendClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val clipboardManager = LocalClipboardManager.current + + Column( + modifier = modifier + .verticalScroll(rememberScrollState()) + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + // Balance Card + Card( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 24.dp), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = stringResource(R.string.wallet_balance_label), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(8.dp)) + + if (state.isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(32.dp), + ) + } else { + Text( + text = "${state.balanceAda ?: "0"} ADA", + style = MaterialTheme.typography.displaySmall.copy( + fontWeight = FontWeight.Bold, + ), + ) + if (state.isTestnet) { + Text( + text = stringResource(R.string.wallet_testnet_label), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(top = 4.dp), + ) + } + } + } + } + + // QR Code + state.address?.let { address -> + val qrBitmap = remember(address) { + generateQrCode(address, 200) + } + qrBitmap?.let { bitmap -> + Box( + modifier = Modifier + .size(200.dp) + .clip(RoundedCornerShape(12.dp)) + .background(Color.White) + .padding(8.dp), + ) { + Image( + bitmap = bitmap.asImageBitmap(), + contentDescription = stringResource(R.string.wallet_qr_code_description), + modifier = Modifier.fillMaxSize(), + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + // Address + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .clickable { + clipboardManager.setText(AnnotatedString(address)) + state.eventSink(WalletPanelEvent.CopyAddress) + } + .padding(12.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = state.truncatedAddress ?: address, + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + modifier = Modifier.weight(1f, fill = false), + ) + Icon( + imageVector = CompoundIcons.Copy(), + contentDescription = stringResource(R.string.wallet_copy_address), + modifier = Modifier + .padding(start = 8.dp) + .size(20.dp), + ) + } + + Text( + text = stringResource(R.string.wallet_tap_to_copy), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + Spacer(modifier = Modifier.height(32.dp)) + + // Send Button + Button( + onClick = onSendClick, + modifier = Modifier.fillMaxWidth(), + enabled = state.hasWallet && !state.isLoading, + ) { + Icon( + imageVector = CompoundIcons.Send(), + contentDescription = null, + modifier = Modifier.padding(end = 8.dp), + ) + Text(stringResource(R.string.wallet_send_ada)) + } + } +} + +private fun generateQrCode(content: String, size: Int): Bitmap? { + return try { + val hints = mutableMapOf() + hints[EncodeHintType.MARGIN] = 0 + hints[EncodeHintType.CHARACTER_SET] = "UTF-8" + + val writer = QRCodeWriter() + val bitMatrix = writer.encode(content, BarcodeFormat.QR_CODE, size, size, hints) + + val pixels = IntArray(size * size) + for (y in 0 until size) { + for (x in 0 until size) { + pixels[y * size + x] = if (bitMatrix[x, y]) { + android.graphics.Color.BLACK + } else { + android.graphics.Color.WHITE + } + } + } + + Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888).apply { + setPixels(pixels, 0, size, 0, 0, size, size) + } + } catch (e: Exception) { + null + } +} + +@PreviewsDayNight +@Composable +internal fun OverviewTabViewPreview() = ElementPreview { + OverviewTabView( + state = WalletPanelState( + hasWallet = true, + isLoading = false, + address = "addr_test1qpu5vlrf4xkxs2m4wcn7hpq98aqspflj3tdx8ax9qk9qw8zqh2c4tkqehp4j0y8awxmjcgv5p2vz8z5zycq7vq4q2dqst7pf8y", + balanceLovelace = 25_500_000L, + balanceAda = "25.5", + assets = emptyList(), + transactions = emptyList(), + isTestnet = true, + error = null, + eventSink = {}, + ), + onSendClick = {}, + ) +} diff --git a/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/panel/tabs/SettingsTabView.kt b/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/panel/tabs/SettingsTabView.kt new file mode 100644 index 0000000000..9ad1d99976 --- /dev/null +++ b/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/panel/tabs/SettingsTabView.kt @@ -0,0 +1,223 @@ +/* + * Copyright (c) 2026 Sulkta Coop. + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package io.element.android.features.wallet.impl.panel.tabs + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +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.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.features.wallet.impl.R +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.Icon + +@Composable +fun SettingsTabView( + address: String?, + isTestnet: Boolean, + onCopyAddress: () -> Unit, + onExportPhrase: () -> Unit, + onDeleteWallet: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(16.dp), + ) { + // Wallet Address Section + Card( + modifier = Modifier.fillMaxWidth(), + ) { + Column( + modifier = Modifier.padding(16.dp), + ) { + Text( + text = stringResource(R.string.wallet_settings_address), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = address ?: stringResource(R.string.wallet_settings_no_address), + style = MaterialTheme.typography.bodyMedium, + ) + Spacer(modifier = Modifier.height(8.dp)) + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onCopyAddress) + .padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = CompoundIcons.Copy(), + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + Text( + text = stringResource(R.string.wallet_settings_copy_address), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(start = 8.dp), + ) + } + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + // Network Section + Card( + modifier = Modifier.fillMaxWidth(), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column( + modifier = Modifier.weight(1f), + ) { + Text( + text = stringResource(R.string.wallet_settings_network), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = if (isTestnet) { + stringResource(R.string.wallet_settings_testnet) + } else { + stringResource(R.string.wallet_settings_mainnet) + }, + style = MaterialTheme.typography.bodyLarge, + ) + } + if (isTestnet) { + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.errorContainer, + ), + ) { + Text( + text = "TESTNET", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onErrorContainer, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + ) + } + } + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + // Security Section + Card( + modifier = Modifier.fillMaxWidth(), + ) { + Column { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onExportPhrase) + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = CompoundIcons.Key(), + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurface, + ) + Column( + modifier = Modifier + .weight(1f) + .padding(start = 16.dp), + ) { + Text( + text = stringResource(R.string.wallet_settings_export_phrase), + style = MaterialTheme.typography.bodyLarge, + ) + Text( + text = stringResource(R.string.wallet_settings_export_phrase_description), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Icon( + imageVector = CompoundIcons.ChevronRight(), + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + HorizontalDivider() + + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onDeleteWallet) + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = CompoundIcons.Delete(), + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + ) + Column( + modifier = Modifier + .weight(1f) + .padding(start = 16.dp), + ) { + Text( + text = stringResource(R.string.wallet_settings_delete_wallet), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.error, + ) + Text( + text = stringResource(R.string.wallet_settings_delete_wallet_description), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + } +} + +@PreviewsDayNight +@Composable +internal fun SettingsTabViewPreview() = ElementPreview { + SettingsTabView( + address = "addr_test1qpu5vlrf4xkxs2m4wcn7hpq98aqspflj3tdx8ax9qk9qw8zqh2c4tkqehp4j0y8awxmjcgv5p2vz8z5zycq7vq4q2dqst7pf8y", + isTestnet = true, + onCopyAddress = {}, + onExportPhrase = {}, + onDeleteWallet = {}, + ) +} diff --git a/features/wallet/impl/src/main/res/values/strings.xml b/features/wallet/impl/src/main/res/values/strings.xml new file mode 100644 index 0000000000..7d9032f75e --- /dev/null +++ b/features/wallet/impl/src/main/res/values/strings.xml @@ -0,0 +1,50 @@ + + + + Cardano Wallet + + + Overview + Assets + History + Settings + + + Balance + Testnet + QR code for receiving ADA + Copy address + Tap to copy full address + Send ADA + + + No native assets yet + + + No transactions yet + Sent + Received + View on explorer + + + Wallet Address + No wallet configured + Copy full address + Network + Preprod Testnet + Mainnet + Export Recovery Phrase + View your 24-word recovery phrase + Delete Wallet + Remove wallet from this device + + + Set up your wallet + Your Cardano wallet keys will be stored securely on your device and backed up via your Matrix account. + Get Started + + + Set up your wallet to send ADA + Set Up Wallet + Insufficient balance (%s ADA available) + diff --git a/features/wallet/test/src/main/kotlin/io/element/android/features/wallet/test/FakeCardanoClient.kt b/features/wallet/test/src/main/kotlin/io/element/android/features/wallet/test/FakeCardanoClient.kt index 303b658f34..349410f596 100644 --- a/features/wallet/test/src/main/kotlin/io/element/android/features/wallet/test/FakeCardanoClient.kt +++ b/features/wallet/test/src/main/kotlin/io/element/android/features/wallet/test/FakeCardanoClient.kt @@ -8,8 +8,10 @@ package io.element.android.features.wallet.test import io.element.android.features.wallet.api.CardanoClient import io.element.android.features.wallet.api.CardanoException +import io.element.android.features.wallet.api.NativeAsset import io.element.android.features.wallet.api.ProtocolParameters import io.element.android.features.wallet.api.TxStatus +import io.element.android.features.wallet.api.TxSummary import io.element.android.features.wallet.api.Utxo /** @@ -27,6 +29,8 @@ class FakeCardanoClient : CardanoClient { var utxos = mutableMapOf>() var transactionStatuses = mutableMapOf() var submittedTransactions = mutableListOf() + var assets = mutableMapOf>() + var transactions = mutableMapOf>() // Error simulation var shouldFailWithNetworkError = false @@ -53,6 +57,10 @@ class FakeCardanoClient : CardanoClient { private set var getProtocolParametersCallCount = 0 private set + var getAddressAssetsCallCount = 0 + private set + var getAddressTransactionsCallCount = 0 + private set /** * Represents a submitted transaction for testing. @@ -145,6 +153,32 @@ class FakeCardanoClient : CardanoClient { return Result.success(protocolParameters) } + override suspend fun getAddressAssets(address: String): Result> { + getAddressAssetsCallCount++ + + if (shouldFailWithNetworkError) { + return Result.failure(CardanoException.NetworkException("Simulated network error")) + } + if (shouldFailWithRateLimit) { + return Result.failure(CardanoException.RateLimitException(retryAfterMs = 1000L)) + } + + return Result.success(assets[address] ?: emptyList()) + } + + override suspend fun getAddressTransactions(address: String, limit: Int): Result> { + getAddressTransactionsCallCount++ + + if (shouldFailWithNetworkError) { + return Result.failure(CardanoException.NetworkException("Simulated network error")) + } + if (shouldFailWithRateLimit) { + return Result.failure(CardanoException.RateLimitException(retryAfterMs = 1000L)) + } + + return Result.success(transactions[address]?.take(limit) ?: emptyList()) + } + // Helper methods for test setup /** @@ -212,6 +246,8 @@ class FakeCardanoClient : CardanoClient { utxos.clear() transactionStatuses.clear() submittedTransactions.clear() + assets.clear() + transactions.clear() shouldFailWithNetworkError = false shouldFailWithRateLimit = false submitShouldFail = false @@ -221,6 +257,8 @@ class FakeCardanoClient : CardanoClient { submitTxCallCount = 0 getTxStatusCallCount = 0 getProtocolParametersCallCount = 0 + getAddressAssetsCallCount = 0 + getAddressTransactionsCallCount = 0 protocolParameters = ProtocolParameters( minFeeA = 44L, minFeeB = 155381L,