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

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