Phase 3: Wallet panel UI and full /pay flow wiring

- Add WalletPanelView with 4 tabs (Overview, Assets, History, Settings)
- Overview tab shows balance, QR code for receiving, and Send ADA button
- Assets tab shows native tokens held at address
- History tab shows recent transactions with explorer links
- Settings tab shows address, network, and backup/delete options

- Add NativeAsset and TxSummary models to wallet API
- Add getAddressAssets() and getAddressTransactions() to CardanoClient
- Implement new methods in KoiosCardanoClient and FakeCardanoClient

- Add wallet button to MessagesViewTopBar (DM rooms only)
- Add isDmRoom to MessagesState for conditional UI
- Wire navigateToWallet() callback through to MessagesFlowNode
- Add NavTarget.WalletPanel and WalletPanelNode integration

- Add string resources for wallet panel UI

Known limitations:
- Uses Chart icon as placeholder for wallet (Compound lacks wallet icon)
- Wallet setup flow not implemented (TODO)
- Transaction amounts in history need additional API calls to calculate
This commit is contained in:
Kayos 2026-03-28 09:23:58 -07:00
parent b867fa783e
commit e33c87c164
24 changed files with 1685 additions and 1 deletions

View file

@ -53,6 +53,7 @@ import io.element.android.features.messages.impl.timeline.model.event.duration
import io.element.android.features.poll.api.create.CreatePollEntryPoint
import io.element.android.features.poll.api.create.CreatePollMode
import io.element.android.features.wallet.api.WalletEntryPoint
import io.element.android.features.wallet.impl.panel.WalletPanelNode
import io.element.android.libraries.architecture.BackstackWithOverlayBox
import io.element.android.libraries.architecture.BaseFlowNode
import io.element.android.libraries.architecture.callback
@ -182,6 +183,9 @@ class MessagesFlowNode(
@Parcelize
data class Thread(val threadRootId: ThreadId, val focusedEventId: EventId?) : NavTarget
@Parcelize
data object WalletPanel : NavTarget
@Parcelize
data class PaymentFlow(
val roomId: RoomId,
@ -304,6 +308,10 @@ class MessagesFlowNode(
backstack.push(NavTarget.Thread(threadRootId, focusedEventId))
}
override fun navigateToWallet() {
backstack.push(NavTarget.WalletPanel)
}
override fun navigateToPaymentFlow(
roomId: RoomId,
recipientUserId: UserId?,
@ -533,6 +541,23 @@ class MessagesFlowNode(
}
createNode<ThreadedMessagesNode>(buildContext, listOf(inputs, callback))
}
is NavTarget.WalletPanel -> {
val walletPanelCallback = object : WalletPanelNode.Callback {
override fun onClose() {
backstack.pop()
}
override fun onSendAda() {
backstack.pop()
backstack.push(NavTarget.PaymentFlow(room.roomId, null, null, null))
}
override fun onSetupWallet() {
// TODO: Navigate to wallet setup flow
}
}
createNode<WalletPanelNode>(buildContext, listOf(walletPanelCallback))
}
is NavTarget.PaymentFlow -> {
val walletCallback = object : WalletEntryPoint.Callback {
override fun onPaymentSent(txHash: String) {

View file

@ -130,6 +130,7 @@ class MessagesNode(
fun navigateToRoomDetails()
fun navigateToPinnedMessagesList()
fun navigateToKnockRequestsList()
fun navigateToWallet()
fun navigateToPaymentFlow(roomId: RoomId, recipientUserId: UserId?, recipientAddress: String?, amountLovelace: Long?)
}
@ -293,6 +294,7 @@ class MessagesNode(
callback.navigateToRoomCall(room.roomId, isAudioCall)
},
onViewAllPinnedMessagesClick = callback::navigateToPinnedMessagesList,
onWalletClick = callback::navigateToWallet,
modifier = modifier,
knockRequestsBannerView = {
knockRequestsBannerRenderer.View(

View file

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

View file

@ -56,6 +56,7 @@ data class MessagesState(
val roomMemberModerationState: RoomMemberModerationState,
/** Type of "shared history" icon to show in the top bar. */
val topBarSharedHistoryIcon: SharedHistoryIcon,
val isDmRoom: Boolean,
val successorRoom: SuccessorRoom?,
val eventSink: (MessagesEvent) -> Unit
) {

View file

@ -121,6 +121,7 @@ fun aMessagesState(
dmUserVerificationState: IdentityState? = null,
roomMemberModerationState: RoomMemberModerationState = aRoomMemberModerationState(),
topBarSharedHistoryIcon: SharedHistoryIcon = SharedHistoryIcon.NONE,
isDmRoom: Boolean = false,
successorRoom: SuccessorRoom? = null,
eventSink: (MessagesEvent) -> Unit = {},
) = MessagesState(
@ -149,6 +150,7 @@ fun aMessagesState(
dmUserVerificationState = dmUserVerificationState,
roomMemberModerationState = roomMemberModerationState,
topBarSharedHistoryIcon = topBarSharedHistoryIcon,
isDmRoom = isDmRoom,
successorRoom = successorRoom,
eventSink = eventSink,
)

View file

@ -131,6 +131,7 @@ fun MessagesView(
onSendLocationClick: () -> Unit,
onCreatePollClick: () -> Unit,
onJoinCallClick: (isAudioCall: Boolean) -> Unit,
onWalletClick: () -> Unit,
onViewAllPinnedMessagesClick: () -> Unit,
modifier: Modifier = Modifier,
forceJumpToBottomVisibility: Boolean = false,
@ -226,9 +227,11 @@ fun MessagesView(
roomCallState = state.roomCallState,
dmUserIdentityState = state.dmUserVerificationState,
sharedHistoryIcon = state.topBarSharedHistoryIcon,
isDmRoom = state.isDmRoom,
onBackClick = { hidingKeyboard { onBackClick() } },
onRoomDetailsClick = { hidingKeyboard { onRoomDetailsClick() } },
onJoinCallClick = onJoinCallClick,
onWalletClick = onWalletClick,
)
}
},
@ -268,6 +271,7 @@ fun MessagesView(
},
forceJumpToBottomVisibility = forceJumpToBottomVisibility,
onJoinCallClick = onJoinCallClick,
onWalletClick = onWalletClick,
onViewAllPinnedMessagesClick = onViewAllPinnedMessagesClick,
knockRequestsBannerView = knockRequestsBannerView,
)
@ -424,6 +428,7 @@ private fun MessagesViewContent(
onSendLocationClick: () -> Unit,
onCreatePollClick: () -> Unit,
onJoinCallClick: (isAudioCall: Boolean) -> Unit,
onWalletClick: () -> Unit,
onViewAllPinnedMessagesClick: () -> Unit,
forceJumpToBottomVisibility: Boolean,
onSwipeToReply: (TimelineItem.Event) -> Unit,
@ -592,6 +597,7 @@ internal fun MessagesViewPreview(@PreviewParameter(MessagesStateProvider::class)
onSendLocationClick = {},
onCreatePollClick = {},
onJoinCallClick = {},
onWalletClick = {},
onViewAllPinnedMessagesClick = { },
forceJumpToBottomVisibility = true,
knockRequestsBannerView = {},
@ -646,6 +652,7 @@ internal fun MessagesViewA11yPreview() = ElementPreview {
onSendLocationClick = {},
onCreatePollClick = {},
onJoinCallClick = {},
onWalletClick = {},
onViewAllPinnedMessagesClick = { },
forceJumpToBottomVisibility = true,
knockRequestsBannerView = {},

View file

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

View file

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

View file

@ -45,6 +45,7 @@ import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.IconButton
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
@ -65,8 +66,10 @@ internal fun MessagesViewTopBar(
roomCallState: RoomCallState,
dmUserIdentityState: IdentityState?,
sharedHistoryIcon: SharedHistoryIcon,
isDmRoom: Boolean,
onRoomDetailsClick: () -> Unit,
onJoinCallClick: (isAudioCall: Boolean) -> Unit,
onWalletClick: () -> Unit,
onBackClick: () -> Unit,
modifier: Modifier = Modifier,
) {
@ -127,6 +130,15 @@ internal fun MessagesViewTopBar(
}
},
actions = {
// Wallet button - only show in DM rooms
if (isDmRoom) {
IconButton(onClick = onWalletClick) {
Icon(
imageVector = CompoundIcons.Chart(),
contentDescription = "Cardano Wallet",
)
}
}
CallMenuItem(
roomCallState = roomCallState,
onJoinCallClick = onJoinCallClick,
@ -186,6 +198,7 @@ internal fun MessagesViewTopBarPreview() = ElementPreview {
roomCallState: RoomCallState = RoomCallState.Unavailable,
dmUserIdentityState: IdentityState? = null,
sharedHistoryIcon: SharedHistoryIcon = SharedHistoryIcon.NONE,
isDmRoom: Boolean = false,
) = MessagesViewTopBar(
roomName = roomName,
roomAvatar = roomAvatar,
@ -194,8 +207,10 @@ internal fun MessagesViewTopBarPreview() = ElementPreview {
roomCallState = roomCallState,
dmUserIdentityState = dmUserIdentityState,
sharedHistoryIcon = sharedHistoryIcon,
isDmRoom = isDmRoom,
onRoomDetailsClick = {},
onJoinCallClick = {},
onWalletClick = {},
onBackClick = {},
)
Column {
@ -218,7 +233,8 @@ internal fun MessagesViewTopBarPreview() = ElementPreview {
url = "https://some-avatar.jpg"
),
roomCallState = aStandByCallState(canStartCall = false),
dmUserIdentityState = IdentityState.Verified
dmUserIdentityState = IdentityState.Verified,
isDmRoom = true,
)
HorizontalDivider()
AMessagesViewTopBar(