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
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -295,6 +295,7 @@ class MessagesPresenter(
|
|||
dmUserVerificationState = dmUserVerificationState,
|
||||
roomMemberModerationState = roomMemberModerationState,
|
||||
topBarSharedHistoryIcon = topBarSharedHistoryIcon,
|
||||
isDmRoom = roomInfo.isDm,
|
||||
successorRoom = roomInfo.successorRoom,
|
||||
eventSink = ::handleEvent,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 = {},
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ internal fun MessagesViewWithIdentityChangePreview(
|
|||
onSendLocationClick = {},
|
||||
onCreatePollClick = {},
|
||||
onJoinCallClick = {},
|
||||
onWalletClick = {},
|
||||
onViewAllPinnedMessagesClick = {},
|
||||
knockRequestsBannerView = {}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -298,6 +298,7 @@ class ThreadedMessagesNode(
|
|||
onJoinCallClick = { isAudioCall ->
|
||||
callback.navigateToRoomCall(room.roomId, isAudioCall)
|
||||
},
|
||||
onWalletClick = {},
|
||||
onViewAllPinnedMessagesClick = {},
|
||||
modifier = modifier,
|
||||
knockRequestsBannerView = {},
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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>>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -44,6 +44,8 @@ dependencies {
|
|||
|
||||
// JSON
|
||||
implementation(libs.serialization.json)
|
||||
// QR code generation
|
||||
implementation(libs.google.zxing)
|
||||
|
||||
// Coroutines
|
||||
implementation(libs.coroutines.core)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue