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:
parent
b867fa783e
commit
e33c87c164
24 changed files with 1685 additions and 1 deletions
|
|
@ -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>,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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 = {},
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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 = {},
|
||||
)
|
||||
}
|
||||
|
|
@ -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 = {},
|
||||
)
|
||||
}
|
||||
|
|
@ -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 = {},
|
||||
)
|
||||
}
|
||||
50
features/wallet/impl/src/main/res/values/strings.xml
Normal file
50
features/wallet/impl/src/main/res/values/strings.xml
Normal 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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue