From db85c2234f9bc75a526caa5bf3dc26d604e80019 Mon Sep 17 00:00:00 2001 From: Oscar Mira Date: Mon, 4 Mar 2024 13:33:56 +0100 Subject: [PATCH] demo: minor UI improvements --- .../monero/demo/ui/AddWalletViewModel.kt | 127 +++++++++++------- .../molly/monero/demo/ui/AddWalletWizard.kt | 82 +++++------ .../im/molly/monero/demo/ui/AddressCard.kt | 2 +- .../im/molly/monero/demo/ui/RecipientList.kt | 8 +- .../im/molly/monero/demo/ui/RemoteNodeList.kt | 19 ++- 5 files changed, 142 insertions(+), 96 deletions(-) diff --git a/demo/android/src/main/kotlin/im/molly/monero/demo/ui/AddWalletViewModel.kt b/demo/android/src/main/kotlin/im/molly/monero/demo/ui/AddWalletViewModel.kt index 40c1c7c..2dae50b 100644 --- a/demo/android/src/main/kotlin/im/molly/monero/demo/ui/AddWalletViewModel.kt +++ b/demo/android/src/main/kotlin/im/molly/monero/demo/ui/AddWalletViewModel.kt @@ -1,10 +1,9 @@ package im.molly.monero.demo.ui -import androidx.compose.runtime.* +import androidx.compose.runtime.mutableStateMapOf import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import im.molly.monero.MoneroNetwork -import im.molly.monero.BlockchainTime import im.molly.monero.RestorePoint import im.molly.monero.SecretKey import im.molly.monero.demo.AppModule @@ -17,7 +16,6 @@ import im.molly.monero.util.parseHex import im.molly.monero.util.toHex import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.* -import kotlinx.coroutines.flow.SharingStarted.Companion.WhileSubscribed import kotlinx.coroutines.launch import java.time.LocalDate @@ -27,33 +25,24 @@ class AddWalletViewModel( private val walletRepository: WalletRepository = AppModule.walletRepository, ) : ViewModel() { - var network by mutableStateOf(DefaultMoneroNetwork) - private set + private val viewModelState = MutableStateFlow(AddWalletUiState()) - var walletName by mutableStateOf("") - private set - - var secretSpendKeyHex by mutableStateOf("") - private set - - var creationDate by mutableStateOf("") - private set - - var restoreHeight by mutableStateOf("") - private set + val uiState = viewModelState.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = viewModelState.value + ) val currentRemoteNodes: StateFlow> = - snapshotFlow { network } - .flatMapLatest { - remoteNodeRepository.getAllRemoteNodes( - filterNetworkIds = setOf(network.id), - ) - } - .stateIn( - scope = viewModelScope, - started = WhileSubscribed(5000), - initialValue = emptyList(), + uiState.flatMapLatest { + remoteNodeRepository.getAllRemoteNodes( + filterNetworkIds = setOf(it.network.id), ) + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = emptyList(), + ) val selectedRemoteNodes = mutableStateMapOf() @@ -73,21 +62,23 @@ class AddWalletViewModel( } fun toggleSelectedNetwork(network: MoneroNetwork) { - this.network = network + viewModelState.update { it.copy(network = network) } } fun updateWalletName(name: String) { - this.walletName = name + viewModelState.update { it.copy(walletName = name) } } fun updateSecretSpendKeyHex(value: String) { - this.secretSpendKeyHex = value + viewModelState.update { it.copy(secretSpendKeyHex = value) } } fun recoverFromMnemonic(words: String): Boolean { MoneroMnemonic.recoverEntropy(words)?.use { mnemonicCode -> val secretKey = SecretKey(mnemonicCode.entropy) - secretSpendKeyHex = secretKey.bytes.toHex() + viewModelState.update { + it.copy(secretSpendKeyHex = secretKey.bytes.toHex()) + } secretKey.destroy() return true } @@ -95,40 +86,76 @@ class AddWalletViewModel( } fun updateCreationDate(value: String) { - this.creationDate = value + viewModelState.update { it.copy(creationDate = value) } } fun updateRestoreHeight(value: String) { - this.restoreHeight = value + viewModelState.update { it.copy(restoreHeight = value) } } fun validateSecretSpendKeyHex(): Boolean = - secretSpendKeyHex.length == 64 && runCatching { secretSpendKeyHex.parseHex() }.isSuccess + with(viewModelState.value) { + return secretSpendKeyHex.length == 64 && runCatching { + secretSpendKeyHex.parseHex() + }.isSuccess + } fun validateCreationDate(): Boolean = - creationDate.isEmpty() || runCatching { RestorePoint.creationTime(LocalDate.parse(creationDate)) }.isSuccess + with(viewModelState.value) { + creationDate.isEmpty() || runCatching { + RestorePoint.creationTime(LocalDate.parse(creationDate)) + }.isSuccess + } fun validateRestoreHeight(): Boolean = - restoreHeight.isEmpty() || runCatching { RestorePoint.blockHeight(restoreHeight.toInt()) }.isSuccess + with(viewModelState.value) { + restoreHeight.isEmpty() || runCatching { + RestorePoint.blockHeight(restoreHeight.toInt()) + }.isSuccess + } - fun createWallet() = viewModelScope.launch { - walletRepository.addWallet(network, walletName, getSelectedRemoteNodeIds()) + fun createWallet() { + val state = viewModelState.getAndUpdate { it.copy(isInProgress = true) } + viewModelScope.launch { + walletRepository.addWallet(state.network, state.walletName, getSelectedRemoteNodeIds()) + viewModelState.update { it.copy(walletAdded = true) } + } } - fun restoreWallet() = viewModelScope.launch { - val restorePoint = when { - creationDate.isNotEmpty() -> RestorePoint.creationTime(LocalDate.parse(creationDate)) - restoreHeight.isNotEmpty() -> RestorePoint.blockHeight(restoreHeight.toInt()) - else -> RestorePoint.Genesis + fun restoreWallet() { + val state = viewModelState.getAndUpdate { + it.copy(isInProgress = true) } - SecretKey(secretSpendKeyHex.parseHex()).use { secretSpendKey -> - walletRepository.restoreWallet( - network, - walletName, - getSelectedRemoteNodeIds(), - secretSpendKey, - restorePoint - ) + viewModelScope.launch { + val restorePoint = when { + state.creationDate.isNotEmpty() -> + RestorePoint.creationTime(LocalDate.parse(state.creationDate)) + + state.restoreHeight.isNotEmpty() -> + RestorePoint.blockHeight(state.restoreHeight.toInt()) + + else -> RestorePoint.Genesis + } + SecretKey(state.secretSpendKeyHex.parseHex()).use { secretSpendKey -> + walletRepository.restoreWallet( + state.network, + state.walletName, + getSelectedRemoteNodeIds(), + secretSpendKey, + restorePoint + ) + } + viewModelState.update { it.copy(walletAdded = true) } } } } + +data class AddWalletUiState( + val network: MoneroNetwork = DefaultMoneroNetwork, + val walletName: String = "", + val secretSpendKeyHex: String = "", + val creationDate: String = "", + val restoreHeight: String = "", + val isInProgress: Boolean = false, + val walletAdded: Boolean = false, +) 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 ea36033..425db73 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 @@ -9,6 +9,7 @@ import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.tooling.preview.Preview @@ -88,8 +89,15 @@ fun AddWalletSecondStepRoute( ) { val context = LocalContext.current + val uiState by viewModel.uiState.collectAsStateWithLifecycle() val remoteNodes by viewModel.currentRemoteNodes.collectAsStateWithLifecycle() + LaunchedEffect(uiState) { + if (uiState.walletAdded) { + onNavigateToHome() + } + } + SecondStepScreen( showRestoreOptions = showRestoreOptions, modifier = modifier, @@ -100,15 +108,10 @@ fun AddWalletSecondStepRoute( } else { viewModel.createWallet() } - onNavigateToHome() }, - walletName = viewModel.walletName, - network = viewModel.network, - secretSpendKeyHex = viewModel.secretSpendKeyHex, + uiState = uiState, secretSpendKeyHexError = !viewModel.validateSecretSpendKeyHex(), - creationDate = viewModel.creationDate, creationDateError = !viewModel.validateCreationDate(), - restoreHeight = viewModel.restoreHeight, restoreHeightError = !viewModel.validateRestoreHeight(), onWalletNameChanged = { name -> viewModel.updateWalletName(name) }, onNetworkChanged = { network -> viewModel.toggleSelectedNetwork(network) }, @@ -133,14 +136,10 @@ private fun SecondStepScreen( modifier: Modifier = Modifier, onBackClick: () -> Unit = {}, onCreateClick: () -> Unit = {}, - walletName: String, - secretSpendKeyHex: String, - secretSpendKeyHexError: Boolean, - creationDate: String, - creationDateError: Boolean, - restoreHeight: String, - restoreHeightError: Boolean, - network: MoneroNetwork, + uiState: AddWalletUiState, + secretSpendKeyHexError: Boolean = false, + creationDateError: Boolean = false, + restoreHeightError: Boolean = false, onWalletNameChanged: (String) -> Unit = {}, onNetworkChanged: (MoneroNetwork) -> Unit = {}, onSecretSpendKeyHexChanged: (String) -> Unit = {}, @@ -172,10 +171,11 @@ private fun SecondStepScreen( .verticalScroll(rememberScrollState()), ) { OutlinedTextField( - value = walletName, + value = uiState.walletName, label = { Text("Wallet name") }, onValueChange = onWalletNameChanged, singleLine = true, + enabled = !uiState.isInProgress, modifier = Modifier .fillMaxWidth() .padding(start = 16.dp, end = 16.dp), @@ -183,10 +183,11 @@ private fun SecondStepScreen( SelectListBox( label = "Network", options = MoneroNetwork.entries.associateWith { it.name }, - selectedOption = network, + selectedOption = uiState.network, onOptionClick = { onNetworkChanged(it) }, + enabled = !uiState.isInProgress, modifier = Modifier .fillMaxWidth() .padding(16.dp), @@ -194,31 +195,36 @@ private fun SecondStepScreen( Text( text = "Remote node selection", style = MaterialTheme.typography.titleMedium, - modifier = Modifier.padding(16.dp), + modifier = (if (uiState.isInProgress) Modifier.alpha(0.3f) else Modifier) + .padding(16.dp), ) MultiSelectRemoteNodeList( remoteNodes = remoteNodes, selectedIds = selectedRemoteNodeIds, + enabled = !uiState.isInProgress, modifier = Modifier.padding(start = 16.dp), ) if (showRestoreOptions) { Text( text = "Deterministic wallet recovery", style = MaterialTheme.typography.titleMedium, - modifier = Modifier.padding(16.dp), + modifier = (if (uiState.isInProgress) Modifier.alpha(0.3f) else Modifier) + .padding(16.dp), ) OutlinedTextField( - value = secretSpendKeyHex, + value = uiState.secretSpendKeyHex, label = { Text("Secret spend key") }, onValueChange = onSecretSpendKeyHexChanged, singleLine = true, isError = secretSpendKeyHexError, + enabled = !uiState.isInProgress, modifier = Modifier .fillMaxWidth() .padding(start = 16.dp, end = 16.dp), ) TextButton( onClick = { showMnemonicDialog = true }, + enabled = !uiState.isInProgress, modifier = Modifier .padding(start = 16.dp), ) { @@ -230,23 +236,23 @@ private fun SecondStepScreen( modifier = Modifier.padding(16.dp), ) OutlinedTextField( - value = creationDate, + value = uiState.creationDate, label = { Text("Wallet creation date") }, onValueChange = onCreationDateChanged, singleLine = true, isError = creationDateError, - enabled = restoreHeight.isEmpty(), + enabled = uiState.restoreHeight.isEmpty() && !uiState.isInProgress, modifier = Modifier .fillMaxWidth() .padding(start = 16.dp, end = 16.dp), ) OutlinedTextField( - value = restoreHeight, + value = uiState.restoreHeight, label = { Text("Restore height") }, onValueChange = onRestoreHeightChanged, singleLine = true, isError = restoreHeightError, - enabled = creationDate.isEmpty(), + enabled = uiState.creationDate.isEmpty() && !uiState.isInProgress, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), modifier = Modifier .fillMaxWidth() @@ -268,10 +274,10 @@ private fun SecondStepScreen( onCreateClick() } }, - enabled = validInput, + enabled = validInput && !uiState.isInProgress, modifier = Modifier.padding(16.dp), ) { - Text("Finish") + Text(if (uiState.isInProgress) "Adding wallet..." else "Finish") } } } @@ -336,14 +342,11 @@ private fun CreateWalletScreenPreview() { AppTheme { SecondStepScreen( showRestoreOptions = false, - walletName = "Personal", - network = DefaultMoneroNetwork, - secretSpendKeyHex = "d2ca26e22489bd9871c910c58dee3ab08e66b9d566825a064c8c0af061cd8706", - secretSpendKeyHexError = false, - creationDate = "", - creationDateError = false, - restoreHeight = "", - restoreHeightError = false, + uiState = AddWalletUiState( + walletName = "Personal", + network = DefaultMoneroNetwork, + secretSpendKeyHex = "d2ca26e22489bd9871c910c58dee3ab08e66b9d566825a064c8c0af061cd8706", + ), remoteNodes = listOf(RemoteNode.EMPTY), selectedRemoteNodeIds = mutableMapOf(), ) @@ -356,14 +359,11 @@ private fun RestoreWalletScreenPreview() { AppTheme { SecondStepScreen( showRestoreOptions = true, - walletName = "Personal", - network = DefaultMoneroNetwork, - secretSpendKeyHex = "d2ca26e22489bd9871c910c58dee3ab08e66b9d566825a064c8c0af061cd8706", - secretSpendKeyHexError = false, - creationDate = "", - creationDateError = false, - restoreHeight = "", - restoreHeightError = false, + uiState = AddWalletUiState( + walletName = "Personal", + network = DefaultMoneroNetwork, + secretSpendKeyHex = "d2ca26e22489bd9871c910c58dee3ab08e66b9d566825a064c8c0af061cd8706", + ), remoteNodes = listOf(RemoteNode.EMPTY), selectedRemoteNodeIds = mutableMapOf(), ) 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 3ab5d3b..05b68d8 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 @@ -45,7 +45,7 @@ fun AddressCardExpanded( style = MaterialTheme.typography.bodyMedium, ) Text( - text = "Total Balance: ${totalAmount.toFormattedString(appendSymbol = true)}", + text = "Total balance: ${totalAmount.toFormattedString(appendSymbol = true)}", style = MaterialTheme.typography.bodySmall, ) Text( 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 index 0c341d9..7b9ae30 100644 --- 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 @@ -49,7 +49,11 @@ fun EditableRecipientList( onRecipientChange(updatedList) }, onDeleteItemClick = { - updatedList.removeAt(index) + if (updatedList.size > 1) { + updatedList.removeAt(index) + } else { + updatedList[0] = "" to "" + } onRecipientChange(updatedList) } ) @@ -104,7 +108,7 @@ fun PaymentDetailItem( ) IconButton( onClick = onDeleteItemClick, - enabled = enabled && itemIndex > 0, + enabled = enabled, modifier = Modifier.padding(top = 12.dp), ) { Icon(imageVector = Icons.Default.Delete, contentDescription = "Delete") diff --git a/demo/android/src/main/kotlin/im/molly/monero/demo/ui/RemoteNodeList.kt b/demo/android/src/main/kotlin/im/molly/monero/demo/ui/RemoteNodeList.kt index 8cffe07..c59110d 100644 --- a/demo/android/src/main/kotlin/im/molly/monero/demo/ui/RemoteNodeList.kt +++ b/demo/android/src/main/kotlin/im/molly/monero/demo/ui/RemoteNodeList.kt @@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.* import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.tooling.preview.Preview import im.molly.monero.demo.data.model.RemoteNode import im.molly.monero.demo.ui.theme.AppIcons @@ -14,6 +15,7 @@ fun MultiSelectRemoteNodeList( remoteNodes: List, selectedIds: MutableMap, modifier: Modifier = Modifier, + enabled: Boolean = true, ) { Column( modifier = modifier, @@ -27,6 +29,7 @@ fun MultiSelectRemoteNodeList( onCheckedChange = { checked -> selectedIds[remoteNode.id] = checked }, + enabled = enabled, ) } } else { @@ -63,17 +66,29 @@ private fun RemoteNodeItem( checked: Boolean = false, showCheckbox: Boolean = false, showMenu: Boolean = false, + enabled: Boolean = true, onCheckedChange: (Boolean) -> Unit = {}, onEditClick: (RemoteNode) -> Unit = {}, onDeleteClick: (RemoteNode) -> Unit = {}, ) { ListItem( - headlineContent = { Text(remoteNode.uri.toString()) }, - overlineContent = { Text(remoteNode.network.name.uppercase()) }, + headlineContent = { + Text( + text = remoteNode.uri.toString(), + modifier = (if (enabled) Modifier else Modifier.alpha(0.3f)), + ) + }, + overlineContent = { + Text( + text = remoteNode.network.name.uppercase(), + modifier = (if (enabled) Modifier else Modifier.alpha(0.3f)), + ) + }, trailingContent = { if (showCheckbox) { Checkbox( checked = checked, + enabled = enabled, onCheckedChange = onCheckedChange, ) }