diff --git a/demo/android/src/main/kotlin/im/molly/monero/demo/data/WalletRepository.kt b/demo/android/src/main/kotlin/im/molly/monero/demo/data/WalletRepository.kt index 7168a3b..28f4bc3 100644 --- a/demo/android/src/main/kotlin/im/molly/monero/demo/data/WalletRepository.kt +++ b/demo/android/src/main/kotlin/im/molly/monero/demo/data/WalletRepository.kt @@ -63,6 +63,10 @@ class WalletRepository( fun getTransaction(walletId: Long, txId: String): Flow = 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, diff --git a/demo/android/src/main/kotlin/im/molly/monero/demo/ui/AddWalletWizard.kt b/demo/android/src/main/kotlin/im/molly/monero/demo/ui/AddWalletWizard.kt index bead0ad..da3b19f 100644 --- a/demo/android/src/main/kotlin/im/molly/monero/demo/ui/AddWalletWizard.kt +++ b/demo/android/src/main/kotlin/im/molly/monero/demo/ui/AddWalletWizard.kt @@ -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() diff --git a/demo/android/src/main/kotlin/im/molly/monero/demo/ui/AddressCard.kt b/demo/android/src/main/kotlin/im/molly/monero/demo/ui/AddressCard.kt index a769242..fec4046 100644 --- a/demo/android/src/main/kotlin/im/molly/monero/demo/ui/AddressCard.kt +++ b/demo/android/src/main/kotlin/im/molly/monero/demo/ui/AddressCard.kt @@ -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, + ) } } + } } } diff --git a/demo/android/src/main/kotlin/im/molly/monero/demo/ui/EditRemoteNodeDialog.kt b/demo/android/src/main/kotlin/im/molly/monero/demo/ui/EditRemoteNodeDialog.kt index be7fc3f..8c20c89 100644 --- a/demo/android/src/main/kotlin/im/molly/monero/demo/ui/EditRemoteNodeDialog.kt +++ b/demo/android/src/main/kotlin/im/molly/monero/demo/ui/EditRemoteNodeDialog.kt @@ -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 { diff --git a/demo/android/src/main/kotlin/im/molly/monero/demo/ui/RecipientList.kt b/demo/android/src/main/kotlin/im/molly/monero/demo/ui/RecipientList.kt new file mode 100644 index 0000000..0c341d9 --- /dev/null +++ b/demo/android/src/main/kotlin/im/molly/monero/demo/ui/RecipientList.kt @@ -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>, + modifier: Modifier = Modifier, + enabled: Boolean = true, + onRecipientChange: (List>) -> 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" + ), + ) +} diff --git a/demo/android/src/main/kotlin/im/molly/monero/demo/ui/SendTabViewModel.kt b/demo/android/src/main/kotlin/im/molly/monero/demo/ui/SendTabViewModel.kt new file mode 100644 index 0000000..4c18cd9 --- /dev/null +++ b/demo/android/src/main/kotlin/im/molly/monero/demo/ui/SendTabViewModel.kt @@ -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>) { + viewModelState.update { + it.copy( + recipients = recipients, status = TransferStatus.Idle + ) + } + } + + private fun getPaymentRequest(state: SendTabUiState): Result { + 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> = 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 +} diff --git a/demo/android/src/main/kotlin/im/molly/monero/demo/ui/WalletBalanceView.kt b/demo/android/src/main/kotlin/im/molly/monero/demo/ui/WalletBalanceView.kt index 5074309..36c93aa 100644 --- a/demo/android/src/main/kotlin/im/molly/monero/demo/ui/WalletBalanceView.kt +++ b/demo/android/src/main/kotlin/im/molly/monero/demo/ui/WalletBalanceView.kt @@ -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) -> diff --git a/demo/android/src/main/kotlin/im/molly/monero/demo/ui/WalletScreen.kt b/demo/android/src/main/kotlin/im/molly/monero/demo/ui/WalletScreen.kt index 36085e8..618c475 100644 --- a/demo/android/src/main/kotlin/im/molly/monero/demo/ui/WalletScreen.kt +++ b/demo/android/src/main/kotlin/im/molly/monero/demo/ui/WalletScreen.kt @@ -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>) -> 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>) -> 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>) -> 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 { +private class WalletScreenPreviewParameterProvider : PreviewParameterProvider { override val values = sequenceOf(PreviewParameterData.ledger) } diff --git a/demo/android/src/main/kotlin/im/molly/monero/demo/ui/WalletViewModel.kt b/demo/android/src/main/kotlin/im/molly/monero/demo/ui/WalletViewModel.kt index a70ff90..65d2328 100644 --- a/demo/android/src/main/kotlin/im/molly/monero/demo/ui/WalletViewModel.kt +++ b/demo/android/src/main/kotlin/im/molly/monero/demo/ui/WalletViewModel.kt @@ -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, val addresses: List, val transactions: List, ) : WalletUiState diff --git a/demo/android/src/main/kotlin/im/molly/monero/demo/ui/component/SelectListBox.kt b/demo/android/src/main/kotlin/im/molly/monero/demo/ui/component/SelectListBox.kt index dd8c5f6..5675d77 100644 --- a/demo/android/src/main/kotlin/im/molly/monero/demo/ui/component/SelectListBox.kt +++ b/demo/android/src/main/kotlin/im/molly/monero/demo/ui/component/SelectListBox.kt @@ -7,12 +7,13 @@ import androidx.compose.ui.Modifier @OptIn(ExperimentalMaterial3Api::class) @Composable -fun SelectListBox( +fun SelectListBox( label: String, - options: List, - selectedOption: String, - onOptionClick: (String) -> Unit, + options: Map, + 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 }, ) diff --git a/demo/android/src/main/kotlin/im/molly/monero/demo/ui/preview/PreviewParameterData.kt b/demo/android/src/main/kotlin/im/molly/monero/demo/ui/preview/PreviewParameterData.kt index e2716ba..939a802 100644 --- a/demo/android/src/main/kotlin/im/molly/monero/demo/ui/preview/PreviewParameterData.kt +++ b/demo/android/src/main/kotlin/im/molly/monero/demo/ui/preview/PreviewParameterData.kt @@ -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), ) } diff --git a/lib/android/src/main/aidl/im/molly/monero/ITransferRequestCallback.aidl b/lib/android/src/main/aidl/im/molly/monero/ITransferRequestCallback.aidl index f4898f0..e76e109 100644 --- a/lib/android/src/main/aidl/im/molly/monero/ITransferRequestCallback.aidl +++ b/lib/android/src/main/aidl/im/molly/monero/ITransferRequestCallback.aidl @@ -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); } diff --git a/lib/android/src/main/cpp/wallet/jni_cache.cc b/lib/android/src/main/cpp/wallet/jni_cache.cc index df6d701..d787f36 100644 --- a/lib/android/src/main/cpp/wallet/jni_cache.cc +++ b/lib/android/src/main/cpp/wallet/jni_cache.cc @@ -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"); diff --git a/lib/android/src/main/cpp/wallet/jni_cache.h b/lib/android/src/main/cpp/wallet/jni_cache.h index 0629423..705c09c 100644 --- a/lib/android/src/main/cpp/wallet/jni_cache.h +++ b/lib/android/src/main/cpp/wallet/jni_cache.h @@ -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; diff --git a/lib/android/src/main/cpp/wallet/wallet.cc b/lib/android/src/main/cpp/wallet/wallet.cc index 2ac027b..7b4a3e9 100644 --- a/lib/android/src/main/cpp/wallet/wallet.cc +++ b/lib/android/src/main/cpp/wallet/wallet.cc @@ -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( diff --git a/lib/android/src/main/kotlin/im/molly/monero/BlockchainTime.kt b/lib/android/src/main/kotlin/im/molly/monero/BlockchainTime.kt index 06ae241..31eb062 100644 --- a/lib/android/src/main/kotlin/im/molly/monero/BlockchainTime.kt +++ b/lib/android/src/main/kotlin/im/molly/monero/BlockchainTime.kt @@ -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 diff --git a/lib/android/src/main/kotlin/im/molly/monero/MoneroWallet.kt b/lib/android/src/main/kotlin/im/molly/monero/MoneroWallet.kt index 9419f8d..704809e 100644 --- a/lib/android/src/main/kotlin/im/molly/monero/MoneroWallet.kt +++ b/lib/android/src/main/kotlin/im/molly/monero/MoneroWallet.kt @@ -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) diff --git a/lib/android/src/main/kotlin/im/molly/monero/TransferRequest.kt b/lib/android/src/main/kotlin/im/molly/monero/TransferRequest.kt index d16f63b..b13a644 100644 --- a/lib/android/src/main/kotlin/im/molly/monero/TransferRequest.kt +++ b/lib/android/src/main/kotlin/im/molly/monero/TransferRequest.kt @@ -8,7 +8,7 @@ sealed interface TransferRequest : Parcelable @Parcelize data class PaymentRequest( val paymentDetails: List, - val sourceAccounts: Set, + val spendingAccountIndex: Int, val feePriority: FeePriority? = null, val timeLock: UnlockTime? = null, ) : TransferRequest diff --git a/lib/android/src/main/kotlin/im/molly/monero/WalletNative.kt b/lib/android/src/main/kotlin/im/molly/monero/WalletNative.kt index 56469e6..199a19a 100644 --- a/lib/android/src/main/kotlin/im/molly/monero/WalletNative.kt +++ b/lib/android/src/main/kotlin/im/molly/monero/WalletNative.kt @@ -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, )