demo: initial send functionality on wallet screen

This commit is contained in:
Oscar Mira 2024-03-03 12:57:54 +01:00
parent 7f167fb145
commit abb43563e3
19 changed files with 527 additions and 107 deletions

View File

@ -63,6 +63,10 @@ class WalletRepository(
fun getTransaction(walletId: Long, txId: String): Flow<Transaction?> =
getLedger(walletId).map { it.transactionById[txId] }
suspend fun createTransfer(walletId: Long, transferRequest: TransferRequest): PendingTransfer {
return getWallet(walletId).createTransfer(transferRequest)
}
suspend fun addWallet(
moneroNetwork: MoneroNetwork,
name: String,

View File

@ -170,10 +170,10 @@ private fun SecondStepScreen(
)
SelectListBox(
label = "Network",
options = MoneroNetwork.values().map { it.name },
selectedOption = network.name,
options = MoneroNetwork.entries.associateWith { it.name },
selectedOption = network,
onOptionClick = {
onNetworkChanged(MoneroNetwork.valueOf(it))
onNetworkChanged(it)
},
modifier = Modifier
.fillMaxWidth()

View File

@ -7,14 +7,11 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.unit.dp
import im.molly.monero.demo.data.model.WalletAddress
import im.molly.monero.demo.ui.component.CopyableText
import im.molly.monero.demo.ui.theme.Blue40
import im.molly.monero.demo.ui.theme.Red40
@Composable
fun AddressCardExpanded(
@ -47,18 +44,14 @@ fun AddressCardExpanded(
modifier = if (used) Modifier.alpha(0.5f) else Modifier,
)
if (walletAddress.isLastForAccount) {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
TextButton(onClick = onCreateSubAddressClick) {
Text(
text = "Add subaddress",
style = MaterialTheme.typography.bodySmall,
)
}
TextButton(onClick = onCreateSubAddressClick) {
Text(
text = "Add subaddress",
style = MaterialTheme.typography.bodySmall,
)
}
}
}
}
}

View File

@ -67,10 +67,10 @@ private fun EditRemoteNodeDialog(
)
SelectListBox(
label = "Network",
options = MoneroNetwork.values().map { it.name },
selectedOption = remoteNode.network.name,
options = MoneroNetwork.entries.associateWith { it.name },
selectedOption = remoteNode.network,
onOptionClick = {
onRemoteNodeChange(remoteNode.copy(network = MoneroNetwork.valueOf(it)))
onRemoteNodeChange(remoteNode.copy(network = it))
}
)
Column {

View File

@ -0,0 +1,123 @@
package im.molly.monero.demo.ui
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import im.molly.monero.MoneroCurrency
@Composable
fun EditableRecipientList(
recipients: List<Pair<String, String>>,
modifier: Modifier = Modifier,
enabled: Boolean = true,
onRecipientChange: (List<Pair<String, String>>) -> Unit = {},
) {
val updatedList = recipients.toMutableList()
if (updatedList.isEmpty()) {
updatedList.add("" to "")
onRecipientChange(updatedList)
}
Column(modifier = modifier) {
updatedList.forEachIndexed { index, (address, amount) ->
PaymentDetailItem(
itemIndex = index,
amount = amount,
address = address,
enabled = enabled,
onAmountChange = {
updatedList[index] = address to it
onRecipientChange(updatedList)
},
onAddressChange = {
updatedList[index] = it to amount
onRecipientChange(updatedList)
},
onDeleteItemClick = {
updatedList.removeAt(index)
onRecipientChange(updatedList)
}
)
}
TextButton(
onClick = { onRecipientChange(updatedList + ("" to "")) },
enabled = enabled,
modifier = Modifier.padding(bottom = 8.dp),
) {
Text(text = "Add recipient")
}
}
}
@Composable
fun PaymentDetailItem(
itemIndex: Int,
address: String,
amount: String,
enabled: Boolean = true,
onAmountChange: (String) -> Unit,
onAddressChange: (String) -> Unit,
onDeleteItemClick: () -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
) {
OutlinedTextField(
label = { Text("Recipient") },
singleLine = true,
value = address,
isError = address.isBlank(),
enabled = enabled,
onValueChange = onAddressChange,
modifier = Modifier.weight(1f),
)
OutlinedTextField(
label = { Text("Amount") },
placeholder = { Text("0.00") },
singleLine = true,
value = amount,
suffix = { Text(MoneroCurrency.SYMBOL) },
isError = amount.isBlank(),
enabled = enabled,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
onValueChange = onAmountChange,
modifier = Modifier
.weight(1f)
.padding(start = 8.dp),
)
IconButton(
onClick = onDeleteItemClick,
enabled = enabled && itemIndex > 0,
modifier = Modifier.padding(top = 12.dp),
) {
Icon(imageVector = Icons.Default.Delete, contentDescription = "Delete")
}
}
}
@Preview
@Composable
private fun EditableRecipientListPreview() {
EditableRecipientList(
recipients = listOf(
"888tNkZrPN6JsEgekjMnABU4TBzc2Dt29EPAvkRxbANsAnjyPbb3iQ1YBRk1UXcdRsiKc9dhwMVgN5S9cQUiyoogDavup3H" to "0.01"
),
)
}

View File

@ -0,0 +1,141 @@
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.FeePriority
import im.molly.monero.MoneroCurrency
import im.molly.monero.PaymentDetail
import im.molly.monero.PaymentRequest
import im.molly.monero.PendingTransfer
import im.molly.monero.PublicAddress
import im.molly.monero.TransferRequest
import im.molly.monero.demo.AppModule
import im.molly.monero.demo.data.WalletRepository
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
class SendTabViewModel(
private val walletId: Long,
private val walletRepository: WalletRepository = AppModule.walletRepository,
) : ViewModel() {
private val viewModelState = MutableStateFlow(SendTabUiState())
val uiState = viewModelState.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = viewModelState.value
)
fun updateAccount(accountIndex: Int) {
viewModelState.update {
it.copy(
accountIndex = accountIndex,
status = TransferStatus.Idle,
)
}
}
fun updatePriority(priority: FeePriority) {
viewModelState.update {
it.copy(
feePriority = priority,
status = TransferStatus.Idle,
)
}
}
fun updateRecipients(recipients: List<Pair<String, String>>) {
viewModelState.update {
it.copy(
recipients = recipients, status = TransferStatus.Idle
)
}
}
private fun getPaymentRequest(state: SendTabUiState): Result<PaymentRequest> {
return runCatching {
PaymentRequest(
spendingAccountIndex = state.accountIndex,
paymentDetails = state.recipients.map { (address, amount) ->
PaymentDetail(
recipientAddress = PublicAddress.parse(address),
amount = MoneroCurrency.parse(amount),
)
},
feePriority = state.feePriority,
)
}
}
fun createPayment() {
val result = getPaymentRequest(viewModelState.value)
result.fold(
onSuccess = { createTransfer(it) },
onFailure = { error ->
viewModelState.update {
it.copy(status = TransferStatus.Error(error.message))
}
},
)
}
private fun createTransfer(transferRequest: TransferRequest) {
viewModelState.update {
it.copy(status = TransferStatus.Preparing)
}
viewModelScope.launch {
val pendingTransfer = runCatching {
walletRepository.createTransfer(walletId, transferRequest)
}
viewModelState.update {
val updatedState = pendingTransfer.fold(
onSuccess = { pendingTransfer ->
it.copy(status = TransferStatus.ReadyForApproval(pendingTransfer))
},
onFailure = { error ->
it.copy(status = TransferStatus.Error(error.message))
},
)
updatedState
}
}
}
fun confirmPayment() {
TODO()
}
companion object {
fun factory(walletId: Long) = viewModelFactory {
initializer {
SendTabViewModel(walletId)
}
}
}
}
data class SendTabUiState(
val accountIndex: Int = 0,
val recipients: List<Pair<String, String>> = emptyList(),
val feePriority: FeePriority = FeePriority.Medium,
val status: TransferStatus = TransferStatus.Idle,
) {
val isInProgress: Boolean
get() = !(status == TransferStatus.Idle || status is TransferStatus.Error)
}
sealed interface TransferStatus {
data object Idle : TransferStatus
data object Preparing : TransferStatus
data class ReadyForApproval(
val pendingTransfer: PendingTransfer
) : TransferStatus
data class Error(val errorMessage: String?) : TransferStatus
}

View File

@ -7,7 +7,7 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Divider
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@ -55,17 +55,17 @@ fun WalletBalanceView(
) {
Text(
style = MaterialTheme.typography.bodyLarge,
text = "Balance at ${blockchainTime}",
text = "Balance at $blockchainTime",
)
Spacer(modifier = Modifier.height(12.dp))
BalanceRow("Confirmed", balance.confirmedAmount)
BalanceRow("Pending", balance.pendingAmount)
Divider()
HorizontalDivider()
BalanceRow("Total", balance.totalAmount)
val currentTime = blockchainTime.copy(timestamp = now)
val currentTime = blockchainTime.withTimestamp(now)
BalanceRow("Unlocked", balance.unlockedAmountAt(currentTime))
balance.lockedAmountsAt(currentTime).forEach { (timeSpan, amount) ->

View File

@ -3,6 +3,10 @@ package im.molly.monero.demo.ui
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Info
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
@ -19,14 +23,19 @@ 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.FeePriority
import im.molly.monero.Ledger
import im.molly.monero.MoneroCurrency
import im.molly.monero.demo.data.model.WalletConfig
import im.molly.monero.demo.ui.component.CopyableText
import im.molly.monero.demo.ui.component.SelectListBox
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
import im.molly.monero.toFormattedString
import kotlinx.coroutines.delay
import java.time.Instant
import kotlin.time.Duration.Companion.seconds
@Composable
fun WalletRoute(
@ -34,20 +43,32 @@ fun WalletRoute(
onTransactionClick: (String, Long) -> Unit,
onBackClick: () -> Unit,
modifier: Modifier = Modifier,
viewModel: WalletViewModel = viewModel(
walletViewModel: WalletViewModel = viewModel(
factory = WalletViewModel.factory(walletId),
key = WalletViewModel.key(walletId),
)
),
sendTabViewModel: SendTabViewModel = viewModel(
factory = SendTabViewModel.factory(walletId)
),
) {
val uiState: WalletUiState by viewModel.uiState.collectAsStateWithLifecycle()
val walletUiState by walletViewModel.uiState.collectAsStateWithLifecycle()
val sendTabUiState by sendTabViewModel.uiState.collectAsStateWithLifecycle()
WalletScreen(
uiState = uiState,
onWalletConfigChange = { config -> viewModel.updateConfig(config) },
onTransactionClick = onTransactionClick,
onCreateAccountClick = { viewModel.createAccount() },
onCreateSubAddressClick = { accountIndex ->
viewModel.createSubAddress(accountIndex)
walletUiState = walletUiState,
sendTabUiState = sendTabUiState,
onWalletConfigChange = { config ->
walletViewModel.updateConfig(config)
},
onTransactionClick = onTransactionClick,
onCreateAccountClick = { walletViewModel.createAccount() },
onCreateSubAddressClick = { accountIndex ->
walletViewModel.createSubAddress(accountIndex)
},
onTransferAccountSelect = { sendTabViewModel.updateAccount(it) },
onTransferPrioritySelect = { sendTabViewModel.updatePriority(it) },
onTransferRecipientChange = { sendTabViewModel.updateRecipients(it) },
onTransferSendClick = { sendTabViewModel.createPayment() },
onBackClick = onBackClick,
modifier = modifier,
)
@ -55,21 +76,31 @@ fun WalletRoute(
@Composable
private fun WalletScreen(
uiState: WalletUiState,
onWalletConfigChange: (WalletConfig) -> Unit,
onTransactionClick: (String, Long) -> Unit,
onCreateAccountClick: () -> Unit,
onCreateSubAddressClick: (Int) -> Unit,
onBackClick: () -> Unit,
walletUiState: WalletUiState,
sendTabUiState: SendTabUiState,
modifier: Modifier = Modifier,
onWalletConfigChange: (WalletConfig) -> Unit = {},
onTransactionClick: (String, Long) -> Unit = { _, _ -> },
onCreateAccountClick: () -> Unit = {},
onCreateSubAddressClick: (Int) -> Unit = {},
onTransferAccountSelect: (Int) -> Unit = {},
onTransferPrioritySelect: (FeePriority) -> Unit = {},
onTransferRecipientChange: (List<Pair<String, String>>) -> Unit = {},
onTransferSendClick: () -> Unit = {},
onBackClick: () -> Unit = {},
) {
when (uiState) {
when (walletUiState) {
is WalletUiState.Loaded -> WalletScreenLoaded(
uiState = uiState,
walletUiState = walletUiState,
sendTabUiState = sendTabUiState,
onWalletConfigChange = onWalletConfigChange,
onTransactionClick = onTransactionClick,
onCreateAccountClick = onCreateAccountClick,
onCreateSubAddressClick = onCreateSubAddressClick,
onTransferAccountSelect = onTransferAccountSelect,
onTransferPrioritySelect = onTransferPrioritySelect,
onTransferRecipientChange = onTransferRecipientChange,
onTransferSendClick = onTransferSendClick,
onBackClick = onBackClick,
modifier = modifier,
)
@ -80,13 +111,33 @@ private fun WalletScreen(
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun WalletToolbar(
onBackClick: () -> Unit,
actions: @Composable RowScope.() -> Unit = {},
) {
Toolbar(navigationIcon = {
IconButton(onClick = onBackClick) {
Icon(
imageVector = AppIcons.ArrowBack,
contentDescription = "Back",
)
}
}, actions = actions)
}
@Composable
private fun WalletScreenLoaded(
uiState: WalletUiState.Loaded,
walletUiState: WalletUiState.Loaded,
sendTabUiState: SendTabUiState,
onWalletConfigChange: (WalletConfig) -> Unit,
onTransactionClick: (String, Long) -> Unit,
onCreateAccountClick: () -> Unit,
onCreateSubAddressClick: (Int) -> Unit,
onTransferAccountSelect: (Int) -> Unit,
onTransferPrioritySelect: (FeePriority) -> Unit,
onTransferRecipientChange: (List<Pair<String, String>>) -> Unit,
onTransferSendClick: () -> Unit,
onBackClick: () -> Unit,
modifier: Modifier = Modifier,
) {
@ -96,14 +147,7 @@ private fun WalletScreenLoaded(
Scaffold(
topBar = {
Toolbar(navigationIcon = {
IconButton(onClick = onBackClick) {
Icon(
imageVector = AppIcons.ArrowBack,
contentDescription = "Back",
)
}
}, actions = {
WalletToolbar(onBackClick = onBackClick, actions = {
WalletKebabMenu(
onRenameClick = { showRenameDialog = true },
onDeleteClick = { },
@ -122,11 +166,11 @@ private fun WalletScreenLoaded(
withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) {
append(
MoneroCurrency.Format(precision = 5)
.format(uiState.balance.confirmedAmount)
.format(walletUiState.totalBalance.confirmedAmount)
)
}
})
Text(text = uiState.config.name, style = MaterialTheme.typography.headlineSmall)
Text(text = walletUiState.config.name, style = MaterialTheme.typography.headlineSmall)
WalletHeaderTabs(
titles = listOf("Balance", "Send", "Receive", "History"),
@ -137,12 +181,20 @@ private fun WalletScreenLoaded(
when (selectedTabIndex) {
0 -> {
WalletBalanceView(
balance = uiState.balance,
blockchainTime = uiState.blockchainTime
balance = walletUiState.totalBalance,
blockchainTime = walletUiState.blockchainTime
)
}
1 -> {} // TODO
1 -> {
WalletSendTab(
walletUiState, sendTabUiState,
onAccountSelect = onTransferAccountSelect,
onPrioritySelect = onTransferPrioritySelect,
onRecipientChange = onTransferRecipientChange,
onSendClick = onTransferSendClick,
)
}
2 -> {
val scrollState = rememberLazyListState()
@ -151,23 +203,18 @@ private fun WalletScreenLoaded(
state = scrollState,
) {
addressCardItems(
items = uiState.addresses,
items = walletUiState.addresses,
onCreateSubAddressClick = onCreateSubAddressClick,
)
item {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
TextButton(
onClick = onCreateAccountClick,
modifier = modifier.padding(start = 16.dp, bottom = 8.dp),
) {
OutlinedButton(
onClick = onCreateAccountClick,
modifier = modifier.padding(bottom = 16.dp),
) {
Text(
text = "Create new account",
style = MaterialTheme.typography.bodySmall,
)
}
Text(
text = "Create new account",
style = MaterialTheme.typography.bodySmall,
)
}
}
}
@ -177,10 +224,10 @@ private fun WalletScreenLoaded(
val scrollState = rememberLazyListState()
var lastTxId by remember { mutableStateOf("") }
lastTxId = uiState.transactions.firstOrNull()?.transaction?.txId ?: ""
lastTxId = walletUiState.transactions.firstOrNull()?.transaction?.txId ?: ""
LaunchedEffect(lastTxId) {
if (uiState.transactions.isNotEmpty()) {
if (walletUiState.transactions.isNotEmpty()) {
scrollState.scrollToItem(0)
}
}
@ -189,7 +236,7 @@ private fun WalletScreenLoaded(
state = scrollState,
) {
transactionCardItems(
items = uiState.transactions,
items = walletUiState.transactions,
onTransactionClick = onTransactionClick,
)
}
@ -198,7 +245,7 @@ private fun WalletScreenLoaded(
}
if (showRenameDialog) {
var name by remember { mutableStateOf(uiState.config.name) }
var name by remember { mutableStateOf(walletUiState.config.name) }
AlertDialog(
onDismissRequest = { showRenameDialog = false },
title = { Text("Enter wallet name") },
@ -211,7 +258,7 @@ private fun WalletScreenLoaded(
},
confirmButton = {
TextButton(onClick = {
onWalletConfigChange(uiState.config.copy(name = name))
onWalletConfigChange(walletUiState.config.copy(name = name))
showRenameDialog = false
}) {
Text("Rename")
@ -222,16 +269,100 @@ private fun WalletScreenLoaded(
}
}
@Composable
private fun WalletSendTab(
walletUiState: WalletUiState.Loaded,
sendTabUiState: SendTabUiState,
onAccountSelect: (Int) -> Unit,
onPrioritySelect: (FeePriority) -> Unit,
onRecipientChange: (List<Pair<String, String>>) -> Unit,
onSendClick: () -> Unit,
) {
var now by remember { mutableStateOf(Instant.now()) }
LaunchedEffect(now) {
delay(2.seconds)
now = Instant.now()
}
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
.imePadding()
.verticalScroll(rememberScrollState()),
) {
SelectListBox(
label = "Send from",
options = walletUiState.accountBalance.mapValues { (index, balance) ->
val currentTime = walletUiState.blockchainTime.withTimestamp(now)
val funds = balance.unlockedAmountAt(currentTime)
val fundsFormatted = funds.toFormattedString(appendSymbol = true)
"Account #$index : $fundsFormatted"
},
selectedOption = sendTabUiState.accountIndex,
onOptionClick = { option -> onAccountSelect(option) },
enabled = !sendTabUiState.isInProgress,
)
EditableRecipientList(
recipients = sendTabUiState.recipients,
onRecipientChange = onRecipientChange,
enabled = !sendTabUiState.isInProgress,
)
SelectListBox(
label = "Transaction priority",
options = FeePriority.entries.associateWith { it.name },
selectedOption = sendTabUiState.feePriority,
onOptionClick = { option -> onPrioritySelect(option) },
enabled = !sendTabUiState.isInProgress,
)
if (sendTabUiState.status is TransferStatus.Error) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(top = 24.dp),
) {
Icon(
imageVector = Icons.Default.Info,
contentDescription = "Error Icon",
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(end = 8.dp),
)
Text(
text = sendTabUiState.status.errorMessage
?: "Unspecific error",
color = MaterialTheme.colorScheme.error,
)
}
}
ElevatedButton(
modifier = Modifier.padding(vertical = 24.dp),
onClick = onSendClick,
enabled = !sendTabUiState.isInProgress,
) {
val text = when (sendTabUiState.status) {
TransferStatus.Preparing -> "Preparing..."
else -> "Send"
}
Text(text = text)
}
}
}
@Composable
private fun WalletScreenError(
onBackClick: () -> Unit,
) {
Scaffold(topBar = { WalletToolbar(onBackClick = onBackClick) }) {}
}
@Composable
private fun WalletScreenLoading(
onBackClick: () -> Unit,
) {
Scaffold(topBar = { WalletToolbar(onBackClick = onBackClick) }) {}
}
@Composable
@ -300,7 +431,7 @@ private fun WalletScreenPopulated(
) {
AppTheme {
WalletScreen(
uiState = WalletUiState.Loaded(
walletUiState = WalletUiState.Loaded(
config = WalletConfig(
id = 0,
publicAddress = ledger.publicAddress.address,
@ -309,21 +440,21 @@ private fun WalletScreenPopulated(
remoteNodes = emptySet(),
),
network = ledger.publicAddress.network,
balance = ledger.balance,
totalBalance = ledger.getBalance(),
accountBalance = emptyMap(),
blockchainTime = ledger.checkedAt,
addresses = emptyList(),
transactions = emptyList(),
),
onWalletConfigChange = {},
onTransactionClick = { _: String, _: Long -> },
onCreateAccountClick = {},
onCreateSubAddressClick = {},
onBackClick = {},
sendTabUiState = SendTabUiState(
accountIndex = 0,
recipients = emptyList(),
feePriority = FeePriority.Medium,
)
)
}
}
private class WalletScreenPreviewParameterProvider :
PreviewParameterProvider<Ledger> {
private class WalletScreenPreviewParameterProvider : PreviewParameterProvider<Ledger> {
override val values = sequenceOf(PreviewParameterData.ledger)
}

View File

@ -74,13 +74,16 @@ private fun walletUiState(
is Result.Success -> {
val config = result.data.first
val ledger = result.data.second
val accountBalance = List(ledger.indexedAccounts.size) { index ->
index to ledger.getBalanceForAccount(index)
}.toMap()
val addresses =
ledger.accountAddresses.groupBy { it.accountIndex }.flatMap { (_, group) ->
group.sortedBy { it.subAddressIndex }.mapIndexed { index, address ->
ledger.indexedAccounts.flatMap { account ->
account.addresses.map { address ->
WalletAddress(
address = address,
used = address.isAddressUsed(ledger.transactions),
isLastForAccount = index == group.size - 1,
isLastForAccount = address === account.addresses.last(),
)
}
}
@ -91,7 +94,8 @@ private fun walletUiState(
config = config,
network = ledger.publicAddress.network,
blockchainTime = ledger.checkedAt,
balance = ledger.balance,
totalBalance = ledger.getBalance(),
accountBalance = accountBalance,
addresses = addresses,
transactions = transactions,
)
@ -113,7 +117,8 @@ sealed interface WalletUiState {
val config: WalletConfig,
val network: MoneroNetwork,
val blockchainTime: BlockchainTime,
val balance: Balance,
val totalBalance: Balance,
val accountBalance: Map<Int, Balance>,
val addresses: List<WalletAddress>,
val transactions: List<WalletTransaction>,
) : WalletUiState

View File

@ -7,12 +7,13 @@ import androidx.compose.ui.Modifier
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SelectListBox(
fun <T>SelectListBox(
label: String,
options: List<String>,
selectedOption: String,
onOptionClick: (String) -> Unit,
options: Map<T, String>,
selectedOption: T,
onOptionClick: (T) -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
) {
var expanded by remember { mutableStateOf(false) }
@ -25,16 +26,19 @@ fun SelectListBox(
) {
OutlinedTextField(
readOnly = true,
value = selectedOption,
value = options.getValue(selectedOption),
onValueChange = { },
enabled = enabled,
modifier = Modifier
.fillMaxWidth()
.menuAnchor(),
label = { Text(label) },
trailingIcon = {
ExposedDropdownMenuDefaults.TrailingIcon(
expanded = expanded
)
if (enabled) {
ExposedDropdownMenuDefaults.TrailingIcon(
expanded = expanded
)
}
},
colors = ExposedDropdownMenuDefaults.outlinedTextFieldColors(),
)
@ -45,13 +49,13 @@ fun SelectListBox(
},
modifier = Modifier.exposedDropdownSize(),
) {
options.forEach { selectionOption ->
options.forEach { (key, text) ->
DropdownMenuItem(
text = {
Text(selectionOption)
Text(text)
},
onClick = {
onOptionClick(selectionOption)
onOptionClick(key)
expanded = false
},
)

View File

@ -40,9 +40,9 @@ object PreviewParameterData {
val ledger = Ledger(
publicAddress = PublicAddress.parse("4AYjQM9HoAFNUeC3cvSfgeAN89oMMpMqiByvunzSzhn97cj726rJj3x8hCbH58UnMqQJShczCxbpWRiCJQ3HCUDHLiKuo4T"),
accountAddresses = emptySet(),
indexedAccounts = emptyList(),
transactionById = transactions.associateBy { it.txId },
enotes = emptySet(),
enoteSet = emptySet(),
checkedAt = BlockchainTime(blockHeader = blockHeader, network = network),
)
}

View File

@ -19,5 +19,5 @@ oneway interface ITransferRequestCallback {
// void onTransactionTooBig();
// void onTransferError(String errorMessage);
// void onWalletInternalError(String errorMessage);
// void onUnexpectedError(String errorMessage);
void onUnexpectedError(String message);
}

View File

@ -7,6 +7,7 @@ jmethodID HttpResponse_getBody;
jmethodID HttpResponse_getCode;
jmethodID HttpResponse_getContentType;
jmethodID ITransferRequestCb_onTransferCreated;
jmethodID ITransferRequestCb_onUnexpectedError;
jmethodID Logger_logFromNative;
jmethodID TxInfo_ctor;
jmethodID WalletNative_createPendingTransfer;
@ -41,6 +42,9 @@ void InitializeJniCache(JNIEnv* env) {
ITransferRequestCb_onTransferCreated = GetMethodId(
env, iTransferRequestCb,
"onTransferCreated", "(Lim/molly/monero/IPendingTransfer;)V");
ITransferRequestCb_onUnexpectedError = GetMethodId(
env, iTransferRequestCb,
"onUnexpectedError", "(Ljava/lang/String;)V");
Logger_logFromNative = GetMethodId(
env, logger,
"logFromNative", "(ILjava/lang/String;Ljava/lang/String;)V");

View File

@ -14,6 +14,7 @@ extern jmethodID HttpResponse_getBody;
extern jmethodID HttpResponse_getCode;
extern jmethodID HttpResponse_getContentType;
extern jmethodID ITransferRequestCb_onTransferCreated;
extern jmethodID ITransferRequestCb_onUnexpectedError;
extern jmethodID Logger_logFromNative;
extern jmethodID TxInfo_ctor;
extern jmethodID WalletNative_callRemoteNode;

View File

@ -874,9 +874,12 @@ Java_im_molly_monero_WalletNative_nativeCreatePayment(
// } catch (error::transfer_error& e) {
// } catch (error::wallet_internal_error& e) {
// } catch (error::wallet_logic_error& e) {
// } catch (const std::exception& e) {
} catch (...) {
LOG_FATAL("Caught unknown exception");
} catch (const std::exception& e) {
LOGW("Caught unhandled exception: %s", e.what());
CallVoidMethod(env, j_callback,
ITransferRequestCb_onUnexpectedError,
NativeToJavaString(env, e.what()));
return;
}
jobject j_pending_transfer = CallObjectMethod(

View File

@ -30,6 +30,10 @@ data class BlockchainTime(
}
}
fun withTimestamp(newTimestamp: Instant): BlockchainTime {
return copy(timestamp = newTimestamp)
}
fun estimateHeight(targetTimestamp: Instant): Int {
val timeDiff = Duration.between(timestamp, targetTimestamp)
val estHeight = timeDiff.seconds / network.avgBlockTime(height).seconds + height

View File

@ -4,14 +4,15 @@ import im.molly.monero.internal.TxInfo
import im.molly.monero.internal.consolidateTransactions
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.channels.onFailure
import kotlinx.coroutines.channels.trySendBlocking
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.conflate
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlin.coroutines.resumeWithException
import kotlin.time.Duration.Companion.seconds
@OptIn(ExperimentalCoroutinesApi::class)
@ -191,6 +192,12 @@ class MoneroWallet internal constructor(
pendingTransfer.close()
}
}
override fun onUnexpectedError(message: String) {
continuation.resumeWithException(
IllegalStateException(message)
)
}
}
when (transferRequest) {
is PaymentRequest -> wallet.createPayment(transferRequest, callback)

View File

@ -8,7 +8,7 @@ sealed interface TransferRequest : Parcelable
@Parcelize
data class PaymentRequest(
val paymentDetails: List<PaymentDetail>,
val sourceAccounts: Set<AccountAddress>,
val spendingAccountIndex: Int,
val feePriority: FeePriority? = null,
val timeLock: UnlockTime? = null,
) : TransferRequest

View File

@ -187,7 +187,7 @@ internal class WalletNative private constructor(
amounts = amounts.toLongArray(),
timeLock = request.timeLock?.blockchainTime?.toLong() ?: 0,
priority = request.feePriority?.priority ?: 0,
accountIndex = 0,
accountIndex = request.spendingAccountIndex,
subAddressIndexes = IntArray(0),
callback = callback,
)