demo: transaction screen and components

This commit is contained in:
Oscar Mira 2023-10-22 12:42:05 +02:00
parent a84375d384
commit 42cf100951
27 changed files with 794 additions and 224 deletions

View File

@ -58,6 +58,9 @@ class WalletRepository(
emitAll(getWallet(walletId).ledger()) emitAll(getWallet(walletId).ledger())
} }
fun getTransaction(walletId: Long, txId: String): Flow<Transaction?> =
getLedger(walletId).map { it.transactions[txId] }
suspend fun addWallet( suspend fun addWallet(
moneroNetwork: MoneroNetwork, moneroNetwork: MoneroNetwork,
name: String, name: String,

View File

@ -1,9 +0,0 @@
package im.molly.monero.demo.data.model
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity
data class Transaction(
@PrimaryKey val uid: Int,
)

View File

@ -0,0 +1,8 @@
package im.molly.monero.demo.data.model
import im.molly.monero.Transaction
data class WalletTransaction(
val walletId: Long,
val transaction: Transaction,
)

View File

@ -1,20 +1,37 @@
package im.molly.monero.demo.ui package im.molly.monero.demo.ui
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Scaffold
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import im.molly.monero.demo.ui.component.Toolbar
@Composable @Composable
fun HistoryRoute( fun HistoryRoute(
modifier: Modifier = Modifier, navigateToTransaction: (String, Long) -> Unit,
) { ) {
HistoryScreen( HistoryScreen(
modifier = modifier, onTransactionClick = navigateToTransaction,
) )
} }
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
private fun HistoryScreen( private fun HistoryScreen(
modifier: Modifier = Modifier, onTransactionClick: (String, Long) -> Unit,
) { ) {
val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior()
Scaffold(
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
Toolbar(
title = "Transaction history",
scrollBehavior = scrollBehavior,
)
},
) { padding ->
// TODO
}
} }

View File

@ -18,9 +18,9 @@ import im.molly.monero.demo.ui.component.Toolbar
fun HomeRoute( fun HomeRoute(
navigateToAddWalletWizard: () -> Unit, navigateToAddWalletWizard: () -> Unit,
navigateToWallet: (Long) -> Unit, navigateToWallet: (Long) -> Unit,
viewModel: HomeViewModel = viewModel(), walletListViewModel: WalletListViewModel = viewModel(),
) { ) {
val walletListUiState: WalletListUiState by viewModel.walletListUiState.collectAsStateWithLifecycle() val walletListUiState by walletListViewModel.uiState.collectAsStateWithLifecycle()
HomeScreen( HomeScreen(
walletListUiState = walletListUiState, walletListUiState = walletListUiState,
@ -56,8 +56,8 @@ private fun HomeScreen(
state = listState, state = listState,
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(padding)) .padding(padding),
{ ) {
walletCards(walletListUiState, onWalletClick) walletCards(walletListUiState, onWalletClick)
} }
} }
@ -71,8 +71,11 @@ private fun LazyListScope.walletCards(
WalletListUiState.Loading -> item { WalletListUiState.Loading -> item {
Text(text = "Loading wallet list...") // TODO Text(text = "Loading wallet list...") // TODO
} }
is WalletListUiState.Success -> {
walletCardsItems(walletListUiState.ids, onWalletClick) is WalletListUiState.Loaded -> {
} walletCardItems(walletListUiState.walletIds, onWalletClick)
}
is WalletListUiState.Empty -> Unit // TODO
} }
} }

View File

@ -1,36 +0,0 @@
package im.molly.monero.demo.ui
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import im.molly.monero.demo.AppModule
import im.molly.monero.demo.data.WalletRepository
import kotlinx.coroutines.flow.*
class HomeViewModel(
private val walletRepository: WalletRepository = AppModule.walletRepository,
) : ViewModel() {
val walletListUiState: StateFlow<WalletListUiState> = walletRepository.getWalletIdList()
.map { WalletListUiState.Success(it) }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = WalletListUiState.Loading,
)
}
sealed interface HomeUiState {
data object Loading : HomeUiState
// data class Ready(
// val wallets: List<WalletDetails>
// ) : HomeUiState
data object Empty : HomeUiState
}
sealed interface WalletListUiState {
data class Success(val ids: List<Long>) : WalletListUiState
data object Loading : WalletListUiState
}

View File

@ -0,0 +1,87 @@
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Card
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.compose.ui.unit.dp
import im.molly.monero.MoneroCurrency
import im.molly.monero.Transaction
import im.molly.monero.demo.ui.preview.PreviewParameterData
import im.molly.monero.demo.ui.theme.AppTheme
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TransactionCardExpanded(
transaction: Transaction,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Card(
onClick = onClick,
modifier = modifier.padding(top = 8.dp, start = 8.dp, end = 8.dp)
) {
Column(
modifier = Modifier.padding(12.dp),
) {
TransactionDetail("State", transaction.state.toString())
Spacer(modifier = Modifier.height(12.dp))
TransactionDetail("Sent", transaction.sent.toString())
Spacer(modifier = Modifier.height(12.dp))
TransactionDetail("Received", transaction.received.toString())
Spacer(modifier = Modifier.height(12.dp))
TransactionDetail("Payments", transaction.payments.toString())
Spacer(modifier = Modifier.height(12.dp))
TransactionDetail("Time lock", transaction.timeLock.toString())
Spacer(modifier = Modifier.height(12.dp))
TransactionDetail("Change", MoneroCurrency.format(transaction.change))
Spacer(modifier = Modifier.height(12.dp))
TransactionDetail("Fee", MoneroCurrency.format(transaction.fee))
Spacer(modifier = Modifier.height(12.dp))
TransactionDetail("Transaction ID", transaction.txId)
Spacer(modifier = Modifier.height(12.dp))
}
}
}
@Composable
private fun TransactionDetail(
label: String,
value: String,
modifier: Modifier = Modifier,
) {
Column {
Text(label, style = MaterialTheme.typography.labelMedium, modifier = modifier)
Text(value, style = MaterialTheme.typography.bodyMedium, modifier = modifier)
}
}
@Preview
@Composable
private fun TransactionCardExpandedPreview(
@PreviewParameter(
TransactionCardPreviewParameterProvider::class,
limit = 1,
) transactions: List<Transaction>,
) {
AppTheme {
Surface {
TransactionCardExpanded(
transaction = transactions.first(),
onClick = {},
)
}
}
}
private class TransactionCardPreviewParameterProvider : PreviewParameterProvider<List<Transaction>> {
override val values = sequenceOf(PreviewParameterData.transactions)
}

View File

@ -0,0 +1,23 @@
package im.molly.monero.demo.ui
import TransactionCardExpanded
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.items
import androidx.compose.ui.Modifier
import im.molly.monero.demo.data.model.WalletTransaction
fun LazyListScope.transactionCardItems(
items: List<WalletTransaction>,
onTransactionClick: (txId: String, walletId: Long) -> Unit,
itemModifier: Modifier = Modifier,
) = items(
items = items,
key = { it.transaction.txId },
itemContent = {
TransactionCardExpanded(
transaction = it.transaction,
onClick = { onTransactionClick(it.transaction.txId, it.walletId) },
modifier = itemModifier,
)
},
)

View File

@ -0,0 +1,158 @@
package im.molly.monero.demo.ui
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
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.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import im.molly.monero.MoneroCurrency
import im.molly.monero.Transaction
import im.molly.monero.demo.ui.component.Toolbar
import im.molly.monero.demo.ui.preview.PreviewParameterData
import im.molly.monero.demo.ui.theme.AppIcons
import im.molly.monero.demo.ui.theme.AppTheme
@Composable
fun TransactionRoute(
txId: String,
walletId: Long,
onBackClick: () -> Unit,
modifier: Modifier = Modifier,
viewModel: TransactionViewModel = viewModel(
factory = TransactionViewModel.factory(txId, walletId),
key = TransactionViewModel.key(txId, walletId),
)
) {
val uiState: TxUiState by viewModel.uiState.collectAsStateWithLifecycle()
TransactionScreen(
uiState = uiState,
onBackClick = onBackClick,
modifier = modifier,
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun TransactionScreen(
uiState: TxUiState,
modifier: Modifier = Modifier,
onBackClick: () -> Unit,
) {
Scaffold(topBar = {
Toolbar(
navigationIcon = {
IconButton(onClick = onBackClick) {
Icon(
imageVector = AppIcons.ArrowBack,
contentDescription = "Back",
)
}
},
title = "Transaction details",
)
}) { padding ->
Box(
modifier = modifier
.padding(padding)
.verticalScroll(rememberScrollState()),
) {
Column(Modifier.padding(10.dp)) {
when (uiState) {
is TxUiState.Loaded -> {
val tx = uiState.transaction
TransactionDetail("State", tx.state.toString())
Spacer(modifier = Modifier.height(12.dp))
TransactionDetail("Sent", tx.sent.toString())
Spacer(modifier = Modifier.height(12.dp))
TransactionDetail("Received", tx.received.toString())
Spacer(modifier = Modifier.height(12.dp))
TransactionDetail("Payments", tx.payments.toString())
Spacer(modifier = Modifier.height(12.dp))
TransactionDetail("Time lock", tx.timeLock.toString())
Spacer(modifier = Modifier.height(12.dp))
TransactionDetail("Change", MoneroCurrency.format(tx.change))
Spacer(modifier = Modifier.height(12.dp))
TransactionDetail("Fee", MoneroCurrency.format(tx.fee))
Spacer(modifier = Modifier.height(12.dp))
TransactionDetail("Transaction ID", tx.txId)
Spacer(modifier = Modifier.height(12.dp))
}
else -> Unit
}
}
}
}
}
@Composable
private fun TransactionDetail(
label: String,
value: String,
modifier: Modifier = Modifier,
) {
Column() {
Text(label, style = MaterialTheme.typography.labelMedium, modifier = modifier)
Text(value, style = MaterialTheme.typography.bodyMedium, modifier = modifier)
}
}
@Preview
@Composable
private fun TransactionScreenPopulated(
@PreviewParameter(
TransactionScreenPreviewParameterProvider::class,
limit = 1,
) transactions: List<Transaction>,
) {
AppTheme {
TransactionScreen(
uiState = TxUiState.Loaded(transactions.first()),
onBackClick = {},
)
}
}
@Preview
@Composable
private fun TransactionScreenError() {
AppTheme {
TransactionScreen(
uiState = TxUiState.Error,
onBackClick = {},
)
}
}
@Preview
@Composable
private fun TransactionScreenNotFound() {
AppTheme {
TransactionScreen(
uiState = TxUiState.NotFound,
onBackClick = {},
)
}
}
class TransactionScreenPreviewParameterProvider : PreviewParameterProvider<List<Transaction>> {
override val values = sequenceOf(PreviewParameterData.transactions)
}

View File

@ -0,0 +1,58 @@
package im.molly.monero.demo.ui
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import im.molly.monero.Transaction
import im.molly.monero.demo.AppModule
import im.molly.monero.demo.common.Result
import im.molly.monero.demo.common.asResult
import im.molly.monero.demo.data.WalletRepository
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
class TransactionViewModel(
txId: String,
walletId: Long,
walletRepository: WalletRepository = AppModule.walletRepository,
) : ViewModel() {
val uiState: StateFlow<TxUiState> =
walletRepository.getTransaction(walletId, txId)
.asResult()
.map { result ->
when (result) {
is Result.Success -> result.data?.let { tx ->
TxUiState.Loaded(tx)
} ?: TxUiState.NotFound
is Result.Error -> TxUiState.Error
is Result.Loading -> TxUiState.Loading
}
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = TxUiState.Loading
)
companion object {
fun factory(txId: String, walletId: Long) = viewModelFactory {
initializer {
TransactionViewModel(txId, walletId)
}
}
fun key(txId: String, walletId: Long): String = "tx_$txId:$walletId"
}
}
sealed interface TxUiState {
data class Loaded(val transaction: Transaction) : TxUiState
data object Error : TxUiState
data object Loading : TxUiState
data object NotFound : TxUiState
}

View File

@ -3,7 +3,9 @@ package im.molly.monero.demo.ui
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Divider import androidx.compose.material3.Divider
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@ -51,10 +53,12 @@ fun WalletBalanceView(
.padding(16.dp) .padding(16.dp)
) { ) {
Text( Text(
style = MaterialTheme.typography.headlineSmall, style = MaterialTheme.typography.bodyLarge,
text = "Balance at Block #${blockchainTime.height}", text = "Balance at Block #${blockchainTime.height}",
) )
Spacer(modifier = Modifier.height(12.dp))
BalanceRow("Confirmed", balance.confirmedAmount) BalanceRow("Confirmed", balance.confirmedAmount)
BalanceRow("Pending", balance.pendingAmount) BalanceRow("Pending", balance.pendingAmount)
Divider() Divider()
@ -71,7 +75,6 @@ fun WalletBalanceView(
fun BalanceRow( fun BalanceRow(
label: String, label: String,
amount: MoneroAmount, amount: MoneroAmount,
format: MoneroCurrency.Format = MoneroCurrency.Format(),
) { ) {
Row( Row(
modifier = Modifier modifier = Modifier
@ -79,8 +82,8 @@ fun BalanceRow(
.padding(vertical = 8.dp), .padding(vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
) { ) {
Text(text = label) Text(text = label, style = MaterialTheme.typography.bodyMedium)
Text(text = format.format(amount)) Text(text = MoneroCurrency.format(amount), style = MaterialTheme.typography.bodyMedium)
} }
} }
@ -90,7 +93,6 @@ fun LockedBalanceRow(
amount: MoneroAmount, amount: MoneroAmount,
blockCount: Int, blockCount: Int,
timeRemaining: Duration, timeRemaining: Duration,
moneroFormat: MoneroCurrency.Format = MoneroCurrency.DefaultFormat,
) { ) {
Row( Row(
modifier = Modifier modifier = Modifier
@ -101,10 +103,10 @@ fun LockedBalanceRow(
val durationText = val durationText =
"${timeRemaining.toKotlinDuration()} ($blockCount blocks)" "${timeRemaining.toKotlinDuration()} ($blockCount blocks)"
Column { Column {
Text(text = label) Text(text = label, style = MaterialTheme.typography.bodyMedium)
Text(text = durationText, fontSize = 12.sp) Text(text = durationText, style = MaterialTheme.typography.bodySmall)
} }
Text(text = moneroFormat.format(amount)) Text(text = MoneroCurrency.format(amount), style = MaterialTheme.typography.bodyMedium)
} }
} }

View File

@ -5,50 +5,81 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import im.molly.monero.Balance
import im.molly.monero.Ledger
import im.molly.monero.demo.data.model.WalletConfig
import im.molly.monero.demo.ui.theme.AppTheme
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun WalletCard( fun WalletCard(
walletId: Long, walletId: Long,
onClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onClick: (Long) -> Unit,
viewModel: WalletViewModel = viewModel( viewModel: WalletViewModel = viewModel(
factory = WalletViewModel.factory(walletId), factory = WalletViewModel.factory(walletId),
key = walletId.toString(), key = WalletViewModel.key(walletId),
), ),
) { ) {
val walletUiState: WalletUiState by viewModel.walletUiState.collectAsStateWithLifecycle() val uiState: WalletUiState by viewModel.uiState.collectAsStateWithLifecycle()
Card( Card(
onClick = { onClick(walletId) }, onClick = onClick, modifier = modifier.padding(8.dp)
modifier = modifier
.padding(8.dp)
) { ) {
Column { Column {
when (walletUiState) { when (uiState) {
WalletUiState.Error -> { is WalletUiState.Loaded -> {
WalletCardExpanded(
(uiState as WalletUiState.Loaded).config,
(uiState as WalletUiState.Loaded).balance,
)
}
WalletUiState.Error -> WalletCardError()
WalletUiState.Loading -> WalletCardLoading()
}
}
}
}
@Composable
fun WalletCardExpanded(
config: WalletConfig,
balance: Balance,
) {
Row {
Text(text = "Name: ${config.name}")
}
Row {
Text(text = "Ledger: $balance")
}
}
@Composable
fun WalletCardError() {
Text(text = "Error") // TODO Text(text = "Error") // TODO
} }
WalletUiState.Loading -> {
@Composable
fun WalletCardLoading() {
Text(text = "Loading wallet...") // TODO Text(text = "Loading wallet...") // TODO
} }
is WalletUiState.Success -> {
val state = walletUiState as WalletUiState.Success //@Preview
Row { //@Composable
Text(text = "Name: ${state.config.name}") //private fun WalletCardExpandedPreview() {
} // AppTheme {
Row { // Surface {
Text(text = "Ledger: ${state.ledger}") // WalletCardExpanded()
} // }
} // }
} //}
}
}
}

View File

@ -4,9 +4,9 @@ import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
fun LazyListScope.walletCardsItems( fun LazyListScope.walletCardItems(
items: List<Long>, items: List<Long>,
onItemClick: (Long) -> Unit, onItemClick: (walletId: Long) -> Unit,
itemModifier: Modifier = Modifier, itemModifier: Modifier = Modifier,
) = items( ) = items(
items = items, items = items,
@ -14,7 +14,7 @@ fun LazyListScope.walletCardsItems(
itemContent = { itemContent = {
WalletCard( WalletCard(
walletId = it, walletId = it,
onClick = onItemClick, onClick = { onItemClick(it) },
modifier = itemModifier, modifier = itemModifier,
) )
}, },

View File

@ -0,0 +1,31 @@
package im.molly.monero.demo.ui
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import im.molly.monero.demo.AppModule
import im.molly.monero.demo.data.WalletRepository
import kotlinx.coroutines.flow.*
class WalletListViewModel(
walletRepository: WalletRepository = AppModule.walletRepository,
) : ViewModel() {
val uiState: StateFlow<WalletListUiState> =
walletRepository.getWalletIdList().map { walletIds ->
if (walletIds.isNotEmpty()) {
WalletListUiState.Loaded(walletIds)
} else {
WalletListUiState.Empty
}
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = WalletListUiState.Loading,
)
}
sealed interface WalletListUiState {
data class Loaded(val walletIds: List<Long>) : WalletListUiState
data object Loading : WalletListUiState
data object Empty : WalletListUiState
}

View File

@ -1,39 +1,46 @@
package im.molly.monero.demo.ui package im.molly.monero.demo.ui
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.withStyle import androidx.compose.ui.text.withStyle
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import im.molly.monero.BlockchainTime
import im.molly.monero.Ledger import im.molly.monero.Ledger
import im.molly.monero.MoneroCurrency import im.molly.monero.MoneroCurrency
import im.molly.monero.demo.data.model.WalletConfig import im.molly.monero.demo.data.model.WalletConfig
import im.molly.monero.demo.ui.component.Toolbar import im.molly.monero.demo.ui.component.Toolbar
import im.molly.monero.demo.ui.preview.PreviewParameterData
import im.molly.monero.demo.ui.theme.AppIcons import im.molly.monero.demo.ui.theme.AppIcons
import im.molly.monero.demo.ui.theme.AppTheme import im.molly.monero.demo.ui.theme.AppTheme
@Composable @Composable
fun WalletRoute( fun WalletRoute(
walletId: Long, walletId: Long,
onTransactionClick: (String, Long) -> Unit,
onBackClick: () -> Unit, onBackClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: WalletViewModel = viewModel( viewModel: WalletViewModel = viewModel(
factory = WalletViewModel.factory(walletId), factory = WalletViewModel.factory(walletId),
key = walletId.toString(), key = WalletViewModel.key(walletId),
) )
) { ) {
val walletUiState: WalletUiState by viewModel.walletUiState.collectAsStateWithLifecycle() val uiState: WalletUiState by viewModel.uiState.collectAsStateWithLifecycle()
WalletScreen( WalletScreen(
uiState = walletUiState, uiState = uiState,
onWalletConfigChange = { config -> viewModel.updateConfig(config) }, onWalletConfigChange = { config -> viewModel.updateConfig(config) },
onTransactionClick = onTransactionClick,
onBackClick = onBackClick, onBackClick = onBackClick,
modifier = modifier, modifier = modifier,
) )
@ -42,20 +49,113 @@ fun WalletRoute(
@Composable @Composable
private fun WalletScreen( private fun WalletScreen(
uiState: WalletUiState, uiState: WalletUiState,
onWalletConfigChange: (WalletConfig) -> Unit,
onTransactionClick: (String, Long) -> Unit,
onBackClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onWalletConfigChange: (WalletConfig) -> Unit = {},
onBackClick: () -> Unit = {},
) { ) {
when (uiState) { when (uiState) {
WalletUiState.Error -> WalletScreenError(onBackClick = onBackClick) is WalletUiState.Loaded -> WalletScreenLoaded(
WalletUiState.Loading -> WalletScreenLoading(onBackClick = onBackClick) uiState = uiState,
is WalletUiState.Success -> WalletScreenPopulated(
walletConfig = uiState.config,
ledger = uiState.ledger,
onWalletConfigChange = onWalletConfigChange, onWalletConfigChange = onWalletConfigChange,
onTransactionClick = onTransactionClick,
onBackClick = onBackClick, onBackClick = onBackClick,
modifier = modifier, modifier = modifier,
) )
WalletUiState.Error -> WalletScreenError(onBackClick = onBackClick)
WalletUiState.Loading -> WalletScreenLoading(onBackClick = onBackClick)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun WalletScreenLoaded(
uiState: WalletUiState.Loaded,
onWalletConfigChange: (WalletConfig) -> Unit,
onTransactionClick: (String, Long) -> Unit,
onBackClick: () -> Unit,
modifier: Modifier = Modifier,
) {
var showRenameDialog by remember { mutableStateOf(false) }
Scaffold(topBar = {
Toolbar(navigationIcon = {
IconButton(onClick = onBackClick) {
Icon(
imageVector = AppIcons.ArrowBack,
contentDescription = "Back",
)
}
}, actions = {
WalletKebabMenu(
onRenameClick = { showRenameDialog = true },
onDeleteClick = { },
)
})
}) { padding ->
Column(
modifier = modifier
.fillMaxSize()
.padding(padding),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(style = MaterialTheme.typography.headlineLarge, text = buildAnnotatedString {
append(MoneroCurrency.SYMBOL + " ")
withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) {
append(
MoneroCurrency.Format(precision = 5).format(uiState.balance.confirmedAmount)
)
}
})
Text(text = uiState.config.name, style = MaterialTheme.typography.headlineSmall)
var selectedTabIndex by rememberSaveable { mutableStateOf(0) }
WalletHeaderTabs(
titles = listOf("Balance", "Transactions"),
selectedTabIndex = selectedTabIndex,
onTabSelected = { index -> selectedTabIndex = index },
)
when (selectedTabIndex) {
0 -> {
WalletBalanceView(balance = uiState.balance, blockchainTime = uiState.blockchainTime)
}
1 -> {
LazyColumn {
transactionCardItems(
items = uiState.transactions,
onTransactionClick = onTransactionClick,
)
}
}
}
}
if (showRenameDialog) {
var name by remember { mutableStateOf(uiState.config.name) }
AlertDialog(
onDismissRequest = { showRenameDialog = false },
title = { Text("Enter wallet name") },
text = {
OutlinedTextField(
value = name,
onValueChange = { name = it },
singleLine = true,
)
},
confirmButton = {
TextButton(onClick = {
onWalletConfigChange(uiState.config.copy(name = name))
showRenameDialog = false
}) {
Text("Rename")
}
},
)
}
} }
} }
@ -71,85 +171,6 @@ private fun WalletScreenLoading(
) { ) {
} }
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun WalletScreenPopulated(
walletConfig: WalletConfig,
ledger: Ledger,
onWalletConfigChange: (WalletConfig) -> Unit,
onBackClick: () -> Unit,
modifier: Modifier = Modifier,
) {
var showRenameDialog by remember { mutableStateOf(false) }
Scaffold(
topBar = {
Toolbar(
navigationIcon = {
IconButton(onClick = onBackClick) {
Icon(
imageVector = AppIcons.ArrowBack,
contentDescription = "Back",
)
}
},
actions = {
WalletKebabMenu(
onRenameClick = { showRenameDialog = true },
onDeleteClick = { },
)
}
)
}
) { padding ->
Column(
modifier = modifier
.fillMaxSize()
.padding(padding),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
style = MaterialTheme.typography.headlineLarge,
text = buildAnnotatedString {
append(MoneroCurrency.SYMBOL + " ")
withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) {
append(MoneroCurrency.Format(precision = 5).format(ledger.balance.confirmedAmount))
}
}
)
Text(text = walletConfig.name, style = MaterialTheme.typography.headlineSmall)
WalletBalanceView(
balance = ledger.balance,
blockchainTime = ledger.checkedAt,
)
}
if (showRenameDialog) {
var name by remember { mutableStateOf(walletConfig.name) }
AlertDialog(
onDismissRequest = { showRenameDialog = false },
title = { Text("Enter wallet name") },
text = {
OutlinedTextField(
value = name,
onValueChange = { name = it },
singleLine = true,
)
},
confirmButton = {
TextButton(onClick = {
onWalletConfigChange(walletConfig.copy(name = name))
showRenameDialog = false
}) {
Text("Rename")
}
},
)
}
}
}
@Composable @Composable
private fun WalletKebabMenu( private fun WalletKebabMenu(
onRenameClick: () -> Unit, onRenameClick: () -> Unit,
@ -184,26 +205,57 @@ private fun WalletKebabMenu(
} }
} }
@Composable
fun WalletHeaderTabs(
titles: List<String>,
selectedTabIndex: Int,
onTabSelected: (Int) -> Unit,
) {
Column {
TabRow(selectedTabIndex = selectedTabIndex) {
titles.forEachIndexed { index, title ->
Tab(
selected = index == selectedTabIndex,
onClick = { onTabSelected(index) },
text = {
Text(
text = title,
style = MaterialTheme.typography.bodyLarge,
overflow = TextOverflow.Ellipsis,
)
},
)
}
}
}
}
@Preview @Preview
@Composable @Composable
private fun WalletScreenPreview() { private fun WalletScreenPopulated(
@PreviewParameter(WalletScreenPreviewParameterProvider::class) ledger: Ledger,
) {
AppTheme { AppTheme {
WalletScreen( WalletScreen(
uiState = WalletUiState.Success( uiState = WalletUiState.Loaded(
WalletConfig( config = WalletConfig(
id = 0, id = 0,
publicAddress = "888tNkZrPN6JsEgekjMnABU4TBzc2Dt29EPAvkRxbANsAnjyPbb3iQ1YBRk1UXcdRsiKc9dhwMVgN5S9cQUiyoogDavup3H", publicAddress = ledger.primaryAddress,
filename = "", filename = "",
name = "Personal", name = "Personal",
remoteNodes = emptySet(), remoteNodes = emptySet(),
), ),
Ledger( balance = ledger.balance,
publicAddress = "888tNkZrPN6JsEgekjMnABU4TBzc2Dt29EPAvkRxbANsAnjyPbb3iQ1YBRk1UXcdRsiKc9dhwMVgN5S9cQUiyoogDavup3H", blockchainTime = ledger.checkedAt,
transactions = emptyMap(), transactions = emptyList(),
enotes = emptySet(),
checkedAt = BlockchainTime.Genesis,
),
), ),
onWalletConfigChange = {},
onTransactionClick = { _: String, _: Long -> },
onBackClick = {},
) )
} }
} }
private class WalletScreenPreviewParameterProvider : PreviewParameterProvider<Ledger> {
override val values = sequenceOf(PreviewParameterData.ledger)
}

View File

@ -4,21 +4,23 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory import androidx.lifecycle.viewmodel.viewModelFactory
import im.molly.monero.Ledger import im.molly.monero.Balance
import im.molly.monero.BlockchainTime
import im.molly.monero.demo.AppModule import im.molly.monero.demo.AppModule
import im.molly.monero.demo.common.Result import im.molly.monero.demo.common.Result
import im.molly.monero.demo.common.asResult import im.molly.monero.demo.common.asResult
import im.molly.monero.demo.data.WalletRepository import im.molly.monero.demo.data.WalletRepository
import im.molly.monero.demo.data.model.WalletConfig import im.molly.monero.demo.data.model.WalletConfig
import im.molly.monero.demo.data.model.WalletTransaction
import kotlinx.coroutines.flow.* import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
class WalletViewModel( class WalletViewModel(
private val walletId: Long, walletId: Long,
private val walletRepository: WalletRepository = AppModule.walletRepository, private val walletRepository: WalletRepository = AppModule.walletRepository,
) : ViewModel() { ) : ViewModel() {
val walletUiState: StateFlow<WalletUiState> = walletUiState( val uiState: StateFlow<WalletUiState> = walletUiState(
walletId = walletId, walletId = walletId,
walletRepository = walletRepository, walletRepository = walletRepository,
).stateIn( ).stateIn(
@ -33,6 +35,8 @@ class WalletViewModel(
WalletViewModel(walletId) WalletViewModel(walletId)
} }
} }
fun key(walletId: Long): String = "wallet_$walletId"
} }
fun updateConfig(config: WalletConfig) { fun updateConfig(config: WalletConfig) {
@ -53,11 +57,19 @@ private fun walletUiState(
).asResult().map { result -> ).asResult().map { result ->
when (result) { when (result) {
is Result.Success -> { is Result.Success -> {
WalletUiState.Success(result.data.first, result.data.second) val config = result.data.first
val ledger = result.data.second
val balance = ledger.balance
val blockchainTime = ledger.checkedAt
val transactions =
ledger.transactions.map { WalletTransaction(config.id, it.value) }
WalletUiState.Loaded(config, blockchainTime, balance, transactions)
} }
is Result.Loading -> { is Result.Loading -> {
WalletUiState.Loading WalletUiState.Loading
} }
is Result.Error -> { is Result.Error -> {
WalletUiState.Error WalletUiState.Error
} }
@ -66,9 +78,11 @@ private fun walletUiState(
} }
sealed interface WalletUiState { sealed interface WalletUiState {
data class Success( data class Loaded(
val config: WalletConfig, val config: WalletConfig,
val ledger: Ledger, val blockchainTime: BlockchainTime,
val balance: Balance,
val transactions: List<WalletTransaction>,
) : WalletUiState ) : WalletUiState
data object Error : WalletUiState data object Error : WalletUiState

View File

@ -12,8 +12,12 @@ fun NavController.navigateToHistory(navOptions: NavOptions? = null) {
navigate(historyNavRoute, navOptions) navigate(historyNavRoute, navOptions)
} }
fun NavGraphBuilder.historyScreen() { fun NavGraphBuilder.historyScreen(
navigateToTransaction: (String, Long) -> Unit,
) {
composable(route = historyNavRoute) { composable(route = historyNavRoute) {
HistoryRoute() HistoryRoute(
navigateToTransaction = navigateToTransaction,
)
} }
} }

View File

@ -25,13 +25,23 @@ fun NavGraph(
navController.navigateToAddWalletWizardGraph() navController.navigateToAddWalletWizardGraph()
}, },
) )
historyScreen() historyScreen(
navigateToTransaction = { txId, walletId ->
navController.navigateToTransaction(txId, walletId)
},
)
settingsScreen( settingsScreen(
navigateToEditRemoteNode = { remoteNodeId -> navigateToEditRemoteNode = { remoteNodeId ->
navController.navigateToEditRemoteNode(remoteNodeId) navController.navigateToEditRemoteNode(remoteNodeId)
}, },
) )
walletScreen( walletScreen(
navigateToTransaction = { txId, walletId ->
navController.navigateToTransaction(txId, walletId)
},
onBackClick = onBackClick,
)
transactionScreen(
onBackClick = onBackClick, onBackClick = onBackClick,
) )
editRemoteNodeDialog( editRemoteNodeDialog(

View File

@ -0,0 +1,36 @@
package im.molly.monero.demo.ui.navigation
import androidx.navigation.*
import androidx.navigation.compose.composable
import im.molly.monero.demo.ui.TransactionRoute
const val transactionNavRoute = "tx"
private const val txIdArg = "txId"
private const val walletIdArg = "walletId"
fun NavController.navigateToTransaction(txId: String, walletId: Long) {
val route = "$transactionNavRoute/$txId/$walletId"
navigate(route)
}
fun NavGraphBuilder.transactionScreen(
onBackClick: () -> Unit,
) {
composable(
route = "$transactionNavRoute/{$txIdArg}/{$walletIdArg}",
arguments = listOf(
navArgument(txIdArg) { type = NavType.StringType },
navArgument(walletIdArg) { type = NavType.LongType },
)
) {
val arguments = requireNotNull(it.arguments)
val txId = arguments.getString(txIdArg)
val walletId = arguments.getLong(walletIdArg)
TransactionRoute(
txId = txId!!,
walletId = walletId,
onBackClick = onBackClick,
)
}
}

View File

@ -32,6 +32,7 @@ fun NavController.navigateToAddWalletSecondStep(restoreWallet: Boolean) {
} }
fun NavGraphBuilder.walletScreen( fun NavGraphBuilder.walletScreen(
navigateToTransaction: (String, Long) -> Unit,
onBackClick: () -> Unit, onBackClick: () -> Unit,
) { ) {
composable( composable(
@ -44,6 +45,7 @@ fun NavGraphBuilder.walletScreen(
val walletId = arguments.getLong(walletIdArg) val walletId = arguments.getLong(walletIdArg)
WalletRoute( WalletRoute(
walletId = walletId, walletId = walletId,
onTransactionClick = navigateToTransaction,
onBackClick = onBackClick, onBackClick = onBackClick,
) )
} }

View File

@ -0,0 +1,37 @@
package im.molly.monero.demo.ui.preview
import im.molly.monero.BlockHeader
import im.molly.monero.BlockchainTime
import im.molly.monero.HashDigest
import im.molly.monero.Ledger
import im.molly.monero.MoneroAmount
import im.molly.monero.PaymentDetail
import im.molly.monero.PublicAddress
import im.molly.monero.Transaction
import im.molly.monero.TxState
import im.molly.monero.xmr
object PreviewParameterData {
val recipients =
listOf(PublicAddress.parse("888tNkZrPN6JsEgekjMnABU4TBzc2Dt29EPAvkRxbANsAnjyPbb3iQ1YBRk1UXcdRsiKc9dhwMVgN5S9cQUiyoogDavup3H"))
val transactions = listOf(
Transaction(
hash = HashDigest("e7a60483591378d536792d070f2bf6ccb7d0666df03b57f485ddaf66899a294b"),
state = TxState.OnChain(BlockHeader(height = 2999840, epochSecond = 1697792826)),
timeLock = BlockchainTime.Block(2999850),
sent = emptySet(),
received = emptySet(),
payments = listOf(PaymentDetail((0.10).xmr, recipients.first())),
fee = 0.00093088.xmr,
change = MoneroAmount.ZERO,
),
)
val ledger = Ledger(
primaryAddress = "4AYjQM9HoAFNUeC3cvSfgeAN89oMMpMqiByvunzSzhn97cj726rJj3x8hCbH58UnMqQJShczCxbpWRiCJQ3HCUDHLiKuo4T",
checkedAt = BlockchainTime.Block(2999840),
enotes = emptySet(),
transactions = transactions.associateBy { it.txId },
)
}

View File

@ -6,14 +6,18 @@ data class AccountAddress(
val subAddressIndex: Int = 0, val subAddressIndex: Int = 0,
) : PublicAddress by publicAddress { ) : PublicAddress by publicAddress {
val isPrimaryAddress: Boolean
get() = accountIndex == 0 && subAddressIndex == 0
init { init {
when (publicAddress) { when (publicAddress) {
is SubAddress -> require(accountIndex != -1 && subAddressIndex != -1) is StandardAddress -> require(isPrimaryAddress) {
else -> require(accountIndex == 0 && subAddressIndex == 0) "Standard addresses must have subaddress indices set to zero"
}
is SubAddress -> require(accountIndex != -1 && subAddressIndex != -1) {
"Invalid subaddress indices"
}
else -> throw IllegalArgumentException("Unsupported address type")
} }
} }
fun belongsTo(targetAccountIndex: Int): Boolean {
return accountIndex == targetAccountIndex
}
} }

View File

@ -9,7 +9,8 @@ class Enote(
val sourceTxId: String?, val sourceTxId: String?,
) { ) {
init { init {
require(age >= 0) { "Enote age $age must not be negative" } require(amount > 0) { "Amount must be greater than 0" }
require(age >= 0) { "Age cannot be negative" }
} }
var spent: Boolean = false var spent: Boolean = false

View File

@ -3,7 +3,7 @@ package im.molly.monero
//import im.molly.monero.proto.LedgerProto //import im.molly.monero.proto.LedgerProto
data class Ledger( data class Ledger(
val publicAddress: String, val primaryAddress: String,
val transactions: Map<String, Transaction>, val transactions: Map<String, Transaction>,
val enotes: Set<TimeLocked<Enote>>, val enotes: Set<TimeLocked<Enote>>,
val checkedAt: BlockchainTime, val checkedAt: BlockchainTime,

View File

@ -15,7 +15,8 @@ value class MoneroAmount(val atomicUnits: Long) : Parcelable {
val ZERO = MoneroAmount(0) val ZERO = MoneroAmount(0)
} }
fun toXmr(): BigDecimal = BigDecimal.valueOf(atomicUnits, ATOMIC_UNIT_SCALE) val xmr: BigDecimal
get() = BigDecimal.valueOf(atomicUnits, ATOMIC_UNIT_SCALE)
override fun toString() = atomicUnits.toString() override fun toString() = atomicUnits.toString()

View File

@ -8,10 +8,8 @@ object MoneroCurrency {
const val MAX_PRECISION = MoneroAmount.ATOMIC_UNIT_SCALE const val MAX_PRECISION = MoneroAmount.ATOMIC_UNIT_SCALE
val DefaultFormat = Format() open class Format(
val precision: Int,
data class Format(
val precision: Int = MAX_PRECISION,
val locale: Locale = Locale.US, val locale: Locale = Locale.US,
) { ) {
init { init {
@ -24,10 +22,36 @@ object MoneroCurrency {
minimumFractionDigits = precision minimumFractionDigits = precision
} }
fun format(amount: MoneroAmount): String = numberFormat.format(amount.toXmr()) open fun format(amount: MoneroAmount): String {
return numberFormat.format(amount.xmr)
}
fun parse(source: String): MoneroAmount { open fun parse(source: String): MoneroAmount {
TODO() TODO()
} }
} }
val ExactFormat = object : Format(MoneroAmount.ATOMIC_UNIT_SCALE) {
override fun format(amount: MoneroAmount) = buildString {
val num = amount.atomicUnits.toString()
if (precision < num.length) {
val point = num.length - precision
append(num.substring(0, point))
append('.')
append(num.substring(point))
} else {
append("0.")
for (i in 1..(precision - num.length)) {
append('0')
}
append(num)
}
}
}
fun format(amount: MoneroAmount, outputFormat: Format = ExactFormat): String {
return outputFormat.format(amount)
}
} }

View File

@ -2,7 +2,8 @@ package im.molly.monero
import im.molly.monero.util.decodeBase58 import im.molly.monero.util.decodeBase58
interface PublicAddress { sealed interface PublicAddress {
val address: String
val network: MoneroNetwork val network: MoneroNetwork
val subAddress: Boolean val subAddress: Boolean
// viewPublicKey: ByteArray // viewPublicKey: ByteArray
@ -19,19 +20,18 @@ interface PublicAddress {
throw InvalidAddress("Address too short") throw InvalidAddress("Address too short")
} }
val prefix = decoded[0].toLong() return when (val prefix = decoded[0].toLong()) {
in StandardAddress.prefixes -> {
StandardAddress.prefixes[prefix]?.let { network -> StandardAddress(publicAddress, StandardAddress.prefixes[prefix]!!)
return StandardAddress(network)
} }
SubAddress.prefixes[prefix]?.let { network -> in SubAddress.prefixes -> {
return SubAddress(network) SubAddress(publicAddress, SubAddress.prefixes[prefix]!!)
} }
IntegratedAddress.prefixes[prefix]?.let { network -> in IntegratedAddress.prefixes -> {
TODO() TODO()
} }
else -> throw InvalidAddress("Unrecognized address prefix")
throw InvalidAddress("Unrecognized address prefix") }
} }
} }
} }
@ -39,6 +39,7 @@ interface PublicAddress {
class InvalidAddress(message: String, cause: Throwable? = null) : Exception(message, cause) class InvalidAddress(message: String, cause: Throwable? = null) : Exception(message, cause)
data class StandardAddress( data class StandardAddress(
override val address: String,
override val network: MoneroNetwork, override val network: MoneroNetwork,
) : PublicAddress { ) : PublicAddress {
override val subAddress = false override val subAddress = false
@ -50,9 +51,12 @@ data class StandardAddress(
24L to MoneroNetwork.Stagenet, 24L to MoneroNetwork.Stagenet,
) )
} }
override fun toString(): String = address
} }
data class SubAddress( data class SubAddress(
override val address: String,
override val network: MoneroNetwork, override val network: MoneroNetwork,
) : PublicAddress { ) : PublicAddress {
override val subAddress = true override val subAddress = true
@ -64,9 +68,12 @@ data class SubAddress(
36L to MoneroNetwork.Stagenet, 36L to MoneroNetwork.Stagenet,
) )
} }
override fun toString(): String = address
} }
data class IntegratedAddress( data class IntegratedAddress(
override val address: String,
override val network: MoneroNetwork, override val network: MoneroNetwork,
val paymentId: Long, val paymentId: Long,
) : PublicAddress { ) : PublicAddress {
@ -79,4 +86,6 @@ data class IntegratedAddress(
25L to MoneroNetwork.Stagenet, 25L to MoneroNetwork.Stagenet,
) )
} }
override fun toString(): String = address
} }