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?> = fun getTransaction(walletId: Long, txId: String): Flow<Transaction?> =
getLedger(walletId).map { it.transactionById[txId] } getLedger(walletId).map { it.transactionById[txId] }
suspend fun createTransfer(walletId: Long, transferRequest: TransferRequest): PendingTransfer {
return getWallet(walletId).createTransfer(transferRequest)
}
suspend fun addWallet( suspend fun addWallet(
moneroNetwork: MoneroNetwork, moneroNetwork: MoneroNetwork,
name: String, name: String,

View File

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

View File

@ -7,14 +7,11 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.alpha
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import im.molly.monero.demo.data.model.WalletAddress import im.molly.monero.demo.data.model.WalletAddress
import im.molly.monero.demo.ui.component.CopyableText 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 @Composable
fun AddressCardExpanded( fun AddressCardExpanded(
@ -47,18 +44,14 @@ fun AddressCardExpanded(
modifier = if (used) Modifier.alpha(0.5f) else Modifier, modifier = if (used) Modifier.alpha(0.5f) else Modifier,
) )
if (walletAddress.isLastForAccount) { if (walletAddress.isLastForAccount) {
Column( TextButton(onClick = onCreateSubAddressClick) {
modifier = Modifier.fillMaxWidth(), Text(
horizontalAlignment = Alignment.CenterHorizontally, 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( SelectListBox(
label = "Network", label = "Network",
options = MoneroNetwork.values().map { it.name }, options = MoneroNetwork.entries.associateWith { it.name },
selectedOption = remoteNode.network.name, selectedOption = remoteNode.network,
onOptionClick = { onOptionClick = {
onRemoteNodeChange(remoteNode.copy(network = MoneroNetwork.valueOf(it))) onRemoteNodeChange(remoteNode.copy(network = it))
} }
) )
Column { 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.fillMaxWidth
import androidx.compose.foundation.layout.height 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.HorizontalDivider
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -55,17 +55,17 @@ fun WalletBalanceView(
) { ) {
Text( Text(
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
text = "Balance at ${blockchainTime}", text = "Balance at $blockchainTime",
) )
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
BalanceRow("Confirmed", balance.confirmedAmount) BalanceRow("Confirmed", balance.confirmedAmount)
BalanceRow("Pending", balance.pendingAmount) BalanceRow("Pending", balance.pendingAmount)
Divider() HorizontalDivider()
BalanceRow("Total", balance.totalAmount) BalanceRow("Total", balance.totalAmount)
val currentTime = blockchainTime.copy(timestamp = now) val currentTime = blockchainTime.withTimestamp(now)
BalanceRow("Unlocked", balance.unlockedAmountAt(currentTime)) BalanceRow("Unlocked", balance.unlockedAmountAt(currentTime))
balance.lockedAmountsAt(currentTime).forEach { (timeSpan, amount) -> 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.layout.*
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState 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.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable 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.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.FeePriority
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.CopyableText import im.molly.monero.demo.ui.component.SelectListBox
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.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
import im.molly.monero.toFormattedString
import kotlinx.coroutines.delay
import java.time.Instant
import kotlin.time.Duration.Companion.seconds
@Composable @Composable
fun WalletRoute( fun WalletRoute(
@ -34,20 +43,32 @@ fun WalletRoute(
onTransactionClick: (String, Long) -> Unit, onTransactionClick: (String, Long) -> Unit,
onBackClick: () -> Unit, onBackClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: WalletViewModel = viewModel( walletViewModel: WalletViewModel = viewModel(
factory = WalletViewModel.factory(walletId), factory = WalletViewModel.factory(walletId),
key = WalletViewModel.key(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( WalletScreen(
uiState = uiState, walletUiState = walletUiState,
onWalletConfigChange = { config -> viewModel.updateConfig(config) }, sendTabUiState = sendTabUiState,
onTransactionClick = onTransactionClick, onWalletConfigChange = { config ->
onCreateAccountClick = { viewModel.createAccount() }, walletViewModel.updateConfig(config)
onCreateSubAddressClick = { accountIndex ->
viewModel.createSubAddress(accountIndex)
}, },
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, onBackClick = onBackClick,
modifier = modifier, modifier = modifier,
) )
@ -55,21 +76,31 @@ fun WalletRoute(
@Composable @Composable
private fun WalletScreen( private fun WalletScreen(
uiState: WalletUiState, walletUiState: WalletUiState,
onWalletConfigChange: (WalletConfig) -> Unit, sendTabUiState: SendTabUiState,
onTransactionClick: (String, Long) -> Unit,
onCreateAccountClick: () -> Unit,
onCreateSubAddressClick: (Int) -> Unit,
onBackClick: () -> Unit,
modifier: Modifier = Modifier, 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( is WalletUiState.Loaded -> WalletScreenLoaded(
uiState = uiState, walletUiState = walletUiState,
sendTabUiState = sendTabUiState,
onWalletConfigChange = onWalletConfigChange, onWalletConfigChange = onWalletConfigChange,
onTransactionClick = onTransactionClick, onTransactionClick = onTransactionClick,
onCreateAccountClick = onCreateAccountClick, onCreateAccountClick = onCreateAccountClick,
onCreateSubAddressClick = onCreateSubAddressClick, onCreateSubAddressClick = onCreateSubAddressClick,
onTransferAccountSelect = onTransferAccountSelect,
onTransferPrioritySelect = onTransferPrioritySelect,
onTransferRecipientChange = onTransferRecipientChange,
onTransferSendClick = onTransferSendClick,
onBackClick = onBackClick, onBackClick = onBackClick,
modifier = modifier, modifier = modifier,
) )
@ -80,13 +111,33 @@ private fun WalletScreen(
} }
@OptIn(ExperimentalMaterial3Api::class) @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 @Composable
private fun WalletScreenLoaded( private fun WalletScreenLoaded(
uiState: WalletUiState.Loaded, walletUiState: WalletUiState.Loaded,
sendTabUiState: SendTabUiState,
onWalletConfigChange: (WalletConfig) -> Unit, onWalletConfigChange: (WalletConfig) -> Unit,
onTransactionClick: (String, Long) -> Unit, onTransactionClick: (String, Long) -> Unit,
onCreateAccountClick: () -> Unit, onCreateAccountClick: () -> Unit,
onCreateSubAddressClick: (Int) -> Unit, onCreateSubAddressClick: (Int) -> Unit,
onTransferAccountSelect: (Int) -> Unit,
onTransferPrioritySelect: (FeePriority) -> Unit,
onTransferRecipientChange: (List<Pair<String, String>>) -> Unit,
onTransferSendClick: () -> Unit,
onBackClick: () -> Unit, onBackClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
@ -96,14 +147,7 @@ private fun WalletScreenLoaded(
Scaffold( Scaffold(
topBar = { topBar = {
Toolbar(navigationIcon = { WalletToolbar(onBackClick = onBackClick, actions = {
IconButton(onClick = onBackClick) {
Icon(
imageVector = AppIcons.ArrowBack,
contentDescription = "Back",
)
}
}, actions = {
WalletKebabMenu( WalletKebabMenu(
onRenameClick = { showRenameDialog = true }, onRenameClick = { showRenameDialog = true },
onDeleteClick = { }, onDeleteClick = { },
@ -122,11 +166,11 @@ private fun WalletScreenLoaded(
withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) { withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) {
append( append(
MoneroCurrency.Format(precision = 5) 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( WalletHeaderTabs(
titles = listOf("Balance", "Send", "Receive", "History"), titles = listOf("Balance", "Send", "Receive", "History"),
@ -137,12 +181,20 @@ private fun WalletScreenLoaded(
when (selectedTabIndex) { when (selectedTabIndex) {
0 -> { 0 -> {
WalletBalanceView( WalletBalanceView(
balance = uiState.balance, balance = walletUiState.totalBalance,
blockchainTime = uiState.blockchainTime blockchainTime = walletUiState.blockchainTime
) )
} }
1 -> {} // TODO 1 -> {
WalletSendTab(
walletUiState, sendTabUiState,
onAccountSelect = onTransferAccountSelect,
onPrioritySelect = onTransferPrioritySelect,
onRecipientChange = onTransferRecipientChange,
onSendClick = onTransferSendClick,
)
}
2 -> { 2 -> {
val scrollState = rememberLazyListState() val scrollState = rememberLazyListState()
@ -151,23 +203,18 @@ private fun WalletScreenLoaded(
state = scrollState, state = scrollState,
) { ) {
addressCardItems( addressCardItems(
items = uiState.addresses, items = walletUiState.addresses,
onCreateSubAddressClick = onCreateSubAddressClick, onCreateSubAddressClick = onCreateSubAddressClick,
) )
item { item {
Column( TextButton(
modifier = Modifier.fillMaxWidth(), onClick = onCreateAccountClick,
horizontalAlignment = Alignment.CenterHorizontally, modifier = modifier.padding(start = 16.dp, bottom = 8.dp),
) { ) {
OutlinedButton( Text(
onClick = onCreateAccountClick, text = "Create new account",
modifier = modifier.padding(bottom = 16.dp), style = MaterialTheme.typography.bodySmall,
) { )
Text(
text = "Create new account",
style = MaterialTheme.typography.bodySmall,
)
}
} }
} }
} }
@ -177,10 +224,10 @@ private fun WalletScreenLoaded(
val scrollState = rememberLazyListState() val scrollState = rememberLazyListState()
var lastTxId by remember { mutableStateOf("") } var lastTxId by remember { mutableStateOf("") }
lastTxId = uiState.transactions.firstOrNull()?.transaction?.txId ?: "" lastTxId = walletUiState.transactions.firstOrNull()?.transaction?.txId ?: ""
LaunchedEffect(lastTxId) { LaunchedEffect(lastTxId) {
if (uiState.transactions.isNotEmpty()) { if (walletUiState.transactions.isNotEmpty()) {
scrollState.scrollToItem(0) scrollState.scrollToItem(0)
} }
} }
@ -189,7 +236,7 @@ private fun WalletScreenLoaded(
state = scrollState, state = scrollState,
) { ) {
transactionCardItems( transactionCardItems(
items = uiState.transactions, items = walletUiState.transactions,
onTransactionClick = onTransactionClick, onTransactionClick = onTransactionClick,
) )
} }
@ -198,7 +245,7 @@ private fun WalletScreenLoaded(
} }
if (showRenameDialog) { if (showRenameDialog) {
var name by remember { mutableStateOf(uiState.config.name) } var name by remember { mutableStateOf(walletUiState.config.name) }
AlertDialog( AlertDialog(
onDismissRequest = { showRenameDialog = false }, onDismissRequest = { showRenameDialog = false },
title = { Text("Enter wallet name") }, title = { Text("Enter wallet name") },
@ -211,7 +258,7 @@ private fun WalletScreenLoaded(
}, },
confirmButton = { confirmButton = {
TextButton(onClick = { TextButton(onClick = {
onWalletConfigChange(uiState.config.copy(name = name)) onWalletConfigChange(walletUiState.config.copy(name = name))
showRenameDialog = false showRenameDialog = false
}) { }) {
Text("Rename") 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 @Composable
private fun WalletScreenError( private fun WalletScreenError(
onBackClick: () -> Unit, onBackClick: () -> Unit,
) { ) {
Scaffold(topBar = { WalletToolbar(onBackClick = onBackClick) }) {}
} }
@Composable @Composable
private fun WalletScreenLoading( private fun WalletScreenLoading(
onBackClick: () -> Unit, onBackClick: () -> Unit,
) { ) {
Scaffold(topBar = { WalletToolbar(onBackClick = onBackClick) }) {}
} }
@Composable @Composable
@ -300,7 +431,7 @@ private fun WalletScreenPopulated(
) { ) {
AppTheme { AppTheme {
WalletScreen( WalletScreen(
uiState = WalletUiState.Loaded( walletUiState = WalletUiState.Loaded(
config = WalletConfig( config = WalletConfig(
id = 0, id = 0,
publicAddress = ledger.publicAddress.address, publicAddress = ledger.publicAddress.address,
@ -309,21 +440,21 @@ private fun WalletScreenPopulated(
remoteNodes = emptySet(), remoteNodes = emptySet(),
), ),
network = ledger.publicAddress.network, network = ledger.publicAddress.network,
balance = ledger.balance, totalBalance = ledger.getBalance(),
accountBalance = emptyMap(),
blockchainTime = ledger.checkedAt, blockchainTime = ledger.checkedAt,
addresses = emptyList(), addresses = emptyList(),
transactions = emptyList(), transactions = emptyList(),
), ),
onWalletConfigChange = {}, sendTabUiState = SendTabUiState(
onTransactionClick = { _: String, _: Long -> }, accountIndex = 0,
onCreateAccountClick = {}, recipients = emptyList(),
onCreateSubAddressClick = {}, feePriority = FeePriority.Medium,
onBackClick = {}, )
) )
} }
} }
private class WalletScreenPreviewParameterProvider : private class WalletScreenPreviewParameterProvider : PreviewParameterProvider<Ledger> {
PreviewParameterProvider<Ledger> {
override val values = sequenceOf(PreviewParameterData.ledger) override val values = sequenceOf(PreviewParameterData.ledger)
} }

View File

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

View File

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

View File

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

View File

@ -19,5 +19,5 @@ oneway interface ITransferRequestCallback {
// void onTransactionTooBig(); // void onTransactionTooBig();
// void onTransferError(String errorMessage); // void onTransferError(String errorMessage);
// void onWalletInternalError(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_getCode;
jmethodID HttpResponse_getContentType; jmethodID HttpResponse_getContentType;
jmethodID ITransferRequestCb_onTransferCreated; jmethodID ITransferRequestCb_onTransferCreated;
jmethodID ITransferRequestCb_onUnexpectedError;
jmethodID Logger_logFromNative; jmethodID Logger_logFromNative;
jmethodID TxInfo_ctor; jmethodID TxInfo_ctor;
jmethodID WalletNative_createPendingTransfer; jmethodID WalletNative_createPendingTransfer;
@ -41,6 +42,9 @@ void InitializeJniCache(JNIEnv* env) {
ITransferRequestCb_onTransferCreated = GetMethodId( ITransferRequestCb_onTransferCreated = GetMethodId(
env, iTransferRequestCb, env, iTransferRequestCb,
"onTransferCreated", "(Lim/molly/monero/IPendingTransfer;)V"); "onTransferCreated", "(Lim/molly/monero/IPendingTransfer;)V");
ITransferRequestCb_onUnexpectedError = GetMethodId(
env, iTransferRequestCb,
"onUnexpectedError", "(Ljava/lang/String;)V");
Logger_logFromNative = GetMethodId( Logger_logFromNative = GetMethodId(
env, logger, env, logger,
"logFromNative", "(ILjava/lang/String;Ljava/lang/String;)V"); "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_getCode;
extern jmethodID HttpResponse_getContentType; extern jmethodID HttpResponse_getContentType;
extern jmethodID ITransferRequestCb_onTransferCreated; extern jmethodID ITransferRequestCb_onTransferCreated;
extern jmethodID ITransferRequestCb_onUnexpectedError;
extern jmethodID Logger_logFromNative; extern jmethodID Logger_logFromNative;
extern jmethodID TxInfo_ctor; extern jmethodID TxInfo_ctor;
extern jmethodID WalletNative_callRemoteNode; extern jmethodID WalletNative_callRemoteNode;

View File

@ -874,9 +874,12 @@ Java_im_molly_monero_WalletNative_nativeCreatePayment(
// } catch (error::transfer_error& e) { // } catch (error::transfer_error& e) {
// } catch (error::wallet_internal_error& e) { // } catch (error::wallet_internal_error& e) {
// } catch (error::wallet_logic_error& e) { // } catch (error::wallet_logic_error& e) {
// } catch (const std::exception& e) { } catch (const std::exception& e) {
} catch (...) { LOGW("Caught unhandled exception: %s", e.what());
LOG_FATAL("Caught unknown exception"); CallVoidMethod(env, j_callback,
ITransferRequestCb_onUnexpectedError,
NativeToJavaString(env, e.what()));
return;
} }
jobject j_pending_transfer = CallObjectMethod( 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 { fun estimateHeight(targetTimestamp: Instant): Int {
val timeDiff = Duration.between(timestamp, targetTimestamp) val timeDiff = Duration.between(timestamp, targetTimestamp)
val estHeight = timeDiff.seconds / network.avgBlockTime(height).seconds + height 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 im.molly.monero.internal.consolidateTransactions
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.channels.onFailure
import kotlinx.coroutines.channels.trySendBlocking import kotlinx.coroutines.channels.trySendBlocking
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.conflate import kotlinx.coroutines.flow.conflate
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.suspendCancellableCoroutine
import kotlin.coroutines.resumeWithException
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
@ -191,6 +192,12 @@ class MoneroWallet internal constructor(
pendingTransfer.close() pendingTransfer.close()
} }
} }
override fun onUnexpectedError(message: String) {
continuation.resumeWithException(
IllegalStateException(message)
)
}
} }
when (transferRequest) { when (transferRequest) {
is PaymentRequest -> wallet.createPayment(transferRequest, callback) is PaymentRequest -> wallet.createPayment(transferRequest, callback)

View File

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

View File

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