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
This commit is contained in:
Kayos 2026-03-28 09:23:58 -07:00
parent b867fa783e
commit e33c87c164
24 changed files with 1685 additions and 1 deletions

View file

@ -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<ThreadedMessagesNode>(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<WalletPanelNode>(buildContext, listOf(walletPanelCallback))
}
is NavTarget.PaymentFlow -> {
val walletCallback = object : WalletEntryPoint.Callback {
override fun onPaymentSent(txHash: String) {

View file

@ -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(

View file

@ -295,6 +295,7 @@ class MessagesPresenter(
dmUserVerificationState = dmUserVerificationState,
roomMemberModerationState = roomMemberModerationState,
topBarSharedHistoryIcon = topBarSharedHistoryIcon,
isDmRoom = roomInfo.isDm,
successorRoom = roomInfo.successorRoom,
eventSink = ::handleEvent,
)

View file

@ -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
) {

View file

@ -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,
)

View file

@ -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 = {},

View file

@ -40,6 +40,7 @@ internal fun MessagesViewWithIdentityChangePreview(
onSendLocationClick = {},
onCreatePollClick = {},
onJoinCallClick = {},
onWalletClick = {},
onViewAllPinnedMessagesClick = {},
knockRequestsBannerView = {}
)

View file

@ -298,6 +298,7 @@ class ThreadedMessagesNode(
onJoinCallClick = { isAudioCall ->
callback.navigateToRoomCall(room.roomId, isAudioCall)
},
onWalletClick = {},
onViewAllPinnedMessagesClick = {},
modifier = modifier,
knockRequestsBannerView = {},

View file

@ -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(

View file

@ -53,4 +53,21 @@ interface CardanoClient {
* @return Current [ProtocolParameters] from the latest epoch
*/
suspend fun getProtocolParameters(): Result<ProtocolParameters>
/**
* Get native assets (tokens) for a given address.
*
* @param address Bech32 Cardano address
* @return List of [NativeAsset] objects
*/
suspend fun getAddressAssets(address: String): Result<List<NativeAsset>>
/**
* 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<List<TxSummary>>
}

View file

@ -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"
}

View file

@ -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"
}
}
}

View file

@ -44,6 +44,8 @@ dependencies {
// JSON
implementation(libs.serialization.json)
// QR code generation
implementation(libs.google.zxing)
// Coroutines
implementation(libs.coroutines.core)

View file

@ -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<List<NativeAsset>> =
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<List<TxSummary>> =
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 <T> withRetry(
operation: String,
block: suspend () -> Result<T>,

View file

@ -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<Plugin>,
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,
)
}
}

View file

@ -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<WalletPanelState> {
@Composable
override fun present(): WalletPanelState {
val walletState by walletManager.walletState.collectAsState()
val scope = rememberCoroutineScope()
var assets by remember { mutableStateOf<List<NativeAsset>>(emptyList()) }
var transactions by remember { mutableStateOf<List<TxSummary>>(emptyList()) }
var isLoading by remember { mutableStateOf(true) }
var error by remember { mutableStateOf<String?>(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,
)
}
}

View file

@ -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<NativeAsset>,
val transactions: List<TxSummary>,
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
}

View file

@ -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 = {},
)
}

View file

@ -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<NativeAsset>,
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,
)
}

View file

@ -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<TxSummary>,
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 = {},
)
}

View file

@ -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<EncodeHintType, Any>()
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 = {},
)
}

View file

@ -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 = {},
)
}

View file

@ -0,0 +1,50 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Wallet Panel -->
<string name="wallet_panel_title">Cardano Wallet</string>
<!-- Tabs -->
<string name="wallet_tab_overview">Overview</string>
<string name="wallet_tab_assets">Assets</string>
<string name="wallet_tab_history">History</string>
<string name="wallet_tab_settings">Settings</string>
<!-- Overview Tab -->
<string name="wallet_balance_label">Balance</string>
<string name="wallet_testnet_label">Testnet</string>
<string name="wallet_qr_code_description">QR code for receiving ADA</string>
<string name="wallet_copy_address">Copy address</string>
<string name="wallet_tap_to_copy">Tap to copy full address</string>
<string name="wallet_send_ada">Send ADA</string>
<!-- Assets Tab -->
<string name="wallet_no_assets">No native assets yet</string>
<!-- History Tab -->
<string name="wallet_no_transactions">No transactions yet</string>
<string name="wallet_tx_sent">Sent</string>
<string name="wallet_tx_received">Received</string>
<string name="wallet_view_on_explorer">View on explorer</string>
<!-- Settings Tab -->
<string name="wallet_settings_address">Wallet Address</string>
<string name="wallet_settings_no_address">No wallet configured</string>
<string name="wallet_settings_copy_address">Copy full address</string>
<string name="wallet_settings_network">Network</string>
<string name="wallet_settings_testnet">Preprod Testnet</string>
<string name="wallet_settings_mainnet">Mainnet</string>
<string name="wallet_settings_export_phrase">Export Recovery Phrase</string>
<string name="wallet_settings_export_phrase_description">View your 24-word recovery phrase</string>
<string name="wallet_settings_delete_wallet">Delete Wallet</string>
<string name="wallet_settings_delete_wallet_description">Remove wallet from this device</string>
<!-- Setup -->
<string name="wallet_setup_title">Set up your wallet</string>
<string name="wallet_setup_description">Your Cardano wallet keys will be stored securely on your device and backed up via your Matrix account.</string>
<string name="wallet_setup_button">Get Started</string>
<!-- Payment -->
<string name="wallet_payment_no_wallet_message">Set up your wallet to send ADA</string>
<string name="wallet_payment_no_wallet_button">Set Up Wallet</string>
<string name="wallet_payment_insufficient_balance">Insufficient balance (%s ADA available)</string>
</resources>

View file

@ -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<String, List<Utxo>>()
var transactionStatuses = mutableMapOf<String, TxStatus>()
var submittedTransactions = mutableListOf<SubmittedTx>()
var assets = mutableMapOf<String, List<NativeAsset>>()
var transactions = mutableMapOf<String, List<TxSummary>>()
// 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<List<NativeAsset>> {
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<List<TxSummary>> {
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,